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,23 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { DeathCauseEnum, DefaultCharacterName } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import styles from "./styles.module.css";
export interface CharacterNameProps extends HTMLAttributes<HTMLDivElement> {}
export const CharacterName: FC<CharacterNameProps> = ({
className,
...props
}) => {
const { deathCause, characterName } = useCharacterEngine();
return (
<h1 className={clsx([styles.characterName, className])} {...props}>
{deathCause !== DeathCauseEnum.NONE && "(Dead) "}
{characterName || DefaultCharacterName}
</h1>
);
};
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
import { GuidedTour } from "~/components/GuidedTour";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { getCharacterSheetSteps } from "./getCharacterSheetSteps";
export const CharacterSheetGuidedTour = ({ children, ...props }) => {
const [step, setStep] = useState(0);
const { characterSheetTourFlag } = useFeatureFlags();
const isMobile = window.matchMedia("(max-width: 767px)").matches;
const isTablet = window.matchMedia(
"(min-width: 768px and max-width: 1023px)"
).matches;
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
const ready = useSelector(rulesEngineSelectors.isCharacterSheetReady);
// const hasSpells = useSelector(rulesEngineSelectors.hasSpells);
const [loading, setLoading] = useState(true);
const setCurrentStep = (currentStep) => {
switch (currentStep) {
case 11: {
if (isMobile) {
const toggle = document.querySelector(
"[class^='styles_navToggle']"
) as HTMLButtonElement;
toggle?.click();
}
break;
}
case 13: {
if (isTablet) {
const toggle = document.querySelector(
"[class^='styles_navToggle']"
) as HTMLButtonElement;
toggle?.click();
}
break;
}
default:
break;
}
setStep(currentStep);
};
useEffect(() => {
// If character sheet has loaded, enable tour
if (ready) setLoading(false);
}, [ready]);
return (
<>
{characterSheetTourFlag && !loading ? (
<GuidedTour
steps={getCharacterSheetSteps(false, isTablet, isDesktop)}
step={step}
setStep={setCurrentStep}
showOnFirstLoad={true}
cookieName="characterSheetGuidedTour"
{...props}
>
{children}
</GuidedTour>
) : (
<>{children}</>
)}
</>
);
};
@@ -0,0 +1,457 @@
import { StepType } from "@reactour/tour";
import { GuidedTourStep } from "~/components/GuidedTourStep";
/**
* CHARACTER SHEET TOUR STEPS
**/
export const getCharacterSheetSteps = (
hasSpells: boolean,
isTablet: boolean,
isDesktop: boolean
): StepType[] => {
const getSelector = (
mobileSelector: string,
tabletSelector?: string,
desktopSelector?: string
) =>
isDesktop && desktopSelector
? desktopSelector
: isTablet && tabletSelector
? tabletSelector
: mobileSelector;
const stepList = [
{
selector: getSelector("html"),
content: (
<GuidedTourStep
title="Welcome!"
content={
<>
Welcome to your character sheet! Here, you can find information
about your character, roll dice, and find more information about
other characters in your game. If you ever have any questions
about rules or what something is for, you can click on almost
anything to learn more!
</>
}
showClose
/>
),
},
{
selector: getSelector(".dice-toolbar"),
content: (
<GuidedTourStep
title="Digital Dice"
content={
<>
Click here if you need to make a custom dice roll. Your dice
collection can be found{" "}
<a
href="https://www.dndbeyond.com/my-dice"
target="_blank"
rel="noopener"
>
here
</a>
.
</>
}
/>
),
},
{
selector: getSelector(
".ct-character-header-mobile__group-tidbits",
".ct-character-header-tablet__group-tidbits",
".ct-character-header-desktop__group-tidbits"
),
content: (
<GuidedTourStep
title="Character Overview"
content={
<>
Basic information about your character such as Name, Species,
Class, and Level can be found here. Click this area to change your
character's name, sheet styles, and modify other settings for your
character.
</>
}
/>
),
},
{
selector: ".ct-character-header-desktop__group--builder",
content: (
<GuidedTourStep
title="Character Builder"
content={
<>
Click here to visit the Character Builder. Each time you level up,
check here to see how to develop your character.
</>
}
/>
),
},
{
selector: getSelector(
".ct-main-mobile__abilities",
".ct-main-tablet__abilities",
".ct-quick-info__abilities"
),
content: (
<GuidedTourStep
title="Ability Scores"
content={
<>
Much of what your character does in the game depends on his or her
six abilities: Strength, Dexterity, Constitution, Intelligence,
Wisdom, and Charisma. Learn more about an ability by clicking on
it.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--proficiency",
".ct-combat-tablet__extra--proficiency",
".ct-quick-info__box--proficiency"
),
content: (
<GuidedTourStep
title="Proficiency Bonus"
content={
<>
Proficiency is added to rolls made to accomplish tasks with which
your character is proficient. The bonus is automatically added to
rolls when it is needed. Click here to learn more about how
Proficiency Bonuses are used.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--speed",
".ct-combat-tablet__extra--speed",
".ct-quick-info__box--speed"
),
content: (
<GuidedTourStep
title="Speed"
content={
<>
In each round of combat, your character can move up to the total
distance indicated by your speed. Click here to learn more or
customize your speed.
</>
}
/>
),
},
{
selector: getSelector(
".ct-status-summary-mobile__health",
".ct-status-summary-mobile__health",
".ct-health-summary"
),
content: (
<GuidedTourStep
title="Hit Points"
content={
<>
In this area, you can manage your character's hit points. When
your character reaches zero hit points, they are on the brink of
death and begin making death saving throws.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--initiative",
".ct-combat-tablet__extra--initiative",
".ct-combat__summary-group--initiative"
),
content: (
<GuidedTourStep
title="Initiative"
content={
<>
Initiative rolls determine the order in which you go in combat.
The higher the number, the sooner you get to fight. Click here to
roll initiative!
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--ac",
".ct-combat-tablet__extra--ac",
".ct-combat__summary-group--ac"
),
content: (
<GuidedTourStep
title="Armor Class"
content={
<>
Your Armor Class (AC) represents how well your character avoids
being wounded in battle.
</>
}
/>
),
},
{
selector: getSelector(
".ct-main-mobile__saving-throws",
".ct-saving-throws-box",
".ct-saving-throws-box"
),
content: (
<GuidedTourStep
title="Saving Throws"
content={
<>
Your DM may ask you to roll a saving throw (or make a save) to
resist an incoming effect. Click on a save to automatically roll.
</>
}
/>
),
},
...(isDesktop || isTablet
? [
{
selector: ".ct-proficiency-groups-box",
content: (
<GuidedTourStep
title="Proficiencies"
content={
<>
Proficiencies tell you what tools, equipment, and languages
your character is skilled at using.
</>
}
/>
),
},
{
selector: ".ct-skills-box",
content: (
<GuidedTourStep
title="Skills"
content={
<>
If you wish to perform an action, your DM will determine
which of these skills you will use. Click on a skill to roll
or learn more.
</>
}
/>
),
},
]
: [
{
selector: ".ct-quick-nav__menu-item--skills",
content: (
<GuidedTourStep
title="Skills"
content={
<>
If you wish to perform an action, your DM will determine
which of these skills you will use. Click on a skill to roll
or learn more.
</>
}
/>
),
},
]),
...(!isDesktop
? [
{
selector: ".ct-quick-nav__menu-item--actions",
content: (
<GuidedTourStep
title="Actions"
content={
<>
When you take your action on your turn, you can take one of
the actions presented here. You can track actions or make
rolls to perform them by clicking in this panel.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--equipment",
content: (
<GuidedTourStep
title="Inventory"
content={
<>
View and manage your character's items and coin from this
panel.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--spells",
content: (
<GuidedTourStep
title="Spells"
content={
<>
If your character has the ability to cast spells, look here
for a list of spells and to track your spellcasting.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--features",
content: (
<GuidedTourStep
title="Features & Traits"
content={
<>
This section describes the source of your character's
abilities. How this manifests in your character's
personality is up to you.
</>
}
/>
),
},
...(!isTablet
? [
{
selector: ".ct-quick-nav__menu-item--proficiencies",
content: (
<GuidedTourStep
title="Proficiencies"
content={
<>
Proficiencies tell you what tools, equipment, and
languages your character is skilled at using.
</>
}
/>
),
},
]
: []),
{
selector: ".ct-quick-nav__menu-item--description",
content: (
<GuidedTourStep
title="Description"
content={
<>
Use the description panel to tell your character's story.
Your character's background features are also found here.
</>
}
showClose
/>
),
},
]
: [
{
selector: ".ct-primary-box__tab--actions",
content: (
<GuidedTourStep
title="Actions"
content={
<>
When you take your action on your turn, you can take one of
the actions presented here. You can track actions or make
rolls to perform them by clicking in this panel.
</>
}
/>
),
},
...(hasSpells
? [
{
selector: ".ct-primary-box__tab--spells",
content: (
<GuidedTourStep
title="Spells"
content={
<>
If your character has the ability to cast spells, look
here for a list of spells and to track your
spellcasting.
</>
}
/>
),
},
]
: []),
{
selector: ".ct-primary-box__tab--equipment",
content: (
<GuidedTourStep
title="Inventory"
content={
<>
View and manage your character's items and coin from this
panel.
</>
}
/>
),
},
{
selector: ".ct-primary-box__tab--features",
content: (
<GuidedTourStep
title="Features & Traits"
content={
<>
This section describes the source of your character's
abilities. How this manifests in your character's
personality is up to you.
</>
}
/>
),
},
{
selector: ".ct-primary-box__tab--description",
content: (
<GuidedTourStep
title="Description"
content={
<>
Use the description panel to tell your character's story.
Your character's background features are also found here.
</>
}
showClose
/>
),
},
]),
];
return stepList.filter((s) => s);
};
@@ -0,0 +1,108 @@
import clsx from "clsx";
import { FC, useEffect, useState } from "react";
import ChevronIcon from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Button } from "~/components/Button";
import { Dialog, DialogProps } from "~/components/Dialog";
import characterGalleryImg from "~/images/character-gallery.png";
import characterSheetImg from "~/images/character-sheet.png";
import tadaEmoji from "~/images/tada-emoji.svg?url";
import styles from "./styles.module.css";
export interface ClaimConfirmationDialogProps
extends Omit<DialogProps, "onClose" | "open"> {
characterId: number;
campaignId?: number | null;
}
export const ClaimConfirmationDialog: FC<ClaimConfirmationDialogProps> = ({
className,
characterId,
campaignId,
...props
}) => {
const [isOpen, setIsOpen] = useState(true);
const handleClose = () => {
window.location.href = `/characters/${characterId}`;
};
useEffect(() => {
if (characterId) setIsOpen(true);
}, [characterId]);
return (
<Dialog
className={clsx([styles.dialog, className])}
open={isOpen}
onClose={handleClose}
modal
{...props}
>
<header className={styles.header}>
<h2 className={styles.title}>
<img src={tadaEmoji} alt="Ta-da Emoji" />
Congratulations!
</h2>
<Button
className={styles.closeButton}
variant="tool"
onClick={handleClose}
aria-label="Close Modal"
>
<CloseIcon />
</Button>
</header>
<div className={styles.content}>
<p className={styles.text}>
Your character has been claimed and is now available:
</p>
<div className={styles.card}>
<img
className={styles.cardImage}
src={characterGalleryImg}
alt="Premade character gallery"
/>
<Button
className={styles.cardButton}
href="/characters"
color="primary"
size="x-small"
>
Go to My Characters <ChevronIcon />
</Button>
</div>
<div className={styles.card}>
<img
className={styles.cardImage}
src={characterSheetImg}
alt="Sample character sheet"
/>
<Button
className={styles.cardButton}
href={`/characters/${characterId}`}
color="info"
size="x-small"
>
View Your Character <ChevronIcon />
</Button>
</div>
</div>
<footer className={styles.footer}>
{campaignId && (
<Button
href={`/campaigns/${campaignId}`}
variant="text"
color="info"
size="small"
>
or return to your campaign
</Button>
)}
</footer>
</Dialog>
);
};
@@ -0,0 +1,87 @@
import clsx from "clsx";
import { FC, useState } from "react";
import { useSelector } from "react-redux";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { Button, ButtonProps } from "~/components/Button";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import { useAuth } from "~/contexts/Authentication";
import { useClaimCharacter } from "~/hooks/useClaimCharacter";
import bgImage from "~/images/claim.png";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ClaimConfirmationDialog } from "../ClaimConfirmationDialog";
import styles from "./styles.module.css";
/**
* This component is used when previewing a premade character. It is displayed on the character
* sheet and renders with an onclick when signed in or a link to create an account when not signed
* in.
*/
export const ClaimPremadeButton: FC<ButtonProps> = ({
className,
...props
}) => {
const createAccountLink = `/create-account?returnUrl=${window.location.pathname}`;
const params = new URLSearchParams(globalThis.location.search);
const campaignJoinCode = params.get("campaignJoinCode");
const isAssigned = params.get("isAssigned") === "true";
const [isMaxCharacterOpen, setIsMaxCharacterOpen] = useState(false);
const user = useAuth();
const { characterSlotLimit, activeCharacterCount } = useSelector(
appEnvSelectors.getCharacterSlots
);
const [
claimCharacter,
isClaimingCharacter,
isFinishedClaimingCharacter,
newCharacterId,
campaignId,
] = useClaimCharacter({
campaignJoinCode,
isAssigned,
});
const isClaimed = isFinishedClaimingCharacter && !!newCharacterId;
// Character slot limit is null for admin accounts
const hasOpenSlot =
characterSlotLimit === null || activeCharacterCount < characterSlotLimit;
const handleClick = () => {
hasOpenSlot ? claimCharacter() : setIsMaxCharacterOpen(true);
};
return (
<>
{!isClaimed && (
<Button
className={clsx([styles.claimPremadeButton, className])}
href={!user ? createAccountLink : undefined}
onClick={!!user ? handleClick : undefined}
disabled={isClaimingCharacter}
tabIndex={0}
{...props}
>
<img className={styles.bgImage} src={bgImage} alt="" />
<div className={styles.content}>
{!user && "Create Account to "}
Claim Character <ChevronRight />
</div>
</Button>
)}
<MaxCharactersDialog
open={isMaxCharacterOpen}
onClose={() => setIsMaxCharacterOpen(false)}
useMyCharactersLink
/>
{isClaimed && (
<ClaimConfirmationDialog
characterId={newCharacterId}
campaignId={campaignId}
/>
)}
</>
);
};
@@ -0,0 +1,148 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useSearchParams } from "react-router-dom";
import {
CampaignUtils,
CharacterCurrencyContract,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import CurrencyButton from "~/tools/js/CharacterSheet/components/CurrencyButton";
import { CoinManagerContext } from "~/tools/js/Shared/managers/CoinManagerContext";
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { PaneComponentEnum } from "../../Sidebar/types";
import styles from "./styles.module.css";
export interface EquipmentOverviewProps extends HTMLAttributes<HTMLDivElement> {
activeEquipmentTab: "character" | "party";
}
export const EquipmentOverview: FC<EquipmentOverviewProps> = ({
className,
activeEquipmentTab,
...props
}) => {
const {
partyInfo,
currencies,
totalCarriedWeight,
currentCarriedWeightType,
} = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
const {
pane: { paneHistoryStart },
} = useSidebar();
const { coinManager } = useContext(CoinManagerContext);
const { inventoryManager } = useContext(InventoryManagerContext);
const [searchParams] = useSearchParams();
const isVttView = searchParams.get("view") === "vtt";
const isPartyTab = activeEquipmentTab === "party";
const campaignName = partyInfo ? CampaignUtils.getName(partyInfo) : null;
const weightSpeedLabel = RuleDataUtils.getWeightSpeedTypeLabel(
currentCarriedWeightType
);
let coin: CharacterCurrencyContract | null = currencies;
//if "coins in containers" is enabled, get the total coin for either the player or party
if (isPartyTab && partyInfo) {
coin = CampaignUtils.getCoin(partyInfo);
}
if (coinManager?.canUseCointainers()) {
coin =
isPartyTab && partyInfo
? coinManager.getAllPartyCoin()
: coinManager.getAllCharacterCoin();
}
const handlePrimaryButtonClick = (
evt: React.MouseEvent | React.KeyboardEvent
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (isPartyTab) {
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
} else {
paneHistoryStart(PaneComponentEnum.ENCUMBRANCE);
}
};
const handleCurrencyClick = (
evt: React.MouseEvent | React.KeyboardEvent
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
const partyContainerKey =
inventoryManager.getPartyEquipmentContainerDefinitionKey();
const characterContainerKey =
inventoryManager.getCharacterContainerDefinitionKey();
paneHistoryStart(
PaneComponentEnum.CURRENCY,
PaneIdentifierUtils.generateCurrencyContext(
isPartyTab && partyContainerKey
? partyContainerKey
: characterContainerKey
)
);
};
return (
<div className={clsx([styles.equipmentOverview, className])} {...props}>
{isPartyTab ? (
!isVttView ? (
<Button
variant="text"
themed
className={styles.overviewPrimaryButton}
onClick={handlePrimaryButtonClick}
size="x-small"
>
{`Campaign: ${campaignName}`}
</Button>
) : (
<p className={styles.overviewText}>{`Campaign: ${campaignName}`}</p>
)
) : (
<Button
variant="text"
themed
className={styles.overviewPrimaryButton}
onClick={handlePrimaryButtonClick}
size="x-small"
>
<div className={styles.weightButton}>
<span className={styles.weightButtonText}>
Weight Carried:{" "}
<NumberDisplay
className={styles.weight}
data-testid="weight-carried-number"
type="weightInLb"
number={totalCarriedWeight}
/>
</span>
<span className={styles.weightSecondaryText}>
{weightSpeedLabel}
</span>
</div>
</Button>
)}
<CurrencyButton
handleCurrencyClick={handleCurrencyClick}
coin={coin}
isDarkMode={isDarkMode}
shouldShowPartyInventory={isPartyTab}
/>
</div>
);
};
@@ -1,5 +1,5 @@
import { keyBy } from "lodash";
import React from "react";
import React, { PropsWithChildren } from "react";
import { Snippet } from "@dndbeyond/character-components/es";
import {
@@ -23,16 +23,18 @@ import {
} from "@dndbeyond/character-rules-engine/es";
import { Reference } from "~/components/Reference";
import FeatureSnippetActions from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetActions";
import FeatureSnippetChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetChoices";
import FeatureSnippetInfusionChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetInfusionChoices";
import FeatureSnippetOption from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetOption";
import FeatureSnippetSpells from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetSpells";
import {
FeatureSnippetActions,
FeatureSnippetChoices,
FeatureSnippetInfusionChoices,
FeatureSnippetOption,
FeatureSnippetSpells,
} from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import styles from "./styles.module.css";
//TODO: tools/js/CharacterSheet/components/FeatureSnipper components still need to be gradually migrated to FC
interface Props {
interface Props extends PropsWithChildren {
heading: React.ReactNode;
dataOriginExtra?: string;
extraMeta: Array<string>;
@@ -1,9 +1,10 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
@@ -1,9 +1,10 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import accessibility from "~/styles/accessibility.module.css";
import { HP_TEMP_VALUE } from "~/subApps/sheet/constants";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
@@ -1,34 +1,36 @@
import { visuallyHidden } from "@mui/utils";
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import {
AdvantageIcon,
BoxBackground,
DigitalDiceWrapper,
InitiativeBoxSvg,
} from "@dndbeyond/character-components/es";
import { DiceTools, RollKind, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import {
RollKinds,
RollTypes,
} from "@dndbeyond/pocket-dimension-dice/constants";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import a11yStyles from "~/styles/accessibility.module.css";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript/utils";
import { PaneComponentEnum } from "../Sidebar/types";
import styles from "./styles.module.css";
import { NumberDisplay } from "~/components/NumberDisplay";
interface Props extends HTMLAttributes<HTMLDivElement> {
isMobile?: boolean;
isTablet?: boolean;
}
export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
@@ -40,9 +42,10 @@ export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
const {
hasInitiativeAdvantage,
processedInitiative: initiative,
processedInitiative,
characterTheme: theme,
} = useCharacterEngine();
const initiative = Number(processedInitiative);
const { isDarkMode } = useCharacterTheme();
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
@@ -71,35 +74,32 @@ export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
{!isMobile && (
<>
<BoxBackground StyleComponent={InitiativeBoxSvg} theme={theme} />
<h2 style={visuallyHidden}>Initiative</h2>
<h2 className={a11yStyles.screenreaderOnly}>Initiative</h2>
</>
)}
<DigitalDiceWrapper
diceNotation={DiceTools.CustomD20(initiative)}
rollType={RollType.Roll}
rollAction={"Initiative"}
rollKind={hasInitiativeAdvantage ? RollKind.Advantage : RollKind.None}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions?.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
diceNotation={`1d20${
initiative > 0
? `+${initiative}`
: initiative !== 0
? initiative
: ""
}`}
rollType={RollTypes.Roll}
action="Initiative"
rollKind={hasInitiativeAdvantage ? RollKinds.Advantage : undefined}
isDiceEnabled={diceEnabled}
entityId={String(rollContext.entityId)}
entityType={String(rollContext.entityType)}
name={String(rollContext.name)}
>
<NumberDisplay
number={initiative}
type="signed"
size="large"
className={clsx([
isMobile && !theme.isDarkMode && styles.numberColorOverride,
])}
number={initiative}
type="signed"
size="large"
className={clsx([
isMobile && !theme.isDarkMode && styles.numberColorOverride,
])}
/>
</DigitalDiceWrapper>
</div>
@@ -0,0 +1,135 @@
import clsx from "clsx";
import { FC, HTMLAttributes, MouseEvent } from "react";
import {
characterActions,
Item,
ItemPlan,
ItemPlanUtils,
ItemUtils,
} from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import ItemPreview from "~/tools/js/smartComponents/ItemPreview";
import { PaneComponentEnum } from "../Sidebar/types";
import styles from "./styles.module.css";
export interface MagicItemPlansProps extends HTMLAttributes<HTMLDivElement> {}
export const MagicItemPlans: FC<MagicItemPlansProps> = ({
className,
...props
}) => {
const { characterTheme, itemPlans, maxReplicatedItemsCount } =
useCharacterEngine();
const {
pane: { paneHistoryStart },
} = useSidebar();
const dispatch = useDispatch();
const itemPlanWithMappingsCount = itemPlans.filter((plan) =>
ItemPlanUtils.getItem(plan)
).length;
const atMax =
maxReplicatedItemsCount !== null &&
itemPlanWithMappingsCount >= maxReplicatedItemsCount;
const handleItemPlansManagePaneShow = (
e: MouseEvent<HTMLButtonElement>
): void => {
e.preventDefault();
e.stopPropagation();
paneHistoryStart(PaneComponentEnum.ITEM_PLANS_MANAGE);
};
const handleItemClick = (item: Item): void => {
const inventoryMappingId = ItemUtils.getMappingId(item);
if (inventoryMappingId) {
paneHistoryStart(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(inventoryMappingId)
);
}
};
const handleReplicateItem = (itemPlan: ItemPlan): void => {
const itemDefinitionKey = ItemPlanUtils.getItemDefinitionKey(itemPlan);
const itemPlanOriginDefinitionKey =
ItemPlanUtils.getOriginDefinitionKey(itemPlan);
if (itemDefinitionKey) {
dispatch(
characterActions.itemPlanInventoryMappingCreate(
itemDefinitionKey,
1,
itemPlanOriginDefinitionKey
)
);
}
};
return (
<div className={clsx([styles.magicItemPlans, className])} {...props}>
{itemPlans.length > 0 ? (
itemPlans.map((plan) => {
const planOriginDefinitionKey =
ItemPlanUtils.getOriginDefinitionKey(plan);
const planName = ItemPlanUtils.getItemName(plan);
const itemDefinitionKey = ItemPlanUtils.getItemDefinitionKey(plan);
const item = ItemPlanUtils.getItem(plan);
const planDescription = ItemPlanUtils.getDescription(plan);
return itemDefinitionKey ? (
<div className={styles.itemPlan} key={planOriginDefinitionKey}>
<div className={styles.planHeader}>
<p className={styles.planName}>{planName}</p>
<p className={styles.description}>{planDescription}</p>
</div>
<div className={styles.itemContent}>
{item ? (
<div className={styles.itemPreview}>
<ItemPreview
theme={characterTheme}
item={item}
onClick={() => handleItemClick(item)}
/>
</div>
) : (
<Button
className={styles.replicateButton}
themed
size="xx-small"
onClick={() => handleReplicateItem(plan)}
disabled={atMax}
>
{atMax ? "At Max Replicated Items" : "Replicate Item"}
</Button>
)}
</div>
</div>
) : null;
})
) : (
<div className={styles.empty}>
No Magic Item plans have been selected for this character
<Button
className={styles.emptyButton}
themed
size="x-small"
onClick={handleItemPlansManagePaneShow}
>
Add Magic Item Plans
</Button>
</div>
)}
</div>
);
};
@@ -4,8 +4,8 @@ import { createPortal } from "react-dom";
import { useSelector } from "react-redux";
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
import { Dialog, DialogProps } from "~/components/Dialog";
import { useSheetContext } from "~/subApps/sheet/contexts/Sheet";
import {
ActionsIcon,
@@ -0,0 +1,39 @@
import clsx from "clsx";
import { FC } from "react";
import GearIcon from "@dndbeyond/fontawesome-cache/svgs/solid/gear.svg";
import { Button, ButtonProps } from "~/components/Button";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneComponentEnum } from "../Sidebar/types";
import styles from "./styles.module.css";
export interface SettingsButtonProps extends ButtonProps {
context: string;
}
export const SettingsButton: FC<SettingsButtonProps> = ({
className,
context,
...props
}) => {
const { pane } = useSidebar();
const { SETTINGS } = PaneComponentEnum;
const handleClick = () => pane?.paneHistoryPush(SETTINGS, { context });
return (
<Button
className={clsx([styles.settingsButton, className])}
onClick={handleClick}
variant="text"
size="small"
themed
{...props}
>
<GearIcon />
Settings
</Button>
);
};
@@ -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",
@@ -0,0 +1,144 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import VideoIcon from "@dndbeyond/fontawesome-cache/svgs/solid/video.svg";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Button } from "~/components/Button";
import { Dialog } from "~/components/Dialog";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import { useAuth } from "~/contexts/Authentication";
import { useClaimCharacter } from "~/hooks/useClaimCharacter";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ClaimConfirmationDialog } from "../ClaimConfirmationDialog";
import styles from "./styles.module.css";
export interface WatchTourDialogProps extends HTMLAttributes<HTMLDivElement> {}
export const WatchTourDialog: FC<WatchTourDialogProps> = ({
className,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isMaxCharacterMessageOpen, setIsMaxCharacterMessageOpen] =
useState(false);
const user = useAuth();
const { characterSlotLimit, activeCharacterCount } = useSelector(
appEnvSelectors.getCharacterSlots
);
// Character slot limit is null for admin accounts
const hasOpenSlot =
characterSlotLimit === null || activeCharacterCount < characterSlotLimit;
const signedIn = !!user;
const signupLink = `/create-account?returnUrl=${window.location.pathname}`;
const params = new URLSearchParams(globalThis.location.search);
const campaignJoinCode = params.get("campaignJoinCode");
const isAssigned = params.get("isAssigned") !== "false";
const [
claimCharacter,
isClaimingCharacter,
isFinishedClaimingCharacter,
newCharacterId,
campaignId,
] = useClaimCharacter({
campaignJoinCode,
isAssigned,
});
const handleOpen = () => setIsOpen(true);
const handleClose = () => setIsOpen(false);
const handleClaim = () => {
if (hasOpenSlot) {
claimCharacter();
} else {
setIsMaxCharacterMessageOpen(true);
setIsOpen(false);
}
};
useEffect(() => {
if (isFinishedClaimingCharacter) {
setIsOpen(false);
}
}, [isFinishedClaimingCharacter]);
return (
<>
<div className={clsx([styles.watchTourDialog, className])} {...props}>
<Button
className={styles.trigger}
onClick={handleOpen}
color="secondary"
size="x-small"
>
<VideoIcon />
Watch Tour
</Button>
<Dialog
className={styles.dialog}
open={isOpen}
onClose={handleClose}
modal
>
<header className={styles.header}>
<h2 className={styles.title}>D&D Beyond Character Sheet Tour</h2>
<Button
className={styles.closeButton}
variant="tool"
onClick={handleClose}
aria-label="Close Modal"
>
<CloseIcon />
</Button>
</header>
<div className={styles.content}>
<div className={styles.videoWrapper}>
<iframe
className={styles.video}
src="https://www.youtube-nocookie.com/embed/ChYPIdCrBdE"
title="D&D Beyond Sheet Tour"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
/>
</div>
<p>
For a complete guide to getting your game started, see{" "}
<a href="https://www.dndbeyond.com/posts/754-how-to-play-dungeons-dragons-using-d-d-beyond">
How to Play Dungeons & Dragons Using D&D Beyond
</a>
.
</p>
</div>
<footer className={styles.footer}>
<Button
className={styles.claimButton}
{...(!signedIn && { href: signupLink })}
{...(signedIn && { onClick: handleClaim })}
disabled={isClaimingCharacter}
color="info"
size="x-small"
>
Claim Character
</Button>
</footer>
</Dialog>
</div>
<MaxCharactersDialog
open={isMaxCharacterMessageOpen}
onClose={() => setIsMaxCharacterMessageOpen(false)}
useMyCharactersLink
/>
{isFinishedClaimingCharacter && !!newCharacterId && (
<ClaimConfirmationDialog
characterId={newCharacterId ?? 0}
campaignId={campaignId}
/>
)}
</>
);
};
@@ -1,4 +1,10 @@
import { createContext, FC, useContext, useState } from "react";
import {
createContext,
FC,
PropsWithChildren,
useContext,
useState,
} from "react";
import { MobileSections } from "../../types";
@@ -11,7 +17,7 @@ export interface SheetContextType {
export const SheetContext = createContext<SheetContextType>(null!);
export const SheetProvider: FC = ({ children }) => {
export const SheetProvider: FC<PropsWithChildren> = ({ children }) => {
const [mobileActiveSectionId, setMobileActiveSectionId] =
useState<MobileSections>("main");
const [swipedAmount, setSwipedAmount] = useState(0);