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
@@ -1,9 +1,9 @@
import { orderBy } from "lodash";
import { FC, Fragment, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { v4 as uuidv4 } from "uuid";
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
import LinkIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-up-right-from-square.svg";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
@@ -13,6 +13,7 @@ import { HtmlContent } from "~/components/HtmlContent";
import { Reference } from "~/components/Reference";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
import styles from "./styles.module.css";
@@ -97,7 +98,7 @@ export const ConfirmClassModal: FC<ConfirmClassModalProps> = ({
target="_blank"
rel="noreferrer"
>
{confirmClass.name} Details Page <ArrowRightIcon />
{confirmClass.name} Details Page <LinkIcon />
</Button>
</div>
)}
@@ -1,6 +1,5 @@
import clsx from "clsx";
import React, { FC } from "react";
import { useDispatch } from "react-redux";
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
@@ -11,6 +10,7 @@ import { SummaryList } from "~/components/SummaryList";
import { AppContextTypeEnum, DisplayConfigurationTypeEnum } from "~/constants";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { Feat, RacialTraitContract as RacialTrait } from "~/types";
import { useSpeciesContext } from "../../contexts/Species";
@@ -41,7 +41,9 @@ export const EditorWithDialog: FC<EditorWithDialogProps> = ({
const [isOpen, setIsOpen] = useState(false);
const textareaInput = useRef<HTMLDivElement>(null);
const id = uuidv4();
const headingNode = cloneElement(heading, { className: styles.heading });
const headingNode = cloneElement(heading, { className: styles.heading } as {
className: string;
});
const handleInputBlur = (content: string): void => {
if (saveOnBlur) {
@@ -1,6 +1,5 @@
import clsx from "clsx";
import { FC, FocusEvent, useMemo, useState } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
@@ -14,6 +13,7 @@ import {
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import {
HP_BASE_MAX_VALUE,
HP_BONUS_VALUE,
@@ -3,6 +3,7 @@ import {
ChangeEvent,
FC,
FocusEvent,
FormEvent,
HTMLAttributes,
HTMLInputTypeAttribute,
useEffect,
@@ -10,6 +11,7 @@ import {
} from "react";
import { v4 as uuidv4 } from "uuid";
import formStyles from "../../styles/form.module.css";
import styles from "./styles.module.css";
interface InputProps extends HTMLAttributes<HTMLInputElement> {
@@ -18,14 +20,23 @@ interface InputProps extends HTMLAttributes<HTMLInputElement> {
autoComplete?: string;
}
export interface InputFieldProps extends HTMLAttributes<HTMLInputElement> {
export type InputElements = HTMLInputElement | HTMLTextAreaElement;
export interface InputFieldProps
extends Omit<
HTMLAttributes<HTMLInputElement>,
"onBlur" | "onChange" | "onFocus"
> {
errorMessage?: string;
initialValue?: string | number | null;
label: string;
maxLength?: number;
placeholder?: string;
type?: HTMLInputTypeAttribute;
type?: HTMLInputTypeAttribute | "textarea";
inputProps?: InputProps;
onBlur?: (event: FocusEvent<InputElements, Element>) => void;
onChange?: (event: FormEvent<InputElements>) => void;
onFocus?: (event: FocusEvent<InputElements, Element>) => void;
}
export const InputField: FC<InputFieldProps> = ({
@@ -49,7 +60,7 @@ export const InputField: FC<InputFieldProps> = ({
const [showError, setShowError] = useState(false);
const id = props.id ?? `input-field-${uuidv4()}`;
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: ChangeEvent<InputElements>) => {
const valueLength = e.target.value.length;
if (maxLength) {
// If the value length exceeds maxLength, show error
@@ -75,7 +86,7 @@ export const InputField: FC<InputFieldProps> = ({
return;
};
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
const handleBlur = (e: FocusEvent<InputElements>) => {
const intValue = parseInt(e.target.value);
// If entered value is below the minimum, reset to minimum
@@ -103,7 +114,7 @@ export const InputField: FC<InputFieldProps> = ({
setShowError(false);
};
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
const handleFocus = (e: FocusEvent<InputElements>) => {
// Call the provided onFocus handler if it exists
if (onFocus) onFocus(e);
// Check for maxLength error
@@ -122,18 +133,29 @@ export const InputField: FC<InputFieldProps> = ({
return (
<div className={clsx([styles.inputField, className])} {...props}>
<label className={styles.label} htmlFor={id}>
<label className={formStyles.label} htmlFor={id}>
{label}
</label>
<input
className={styles.input}
value={value ?? ""}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
{...inputAttrs}
{...inputProps}
/>
{type !== "textarea" ? (
<input
className={formStyles.input}
value={value ?? ""}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
{...inputAttrs}
{...inputProps}
/>
) : (
<textarea
className={styles.input}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
>
{value}
</textarea>
)}
{showError && <span className={styles.error}>{errorMessage}</span>}
</div>
);
@@ -1,6 +1,8 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { NoResultsFound } from "~/components/NoResultsFound";
import { GroupedListingItem, ListingItem } from "../../types";
import { ListingGroup } from "./ListingGroup";
import { ListingItemButton } from "./ListingItemButton/ListingItemButton";
@@ -50,7 +52,7 @@ export const Listing: FC<ListingProps> = ({
);
})
) : (
<p className={styles.notFound}>No Listings Found</p>
<NoResultsFound message="No listings found." />
)}
</div>
);
@@ -82,6 +82,7 @@ export const ListingGroup: FC<ListingGroupProps> = ({
isDisabled={disabledIds && disabledIds.includes(item.id)}
key={item.id}
onQuickSelect={onQuickSelect}
data-testid="optionButton"
/>
))}
</div>
@@ -1,6 +1,6 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { CharacterPortraitContract } from "@dndbeyond/character-rules-engine";
import ShuffleIcon from "@dndbeyond/fontawesome-cache/svgs/solid/shuffle.svg";
@@ -14,6 +14,7 @@ import {
DefaultCharacterName,
} from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { builderSelectors } from "~/tools/js/CharacterBuilder/selectors";
import { PortraitManager } from "~/tools/js/Shared/containers/panes/DecoratePane";
@@ -188,18 +189,6 @@ export const PortraitName: FC<Props> = ({
))}
</div>
</div>
<div className={styles.credit}>
Names by
<Button
className={styles.creditLink}
href="http://www.fantasynamegenerators.com/"
target="_blank"
rel="noreferrer"
variant="text"
>
<span className={styles.creditText}>Fantasy Name Generators</span>
</Button>
</div>
</div>
)}
</div>
@@ -1,5 +1,6 @@
import clsx from "clsx";
import { ChangeEvent, FC, HTMLAttributes } from "react";
import { v4 as uuid4 } from "uuid";
import styles from "./styles.module.css";
@@ -17,6 +18,7 @@ export const Search: FC<SearchProps> = ({
...props
}) => (
<input
id={`search-input-${uuid4()}`}
type="search"
className={clsx([styles.search, className])}
value={value}
@@ -0,0 +1,48 @@
import clsx from "clsx";
import { FC } from "react";
import { Select } from "~/components/Select";
import { SelectProps } from "~/components/Select/Select";
import styles from "./styles.module.css";
interface Props extends SelectProps {
label: string;
description?: string;
}
export const SelectField: FC<Props> = ({
value,
className,
description,
label,
options,
onChange,
onChangeConfirm,
hidePlaceholderOption,
placeholder,
name,
}) => {
return (
<div
className={clsx([styles.selectField, className])}
data-testid="select-field-container"
>
<div>
<label className={styles.label} htmlFor={name}>
{label}
</label>
{description && <div className={styles.description}>{description}</div>}
</div>
<Select
className={styles.field}
options={options}
onChange={onChange}
onChangeConfirm={onChangeConfirm}
value={value}
hidePlaceholderOption={hidePlaceholderOption}
placeholder={placeholder}
name={name}
/>
</div>
);
};
@@ -30,11 +30,11 @@ export const SpeciesDisplay: FC<SpeciesDisplayProps> = ({
portraitAvatarUrl || ruleDataUtils.getDefaultRaceImageUrl(ruleData);
// Navigate back to the class manage page
const handleNavigate = (): void =>
const handleNavigate = () =>
navigate(
navigationConfig
.getRouteDefPath(RouteKey.RACE_MANAGE)
.replace(":characterId", characterId)
.replace(":characterId", characterId.toString())
);
return (
+1 -7
View File
@@ -6,9 +6,6 @@ export const NavigationType = {
};
export const RouteKey = {
QUICK_BUILD: "ROUTE:QUICK_BUILD",
RANDOMIZE_BUILD: "ROUTE:RANDOMIZE_BUILD",
HOME_HELP: "ROUTE:HOME_HELP",
HOME_INTRODUCTION: "ROUTE:HOME_INTRODUCTION",
HOME_BASIC_INFO: "ROUTE:HOME_BASIC_INFO",
@@ -40,13 +37,10 @@ export const SectionKey = {
ABILITY_SCORES: "SECTION:ABILITY_SCORES",
DESCRIPTION: "SECTION:DESCRIPTION",
EQUIPMENT: "SECTION:EQUIPMENT",
QUICK_BUILD: "SECTION:QUICK_BUILD",
RANDOMIZE: "SECTION:RANDOMIZE",
WHATS_NEXT: "SECTION:WHATS_NEXT",
};
export const BuilderMethod = {
QUICK: "QUICK",
STEP_BY_STEP: "STEP_BY_STEP",
RANDOMIZE: "RANDOMIZE",
PREMADES: "PREMADES",
};
@@ -1,4 +1,10 @@
import { createContext, FC, useContext, useState } from "react";
import {
createContext,
FC,
PropsWithChildren,
useContext,
useState,
} from "react";
import { BuilderMethod } from "../../types";
@@ -20,7 +26,7 @@ export const BuilderContext = createContext<BuilderContextType>({
suggestedNames: [],
});
export const BuilderProvider: FC = ({ children }) => {
export const BuilderProvider: FC<PropsWithChildren> = ({ children }) => {
const [builderMethod, setBuilderMethodState] = useState<BuilderMethod | null>(
null
);
@@ -1,8 +1,16 @@
import { createContext, FC, useContext, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
createContext,
FC,
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react";
import { useSelector } from "react-redux";
import { ApiAdapterUtils } from "@dndbeyond/character-rules-engine";
import { useDispatch } from "~/hooks/useDispatch";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { ClassDefinitionContract as ClassDef } from "~/types";
@@ -25,7 +33,7 @@ export interface ClassContextType {
export const ClassContext = createContext<ClassContextType>(null!);
export const ClassProvider: FC = ({ children }) => {
export const ClassProvider: FC<PropsWithChildren> = ({ children }) => {
const dispatch = useDispatch();
const [query, setQuery] = useState("");
const [allClasses, setAllClasses] = useState<Array<ClassDef>>([]);
@@ -1,4 +1,10 @@
import { createContext, FC, useContext, useState } from "react";
import {
createContext,
FC,
PropsWithChildren,
useContext,
useState,
} from "react";
import { ConfirmModal, ConfirmModalProps } from "~/components/ConfirmModal";
@@ -14,7 +20,7 @@ export const ModalManagerContext = createContext<ModalManagerContextType>(
null!
);
export const ModalManagerProvider: FC = ({ children }) => {
export const ModalManagerProvider: FC<PropsWithChildren> = ({ children }) => {
const [modalData, setModalData] = useState<ModalData | null>(null);
const createModal = (modalData: ModalData) => {
@@ -1,5 +1,12 @@
import { groupBy, keyBy, uniqBy } from "lodash";
import { createContext, FC, useContext, useEffect, useState } from "react";
import {
createContext,
FC,
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react";
import { useSelector } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
@@ -39,7 +46,7 @@ export interface SpeciesContextType {
export const SpeciesContext = createContext<SpeciesContextType>(null!);
export const SpeciesProvider: FC = ({ children }) => {
export const SpeciesProvider: FC<PropsWithChildren> = ({ children }) => {
const {
apiAdapterUtils: { getResponseData },
raceUtils: { getSubRaceShortName, getBaseName, getIsHomebrew, getSources },
@@ -186,15 +193,6 @@ export const SpeciesProvider: FC = ({ children }) => {
return [...groups, ...unGroupedSpecies];
};
const getSpecies = async () => {
const species = getResponseData(await loadSpecies()) || [];
setAllSpecies(species);
if (species.length > 0) {
setIsLoading(false);
}
};
// Set the source options when the species are loaded
useEffect(() => {
let shouldContaineHomebrewCategory = false;
@@ -267,8 +265,16 @@ export const SpeciesProvider: FC = ({ children }) => {
}, [allSpecies, source]);
useEffect(() => {
const getSpecies = async () => {
const species = getResponseData(await loadSpecies()) || [];
setAllSpecies(species);
if (species.length > 0) {
setIsLoading(false);
}
};
getSpecies();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadSpecies]);
return (
@@ -1,7 +1,6 @@
import { useDispatch } from "react-redux";
import { ContainerTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { useInventoryManager } from "~/tools/js/Shared/managers/InventoryManagerContext";
import { Item } from "~/types";
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { HTMLAttributes, ReactNode, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate, Link } from "react-router-dom";
import { HTMLAttributes, useEffect, useRef } from "react";
import { useSelector } from "react-redux";
import { useNavigate, Link, useLocation } from "react-router-dom";
import {
characterEnvSelectors,
@@ -9,14 +9,13 @@ import {
} from "@dndbeyond/character-rules-engine";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { environment } from "~/config";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
import { BuilderMethod, RouteKey } from "../../constants";
import { BuilderMethod } from "../../constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
@@ -28,9 +27,8 @@ export const BuilderTypeChoicePage: React.FC<Props> = ({
className,
...props
}) => {
const params = new URLSearchParams(globalThis.location.search);
const isMobileApp = params.get("platform");
const [showHelpText, setShowHelpText] = useState(false);
const { search } = useLocation();
const isMobileApp = new URLSearchParams(search).get("platform");
const dispatch = useDispatch();
const navigate = useNavigate();
const { characterId } = useCharacterEngine();
@@ -48,7 +46,7 @@ export const BuilderTypeChoicePage: React.FC<Props> = ({
const handleMethodSet = (method: string) => {
if (method === BuilderMethod.STEP_BY_STEP) {
dispatch(builderActions.stepBuildRequest(showHelpText));
dispatch(builderActions.stepBuildRequest(false));
} else {
dispatch(builderActions.builderMethodSet(method));
}
@@ -73,14 +71,15 @@ export const BuilderTypeChoicePage: React.FC<Props> = ({
const renderCardContent = (
title: string,
description: string,
extraNode?: ReactNode
badge?: string
) => (
<div className={styles.cardContent}>
<h3 className={clsx([styles.cardTitle, styles.font])}>{title}</h3>
<h3 className={clsx([styles.cardTitle, styles.font])}>
{title} {badge && <span className={styles.badge}>{badge}</span>}
</h3>
<p className={clsx([styles.cardDescription, styles.font])}>
{description}
</p>
{extraNode}
</div>
);
@@ -102,67 +101,42 @@ export const BuilderTypeChoicePage: React.FC<Props> = ({
<div className={clsx([styles.img, styles.standardBuild])} />
{renderCardContent(
"Standard",
"Create a character using a step-by-step approach",
<div
onClick={(e) => {
e.stopPropagation();
if (isEnabled) setShowHelpText(!showHelpText);
}}
className={styles.helpCheckbox}
>
<Checkbox
id="q-include-help"
aria-labelledby="include-help-text"
checked={showHelpText}
disabled={!isEnabled}
darkMode
/>
<label
id="include-help-text"
htmlFor="q-include-help"
className={styles.helpText}
>
<strong>Beginner?</strong> Show help text
</label>
</div>
"Create a character using a step-by-step approach"
)}
{renderFooter()}
</div>
{/* QUICK CREATE */}
<a
className={styles.card}
href={
environment === "development"
? "https://stg.dndbeyond.com/play/characters/build/"
: "/play/characters/build/"
}
>
<div className={clsx([styles.img, styles.quickCreate])} />
{renderCardContent(
"Quickbuilder",
"A new player friendly approach to building fast and easy level one characters.",
"New"
)}
{renderFooter("Start Building")}
</a>
{/* PREMADES */}
{!isMobileApp && (
<Button href="/characters/premade" className={styles.card}>
<Link
className={styles.card}
to={`/characters/premade${search}`}
onClick={() => handleMethodSet(BuilderMethod.PREMADES)}
>
<div className={clsx([styles.img, styles.premades])} />
{renderCardContent(
"Premade",
"Browse a selection of ready-to-play, premade characters and claim one to your account."
)}
{renderFooter("Start Browsing")}
</Button>
</Link>
)}
{/* QUICK BUILD */}
<Link
className={styles.card}
to={navigationConfig.getRouteDefPath(RouteKey.QUICK_BUILD)}
onClick={() => handleMethodSet(BuilderMethod.QUICK)}
>
{renderCardContent(
"Quick Build",
"Choose a species and class to quickly create a level 1 character."
)}
{renderFooter()}
</Link>
{/* RANDOM BUILD */}
<Link
className={styles.card}
to={navigationConfig.getRouteDefPath(RouteKey.RANDOMIZE_BUILD)}
onClick={() => handleMethodSet(BuilderMethod.RANDOMIZE)}
>
{renderCardContent(
"Random",
"Roll up a randomized character! You can optionally set some parameters such as level, species, and class."
)}
{renderFooter()}
</Link>
</section>
</div>
);
@@ -1,6 +1,5 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch } from "react-redux";
import {
characterActions,
@@ -15,9 +14,10 @@ import {
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Button } from "~/components/Button";
import { Select } from "~/components/Select";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
import { Select } from "~/tools/js/smartComponents/legacy";
import styles from "./styles.module.css";
@@ -308,11 +308,14 @@ export const ClassHeader: FC<ClassHeaderProps> = ({
Level
</label>
<Select
className={styles.levelSelect}
id={`class-level-${classId}`}
value={level}
options={levelOptions}
initialOptionRemoved={true}
onChangePromise={handleLevelChangePromise}
onChangeConfirm={handleLevelChangePromise}
name={`class-level-select-${classId}`}
searchThreshold={null}
hidePlaceholderOption
/>
</div>
<Button
@@ -7,6 +7,7 @@ import { PrerequisiteUtils } from "@dndbeyond/character-rules-engine";
import { Accordion } from "~/components/Accordion";
import { HtmlContent } from "~/components/HtmlContent";
import { Link } from "~/components/Link";
import { NoResultsFound } from "~/components/NoResultsFound";
import { PreferenceProgressionTypeEnum as progressionType } from "~/constants";
import { orderBy } from "~/helpers/sortUtils";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
@@ -16,11 +17,11 @@ import { navigationConfig } from "~/tools/js/CharacterBuilder/config";
import { Button } from "../../../../components/Button";
import { ConfirmClassModal } from "../../components/ConfirmClassModal";
import { Listing } from "../../components/Listing";
import { PortraitName } from "../../components/PortraitName";
import { Search } from "../../components/Search";
import { Spinner } from "../../components/Spinner";
import { RouteKey } from "../../constants";
import { useClassContext } from "../../contexts/Class";
import pageStyles from "../../styles/page.module.css";
import {
ClassDefinitionContract,
ClassItems,
@@ -105,11 +106,11 @@ export const ClassChoose: FC<ClassChooseProps> = ({
totalClassLevel + 1 > currentLevel;
// Navigate back to the class manage page
const handleNavigate = (): void =>
const handleNavigate = () =>
navigate(
navigationConfig
.getRouteDefPath(RouteKey.CLASS_MANAGE)
.replace(":characterId", characterId)
.replace(":characterId", characterId.toString())
);
// Return requirements for multiclassing based on existing classes
@@ -291,15 +292,13 @@ export const ClassChoose: FC<ClassChooseProps> = ({
return (
<div
className={clsx([styles.page, className])}
className={clsx([pageStyles.page, className])}
{...props}
data-testid="chooseClassSection"
>
{showHeader && (
<>
<PortraitName />
<hr className={styles.divider} />
<h2 className={styles.title}>
<h2 className={pageStyles.title}>
Choose a Class{startingClass && " to Multiclass"}
</h2>
{startingClass && (
@@ -407,10 +406,14 @@ export const ClassChoose: FC<ClassChooseProps> = ({
);
})
) : (
<p className={styles.notFound}>
No Class options available. Return to the <strong>Home</strong>{" "}
tab to enable more source categories.
</p>
<NoResultsFound
message={
<>
No Class options available. Return to the{" "}
<strong>Home</strong> tab to enable more source categories.
</>
}
/>
)}
</>
)}
@@ -0,0 +1,599 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useSelector } from "react-redux";
import { useSearchParams } from "react-router-dom";
import {
characterActions,
CharacterUtils,
ClassUtils,
Constants,
HelperUtils,
RaceUtils,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine";
import { CheckboxGroup, CheckboxInfo } from "~/components/CheckboxGroup";
import { PrivacyTypeRadio } from "~/components/PrivacyTypeRadio";
import { SourceCategoryDescription } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
import { appEnvActions } from "~/tools/js/Shared/actions";
import SimpleClassSpellList from "~/tools/js/Shared/components/SimpleClassSpellList";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import config from "~/tools/js/config";
import { SelectField } from "../../components/SelectField";
import pageStyles from "../../styles/page.module.css";
import { PremadeForm } from "./PremadeForm";
import styles from "./styles.module.css";
export interface HomeProps extends HTMLAttributes<HTMLDivElement> {}
export const Home: FC<HomeProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const {
preferences,
ruleData,
activeSourceCategories,
classes,
race: species,
classSpellListSpellsLookup,
} = useCharacterEngine();
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const { createModal } = useModalManager();
const [searchParams] = useSearchParams();
const isVttView = searchParams.get("view") === "vtt";
const {
useHomebrewContent,
encumbranceType,
hitPointType,
progressionType,
abilityScoreDisplayType,
privacyType,
ignoreCoinWeight,
enforceFeatRules,
enforceMulticlassRules,
showScaledSpells,
enableOptionalOrigins,
enableOptionalClassFeatures,
} = preferences;
const handleSourceCategoryChange = (
sourceId: number,
isActive: boolean
): void => {
let newSourceCats: Array<any> = [];
if (isActive) {
newSourceCats = [...activeSourceCategories, sourceId];
} else {
newSourceCats = activeSourceCategories.filter((id) => id !== sourceId);
}
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
};
const handlePartneredSourceChangeAll = (
sourceIds: number[],
isActive: boolean
): void => {
let newSourceCats: Array<any> = [];
if (isActive) {
newSourceCats = [...activeSourceCategories, ...sourceIds];
} else {
newSourceCats = activeSourceCategories.filter(
(id) => !sourceIds.includes(id)
);
}
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
};
const handlePreferenceChange = (prefKey: string, value: boolean): void => {
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
if (typedPrefKey !== null) {
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
}
};
const handleIntPreferenceChange = (prefKey: string, value: string): void => {
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
if (typedPrefKey !== null) {
dispatch(
characterActions.preferenceChoose(
typedPrefKey,
HelperUtils.parseInputInt(value)
)
);
}
};
const handleDiceToggle = (): void => {
const newDiceEnabledSetting: boolean = !diceEnabled;
try {
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
} catch (e) {}
dispatch(
appEnvActions.dataSet({
diceEnabled: newDiceEnabledSetting,
})
);
};
const handleOptionalClassFeaturesPreferenceChange = (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
const spellListIds: Array<number> =
ClassUtils.getUpdateEnableOptionalClassFeaturesSpellListIdsToRemove(
classes,
newIsEnabled
);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
accept();
} else {
createModal({
content: (
<div>
<p>
Are you sure you want to disable{" "}
<strong>Optional Class Features</strong> for this character?
</p>
<p>
After doing so, the following spells provided by these features
will be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Optional Class Features",
size: "fit-content",
variant: "remove",
onConfirm: () => {
handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const handleOptionalOriginsPreferenceChange = (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
): void => {
if (!species) {
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
return;
}
const spellListIds: Array<number> =
RaceUtils.getUpdateEnableOptionalOriginsSpellListIdsToRemove(
species,
newIsEnabled
);
const hasSpellsToRemove = spellListIds.some((id) =>
classSpellListSpellsLookup.hasOwnProperty(id)
);
if (!hasSpellsToRemove) {
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
} else {
createModal({
content: (
<div>
<p>
Are you sure you want to disable{" "}
<strong>Customized Origins</strong> for this character?
</p>
<p>
After doing so, the following spells provided by these features
will be removed from your character:
</p>
<SimpleClassSpellList
spellListIds={spellListIds}
classSpellListSpellsLookup={classSpellListSpellsLookup}
/>
</div>
),
props: {
heading: "Customized Origin Features",
size: "fit-content",
variant: "remove",
onConfirm: () => {
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const handleProgressionPreferenceChangePromise = (
newValue: string,
oldValue: string,
accept: () => void,
reject: () => void
): void => {
const prefKey = CharacterUtils.getPreferenceKey("progressionType");
const newIdValue = HelperUtils.parseInputInt(newValue);
let content: ReactNode;
let heading: string | null = null;
if (newIdValue === Constants.PreferenceProgressionTypeEnum.XP) {
heading = "XP Advancement";
content = (
<div>
<p>
Are you sure you want to change your advancement method to XP
progression?
</p>
<p>You will begin with the base XP value for your current level.</p>
</div>
);
} else if (
newIdValue === Constants.PreferenceProgressionTypeEnum.MILESTONE
) {
heading = "Milestone Advancement";
content = (
<div>
<p>
Are you sure you want to change your advancement method to Milestone
progression?
</p>
<p>Your current XP values will be lost.</p>
</div>
);
}
if (prefKey !== null && heading) {
createModal({
content,
props: {
heading,
size: "fit-content",
onConfirm: () => {
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const handleHitPointPreferenceChangePromise = (
newValue: string,
oldValue: string,
accept: () => void,
reject: () => void
): void => {
const prefKey = CharacterUtils.getPreferenceKey("hitPointType");
const newIdValue = HelperUtils.parseInputInt(newValue);
let content: ReactNode;
let heading: string | null = null;
if (newIdValue === Constants.PreferenceHitPointTypeEnum.FIXED) {
heading = "Fixed Hit Points";
content = (
<div>
<p>
Are you sure you want to change your hit points to the fixed value?
</p>
<p>Any rolled hit point totals will be lost.</p>
</div>
);
} else if (newIdValue === Constants.PreferenceHitPointTypeEnum.MANUAL) {
heading = "Manual Hit Points";
content = (
<div>
<p>Are you sure you want to change your hit points manual entry?</p>
<p>
After doing so, use Manage HP in the Class section to enter your
rolled values.
</p>
</div>
);
}
if (prefKey !== null && heading) {
createModal({
content,
props: {
heading,
size: "fit-content",
onConfirm: () => {
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const renderSourceToggles = () => {
let sourceToggles: Array<CheckboxInfo> = [];
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
let allPartneredSources: Array<number> = [];
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
if (!sourceCategory.isToggleable) {
return null;
}
const checkbox: CheckboxInfo = {
label: `${sourceCategory.name}`,
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
onChange: (e) =>
handleSourceCategoryChange(sourceCategory.id, e.target.checked),
sortOrder: sourceCategory.sortOrder,
description: sourceCategory.description ?? "",
};
if (sourceCategory.isPartneredContent) {
delete checkbox.description; // remove description from partnered content
partneredSourceCheckboxes.push(checkbox);
allPartneredSources.push(sourceCategory.id);
} else {
sourceToggles.push(checkbox);
}
});
return (
<>
<CheckboxGroup
className={styles.checkboxGroup}
title="Sources"
description={SourceCategoryDescription.official}
name="sources"
checkboxes={[
...sourceToggles,
{
label: "Homebrew",
description: SourceCategoryDescription.homebrew,
initiallyEnabled: useHomebrewContent,
onChange: (e) =>
handlePreferenceChange("useHomebrewContent", e.target.checked),
sortOrder: 0,
},
]}
showAccordion={false}
/>
<CheckboxGroup
className={styles.checkboxGroup}
title="Partnered Content"
description={SourceCategoryDescription.partnered}
name="partneredContent"
checkboxes={partneredSourceCheckboxes}
accordionSummary="Choose Partners"
showAccordion={true}
toggleAllLabel="Enable All Partnered Content"
onToggleAll={(e) =>
handlePartneredSourceChangeAll(
allPartneredSources,
e.target.checked
)
}
/>
</>
);
};
return (
<div className={clsx([pageStyles.page, styles.home, className])} {...props}>
<PremadeForm />
<h2 className={pageStyles.title}>Character Preferences</h2>
<div className="home-manage-preferences">
{renderSourceToggles()}
{!isVttView && (
<CheckboxGroup
className={styles.checkboxGroup}
title="Dice Rolling"
description="Enables digital dice rolling for all characters on this browser"
name="dice-rolling"
checkboxes={[
{
label: "Enable Dice Rolling",
initiallyEnabled: diceEnabled,
onChange: handleDiceToggle,
},
]}
/>
)}
<CheckboxGroup
className={styles.checkboxGroup}
title="Optional Features"
name="optional-features"
description="Allow or restrict optional features for this character"
checkboxes={[
{
label: "Optional Class Features",
initiallyEnabled: enableOptionalClassFeatures,
onChangeConfirm: handleOptionalClassFeaturesPreferenceChange,
},
{
label: "Customize Your Origin",
initiallyEnabled: enableOptionalOrigins,
onChangeConfirm: handleOptionalOriginsPreferenceChange,
},
]}
/>
<SelectField
label="Advancement Type"
description="Story-based character progression / XP-based character progression"
onChangeConfirm={handleProgressionPreferenceChangePromise}
hidePlaceholderOption={true}
options={[
{
label: "Milestone",
value: Constants.PreferenceProgressionTypeEnum.MILESTONE,
},
{ label: "XP", value: Constants.PreferenceProgressionTypeEnum.XP },
]}
value={"" + progressionType}
name={"advancementType"}
/>
<SelectField
label="Hit Point Type"
description="When leveling up, increase hit points by the fixed value for your chosen class or manually enter a rolled value"
onChangeConfirm={handleHitPointPreferenceChangePromise}
hidePlaceholderOption={true}
options={[
{
label: "Fixed",
value: Constants.PreferenceHitPointTypeEnum.FIXED,
},
{
label: "Manual",
value: Constants.PreferenceHitPointTypeEnum.MANUAL,
},
]}
value={"" + hitPointType}
name={"hitPointType"}
/>
<CheckboxGroup
className={styles.checkboxGroup}
title="Use Prerequisites"
description="Allow or restrict choices based on rule prerequisites for the following for this character"
name="use-prerequisites"
checkboxes={[
{
label: "Feats",
initiallyEnabled: enforceFeatRules,
onChange: (e) =>
handlePreferenceChange("enforceFeatRules", e.target.checked),
},
{
label: "Multiclass Requirements",
initiallyEnabled: enforceMulticlassRules,
onChange: (e) =>
handlePreferenceChange(
"enforceMulticlassRules",
e.target.checked
),
},
]}
/>
<CheckboxGroup
className={styles.checkboxGroup}
title="Show Level-Scaled Spells"
description="Display and highlight available spells to cast with higher level spell slots"
name="show-scaled-spells"
checkboxes={[
{
label: "Show Level-Scaled Spells",
initiallyEnabled: showScaledSpells,
onChange: (e) =>
handlePreferenceChange("showScaledSpells", e.target.checked),
},
]}
/>
<SelectField
label="Encumbrance Type"
description="Use the standard encumbrance rules / Disable the encumbrance display / Use the more detailed rules for encumbrance"
onChange={(value: string) =>
handleIntPreferenceChange("encumbranceType", value)
}
hidePlaceholderOption={true}
options={[
{
label: "Use Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.ENCUMBRANCE,
},
{
label: "No Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.NONE,
},
{
label: "Variant Encumbrance",
value: Constants.PreferenceEncumbranceTypeEnum.VARIANT,
},
]}
value={"" + encumbranceType}
name={"encumbranceType"}
/>
<CheckboxGroup
className={styles.checkboxGroup}
title="Ignore Coin Weight"
description="Coins do not count against your total weight carried (50 coins weigh 1 lb.)"
name="ignore-coin-weight"
checkboxes={[
{
label: "Ignore Coin Weight",
initiallyEnabled: ignoreCoinWeight,
onChange: (e) =>
handlePreferenceChange("ignoreCoinWeight", e.target.checked),
},
]}
/>
<SelectField
label="Ability Score/Modifier Display"
description="Reverse the arrangement of ability modifiers and scores"
onChange={(value: string) =>
handleIntPreferenceChange("abilityScoreDisplayType", value)
}
hidePlaceholderOption={true}
options={[
{
label: "Modifiers Top",
value:
Constants.PreferenceAbilityScoreDisplayTypeEnum.MODIFIERS_TOP,
},
{
label: "Scores Top",
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.SCORES_TOP,
},
]}
value={"" + abilityScoreDisplayType}
name="abilityScoreDisplayType"
/>
<PrivacyTypeRadio
className={styles.privacyTypeRadio}
initialValue={privacyType}
onChange={(e) =>
handleIntPreferenceChange("privacyType", e.target.value)
}
/>
</div>
<hr className={pageStyles.divider} />
<div className={styles.versionInfo}>
<p>
Version: <span>{config.version}</span>
</p>
</div>
</div>
);
};
@@ -0,0 +1,386 @@
import clsx from "clsx";
import {
FC,
HTMLAttributes,
FocusEvent,
ChangeEvent,
useState,
useEffect,
} from "react";
import { useSelector } from "react-redux";
import {
characterActions,
PremadeInfo,
PremadeInfoStatus,
RuleDataUtils,
SimpleSourcedDefinitionContract,
} from "@dndbeyond/character-rules-engine";
import { RadioGroup } from "~/components/RadioGroup";
import { Select } from "~/components/Select";
import { Toggle } from "~/components/Toggle";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useDispatch } from "~/hooks/useDispatch";
import { useSource } from "~/hooks/useSource";
import { InputField } from "~/subApps/builder/components/InputField";
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import pageStyles from "../../../styles/page.module.css";
import styles from "./styles.module.css";
export interface PremadeFormProps extends HTMLAttributes<HTMLDivElement> {}
export const PremadeForm: FC<PremadeFormProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { premadeInfo, characterId, ruleData } = useCharacterEngine();
const userRoles = useSelector(appEnvSelectors.getUserRoles);
const { getGroupedOptionsBySourceCategory } = useSource();
const { createModal } = useModalManager();
const [premadeState, setPremadeState] = useState<PremadeInfo | null>(
premadeInfo
);
useEffect(() => {
setPremadeState(premadeInfo);
}, [premadeInfo]);
const canAccessPremadeForm =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
const inputAttributes = {
disabled: !premadeState,
} as HTMLAttributes<HTMLInputElement>;
const sourceOptions: Array<SimpleSourcedDefinitionContract> =
RuleDataUtils.getSourceData(ruleData).map((sourceData) => {
return {
id: sourceData.id,
name: sourceData.description,
sources: [
{
pageNumber: null,
sourceId: sourceData.id,
sourceType: 1,
},
],
};
});
const premadePrimarySource = premadeState?.sources?.find(
(source) => source.isPrimary
);
const handleAddPremadeInfo = (): void => {
const defaultData: PremadeInfo = {
characterId: characterId,
publishStatus: PremadeInfoStatus.DRAFT,
sources: [],
definition: {
longDescription: null,
shortDescription: null,
imageUrl: null,
imageAltText: null,
mobileImageUrl: null,
mobileImageAccessibility: null,
themeColor: null,
},
};
dispatch(characterActions.premadeInfoAdd(defaultData));
};
const handleDeletePremadeInfo = (): void => {
dispatch(characterActions.premadeInfoDelete(characterId));
};
const handlePremadeInfoChanged = (premadeInfo: PremadeInfo): void => {
setPremadeState(premadeInfo);
dispatch(characterActions.premadeInfoUpdate(premadeInfo));
};
const handlePremadePublishStatusChange = (
e: ChangeEvent<HTMLInputElement>,
newValue: string,
oldValue: string,
accept: () => void,
reject: () => void
): void => {
if (premadeState) {
const content = (
<div>
<p>
Are you sure you want to change the publish status from{" "}
<strong>{oldValue}</strong> to <strong>{newValue}</strong>?
</p>
{newValue === PremadeInfoStatus.PUBLISHED && (
<p>
<strong>Warning:</strong> Publishing this character will make it
publically visible to users on D&D Beyond!! Proceed with Caution!
</p>
)}
</div>
);
createModal({
content,
props: {
heading: "Confirm Premade Status Change",
size: "fit-content",
onConfirm: () => {
handlePremadeInfoChanged({
...premadeState,
publishStatus: newValue as PremadeInfoStatus,
});
accept();
},
onClose: () => {
reject();
},
},
});
}
};
const isReadyToPublish = (): boolean => {
if (!premadeState) return false;
if (premadeState.publishStatus === PremadeInfoStatus.PUBLISHED) return true;
// check that all fields are filled out before enabling publish
return (
premadeState.sources.length >= 1 &&
premadeState.definition.longDescription !== null &&
premadeState.definition.imageUrl !== null &&
premadeState.definition.mobileImageUrl !== null &&
premadeState.definition.shortDescription !== null &&
premadeState.definition.themeColor !== null
);
};
const notReadyToPublishDescription = (): string => {
if (!premadeState) return "";
if (premadeState.sources.length === 0) {
if (premadeState.publishStatus !== PremadeInfoStatus.PUBLISHED) {
return "You must select a source before you can publish this character.";
} else {
return "This premade was published before sources were required. A source should be added!";
}
}
const missingFields: string[] = [];
if (!premadeState.definition.longDescription) {
missingFields.push("Character Description");
}
if (!premadeState.definition.imageUrl) {
missingFields.push("Image Url");
}
if (!premadeState.definition.mobileImageUrl) {
missingFields.push("Mobile Image Url");
}
if (!premadeState.definition.shortDescription) {
missingFields.push("Mobile Description");
}
if (!premadeState.definition.themeColor) {
missingFields.push("Mobile Card Theme Color");
}
if (missingFields.length > 0) {
return `The following fields are required to publish: ${missingFields.join(
", "
)}`;
}
return "";
};
return canAccessPremadeForm ? (
<div className={clsx([styles.premadeForm, className])} {...props}>
<h2 className={pageStyles.title}>
Premade Character Preferences - Lorekeepers Only
</h2>
<div className={styles.form}>
<div>
<label className={styles.label} id="toggle-premade-character">
Premade Character
</label>
<p className={styles.toggleDescription}>
Toggle on to make this a premade character. You cannot disable this
if the premade status is Published.
</p>
<Toggle
onClick={(isEnabled: boolean) => {
isEnabled ? handleAddPremadeInfo() : handleDeletePremadeInfo();
}}
checked={!!premadeState}
color="secondary"
aria-labelledby="toggle-premade-character"
disabled={
premadeState?.publishStatus === PremadeInfoStatus.PUBLISHED
}
/>
</div>
{premadeState && (
<>
<RadioGroup
name="premadeStatus"
title="Publish Status"
description="Mark this character as published when you are ready for it to be publically visible."
initialValue={premadeState.publishStatus}
options={[
{ label: "Draft", value: PremadeInfoStatus.DRAFT },
{
label: "Published",
value: PremadeInfoStatus.PUBLISHED,
description: notReadyToPublishDescription(),
disabled: !isReadyToPublish(),
},
{ label: "Archived", value: PremadeInfoStatus.ARCHIVED },
]}
disabled={!premadeState}
onChangeConfirm={handlePremadePublishStatusChange}
/>
<div>
<label className={styles.label} htmlFor="premade-source-mapping">
Source
</label>
<Select
id="premade-source-mapping"
name="premade-source-mapping"
options={getGroupedOptionsBySourceCategory(sourceOptions)}
value={premadePrimarySource?.sourceId || null}
onChange={(value) => {
const sourceData = RuleDataUtils.getSourceDataInfo(
Number(value),
ruleData
);
const sources = sourceData
? [
{
sourceId: Number(value),
sourceName: sourceData.description || "Unknown",
displayOrder: sourceData.premadeDisplayOrder || 1,
isPrimary: true,
},
]
: [];
handlePremadeInfoChanged({
...premadeState,
sources,
});
}}
placeholder={"-- Choose a Source --"}
/>
</div>
<InputField
label="Character Description"
initialValue={premadeState.definition.longDescription}
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
longDescription: e.target.value,
},
});
}}
/>
<InputField
label="Image Url"
initialValue={premadeState.definition.imageUrl}
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
imageUrl: e.target.value,
},
});
}}
/>
<InputField
label="Image Alt Text"
initialValue={premadeState.definition.imageAltText}
maxLength={150}
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
imageAltText: e.target.value,
},
});
}}
/>
<h3 className={styles.mobileHeader}>Mobile Specific Fields</h3>
<InputField
label="Mobile Description"
initialValue={premadeState.definition.shortDescription}
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
shortDescription: e.target.value,
},
});
}}
/>
<InputField
label="Mobile Image Url"
initialValue={premadeState.definition.mobileImageUrl}
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
mobileImageUrl: e.target.value,
},
});
}}
/>
<InputField
label="Mobile Alt Text"
initialValue={premadeState.definition.mobileImageAccessibility}
inputProps={inputAttributes}
maxLength={150}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
mobileImageAccessibility: e.target.value,
},
});
}}
/>
<InputField
className={styles.colorPicker}
label="Mobile Card Theme Color"
type="color"
initialValue={premadeState.definition.themeColor || "#8A9BA8"} // default color provided by Mobile team
inputProps={inputAttributes}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
handlePremadeInfoChanged({
...premadeState,
definition: {
...premadeState.definition,
themeColor: e.target.value,
},
});
}}
/>
</>
)}
</div>
</div>
) : null;
};
@@ -0,0 +1,416 @@
import clsx from "clsx";
import { orderBy } from "lodash";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
PremadeCharacter,
PremadeInfoStatus,
PremadeSourceContract,
} from "@dndbeyond/character-rules-engine";
import Exclamation from "@dndbeyond/fontawesome-cache/svgs/regular/circle-exclamation.svg";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import ArrowLeft from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-left.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { PremadeCharacterCard } from "@dndbeyond/ttui/components/PremadeCharacterCard";
import { Toast } from "@dndbeyond/ttui/components/Toast";
import { Accordion } from "~/components/Accordion";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import { NoResultsFound } from "~/components/NoResultsFound";
import { characterServiceBaseUrl } from "~/config";
import { ddbBaseUrl } from "~/config";
import { claimCharacter, joinCampaign } from "~/helpers/characterServiceApi";
import { summon } from "~/helpers/summon";
import useSubscriptionTier, {
FREE_TIER,
HERO_TIER,
} from "~/hooks/useSubscriptionTier";
import { SubscriptionBanner } from "~/subApps/listing/components/SubscriptionBanner";
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { Search } from "../../components/Search";
import styles from "./styles.module.css";
interface SourceGroup {
name: string;
sourceId: number;
characters: Array<PremadeCharacter>;
displayOrder: number;
}
export const PremadeListing = () => {
const [premadeCharacters, setPremadeCharacters] = useState<
Array<PremadeCharacter>
>([]);
const [elevatedUserCharacters, setElevatedUserCharacters] = useState<
Array<PremadeCharacter>
>([]);
const [showElevatedUserCharacters, setShowElevatedUserCharacters] =
useState(false);
const [showProgressForIndex, setShowProgressForIndex] = useState<
number | null
>(null);
const [showDialog, setShowDialog] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [query, setQuery] = useState("");
const subscriptionTier = useSubscriptionTier();
const userRoles = useSelector(appEnvSelectors.getUserRoles);
const isElevatedUser =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
const characterSlotInfo = useSelector(appEnvSelectors.getCharacterSlots);
const hasUnlimitedSlots = characterSlotInfo.characterSlotLimit === null;
const hasSlots =
hasUnlimitedSlots ||
(characterSlotInfo.characterSlotLimit !== null &&
characterSlotInfo.characterSlotLimit -
characterSlotInfo.activeCharacterCount >
0);
const params = new URLSearchParams(globalThis.location.search);
const campaignJoinCode = params.get("campaignJoinCode");
const isAssigned = params.get("isAssigned") !== "false";
const campaignId = params.get("campaignId");
const filteredCharacters = premadeCharacters.filter((character) => {
return [
character.characterData.name,
character.characterData.characterLevel.toString(),
`level ${character.characterData.characterLevel}`,
`lvl ${character.characterData.characterLevel}`,
character.characterData.primaryClassName,
character.characterData.speciesName,
character.characterData.characterLevel.toString(),
...character.sources.map((source) => source.sourceName),
]
.join(" ")
.toLowerCase()
.includes(query.toLowerCase());
});
// Create an array of source groups with their associated premade characters
// Order the gorups by source name and display order
const groupPremadesBySource = (characters: Array<PremadeCharacter>) => {
const groups: Array<SourceGroup> = [];
characters.forEach((character) => {
const primarySource: PremadeSourceContract = character.sources.find(
(source) => source.isPrimary
) || {
sourceName: "Not Sourced",
sourceId: 0,
displayOrder: 9999,
isPrimary: true,
};
const index = groups.findIndex(
(group) => group.sourceId === primarySource.sourceId
);
if (index < 0) {
groups.push({
name: primarySource.sourceName,
sourceId: primarySource.sourceId,
displayOrder: primarySource.displayOrder,
characters: [character],
});
} else {
groups[index].characters.push(character);
}
});
return orderBy(groups, ["displayOrder", "name"]);
};
const publishedGroups = groupPremadesBySource(filteredCharacters);
const onClaim = async (characterId: number, key: number) => {
try {
setShowProgressForIndex(key);
const response = await claimCharacter(characterId, isAssigned);
let clonedId: number;
if (response.ok) {
const { id } = await response.json();
clonedId = id;
} else {
if (response.status === 406) {
setShowDialog(true);
setShowProgressForIndex(null);
return;
} else {
throw new Error("Error when cloning premade character!");
}
}
let campaignId: string | null = null;
if (campaignJoinCode) {
const campaignResponse = await joinCampaign(campaignJoinCode, clonedId);
if (campaignResponse.ok) {
const data = await campaignResponse.json();
campaignId = data.campaignId;
} else {
throw new Error(
"Error when attempting to attach cloned character to campaign!"
);
}
}
// Redirect to character page or campaign page
const characterUrl = NavigationUtils.getCharacterSheetUrl(clonedId);
if (isAssigned) {
window.location = characterUrl as string & Location;
} else {
// If, somehow, the campaignId is null, send them to their new character sheet. Otherwise, send them
// back to the campaign page.
window.location = campaignId
? (`${ddbBaseUrl}/campaigns/${campaignId}` as string & Location)
: (characterUrl as string & Location);
}
} catch (e) {
setShowAlert(true);
setShowProgressForIndex(null);
throw e;
} finally {
setShowProgressForIndex(null);
}
};
useEffect(() => {
const fetchCharacters = async () => {
try {
const req = await summon(
`${characterServiceBaseUrl}/premade${
campaignId ? `?campaignId=${campaignId}` : ""
}`
);
if (req.ok) {
const json: { data: Array<PremadeCharacter> } = await req.json();
setPremadeCharacters(
json.data.filter(
(char) => char.publishStatus === PremadeInfoStatus.PUBLISHED
)
);
setElevatedUserCharacters(
json.data.filter(
(char) => char.publishStatus !== PremadeInfoStatus.PUBLISHED
)
);
}
} catch {
throw new Error("Non-OK response from GET published premades");
}
};
fetchCharacters();
}, []);
const renderAccordion = (group: SourceGroup) => {
return (
<Accordion
id={group.sourceId.toString()}
summary={
<h2 className={styles.accordionSummary} id={group.name}>
{group.name}
</h2>
}
key={group.sourceId}
className={styles.accordion}
variant="text"
forceShow={true}
resetOpen={premadeCharacters.length !== filteredCharacters.length}
>
<div className={styles.premadesContainer}>
<div className={styles.grid}>
{orderBy(group.characters, (char) => char.characterData.name).map(
(
{
characterData: {
name,
characterLevel,
speciesName,
primaryClassName,
},
definition: { longDescription, imageUrl, imageAltText },
characterId,
publishStatus,
},
i
) => (
<PremadeCharacterCard
key={i}
data-testid="premade-character-card"
characterName={name}
characterLevel={characterLevel}
characterId={characterId}
className={clsx([
styles.premadeCard,
styles[publishStatus.toLowerCase()],
!hasSlots && styles.hideClaim,
])}
speciesName={speciesName}
primaryClassName={primaryClassName}
longDescription={longDescription || ""}
imageUrl={imageUrl || ""}
imageAlt={
imageAltText || `${speciesName} ${primaryClassName}`
}
createButtonText={
publishStatus === PremadeInfoStatus.PUBLISHED
? `Create${campaignJoinCode ? " and Join" : ""}`
: `Status: ${publishStatus}`
}
showProgress={
publishStatus === PremadeInfoStatus.PUBLISHED
? showProgressForIndex === i
: false
}
previewLink={`${ddbBaseUrl}/characters/${characterId}${
campaignJoinCode
? `?campaignJoinCode=${campaignJoinCode}`
: ""
}`}
onClaim={
publishStatus === PremadeInfoStatus.PUBLISHED
? () => onClaim(characterId, i)
: () => {}
}
disableClaim={
publishStatus === PremadeInfoStatus.PUBLISHED
? !hasSlots
: true
}
/>
)
)}
</div>
</div>
</Accordion>
);
};
return (
<div className={styles.premadeListing} data-pagename="premade-listing">
{/* Fastly: We're going to try to make this work in the character app, so we can keep the URL param retrieval logic below */}
{(campaignJoinCode || campaignId) && (
<Button
className={styles.backButton}
color="info"
href={`${ddbBaseUrl}/campaigns/${
campaignJoinCode ? `join/${campaignJoinCode}` : campaignId
}`}
>
<ArrowLeft />
Back to{campaignJoinCode ? " Join" : ""} Campaign
</Button>
)}
<h1 className={styles.pageTitle}>Premade Characters</h1>
<p className={styles.pageDescription}>
Not sure how to create your first character? Our premade character
sheets let you skip the complicated stuff and start playing in minutes.
</p>
{subscriptionTier === FREE_TIER && !hasSlots && (
<SubscriptionBanner
iconType="hero"
text="Your party is full. Unlock unlimited character creation."
buttonLabel="Subscribe Now!"
onClick={() =>
(window.location.href = `${ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
{/* 2025: This will not be seen, but including for consistency between this page and the Characters Grid component. */}
{subscriptionTier.includes(HERO_TIER) && !hasSlots && (
<SubscriptionBanner
iconType="master"
text="Unlock your full potential and EXCLUSIVE perks."
buttonLabel="Upgrade Now!"
onClick={() =>
(window.location.href = `${ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
<Search
className={styles.search}
value={query}
onChange={(e) => {
setQuery(e.target.value);
}}
placeholder="Search by Name, Level, Class, Species, or Source..."
/>
{/* Published Premades */}
{publishedGroups.length > 0 ? (
publishedGroups.map((group) => {
return renderAccordion(group);
})
) : (
<NoResultsFound message="No premade characters found." />
)}
{/* Unpublished, admin only Premades */}
{isElevatedUser && (
<div className={styles.elevatedUserContainer}>
<Button
className={styles.elevatedUserButton}
size="x-small"
color="secondary"
onClick={() =>
setShowElevatedUserCharacters(!showElevatedUserCharacters)
}
>
{showElevatedUserCharacters ? "Hide" : "View"} All Draft and
Archived Premade Characters
</Button>
{showElevatedUserCharacters &&
groupPremadesBySource(elevatedUserCharacters).map((group) => {
return renderAccordion(group);
})}
</div>
)}
{/* We know at the top level if someone doesn't have enough slots and displays a message at the top of the page and have the create character buttons disabled. The dialog here provides extra context if somehow the user tries to create a character without enough slots. */}
{showDialog && (
<MaxCharactersDialog
open={showDialog}
onClose={() => setShowDialog(false)}
useMyCharactersLink
/>
)}
<Toast
open={showAlert}
onClose={() => setShowAlert(false)}
autoHideDuration={10000}
align="right"
className={styles.toast}
>
<div className={styles.toastContent}>
<Exclamation />
<p>
An unexpected error occured.
{campaignJoinCode ? (
<>
{" "}
Try adding from the{" "}
<a
href={`${ddbBaseUrl}/campaigns/${campaignJoinCode}`}
className={styles.dialogLink}
>
Campaign Page
</a>{" "}
again.
</>
) : (
""
)}
</p>
</div>
<Button variant="tool">
<XMark />
</Button>
</Toast>
</div>
);
};
@@ -1,113 +0,0 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
import { PortraitName } from "../../components/PortraitName";
import { ClassChoose } from "../ClassChoose";
import { SpeciesChoose } from "../SpeciesChoose";
import styles from "./styles.module.css";
export interface QuickBuildProps extends HTMLAttributes<HTMLDivElement> {}
export const QuickBuild: FC<QuickBuildProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { raceUtils, ruleData } = useCharacterEngine();
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
null
);
const [name, setName] = useState("");
const [isClassesOpen, setIsClassesOpen] = useState(false);
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
const handleSubmit = (): void => {
dispatch(
builderActions.quickBuildRequest(
species ? raceUtils.getEntityRaceId(species) : null,
species ? raceUtils.getEntityRaceTypeId(species) : null,
charClass ? charClass.id : null,
name
)
);
};
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
setSpecies(species);
};
const handleRemoveSpecies = (): void => {
setSpecies(null);
setIsSpeciesOpen(true);
};
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
setCharClass(charClass);
};
const handleRemoveClass = (): void => {
setCharClass(null);
setIsClassesOpen(true);
};
return (
<div className={clsx([styles.page, className])} {...props}>
<h1 className={styles.title}>Quick Build</h1>
<p>
To create a level 1 character with recommended starting options, choose
a class and species below. You can provide a character name or leave it
blank to randomize one.
</p>
<hr className={styles.divider} />
{charClass ? (
<ClassDisplaySimple
onRequestChange={handleRemoveClass}
charClass={charClass}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
variant="text"
resetOpen={isClassesOpen}
>
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
</Accordion>
)}
{species ? (
<SpeciesDisplaySimple
onRequestAction={handleRemoveSpecies}
headingText="Selected Species"
species={species}
ruleData={ruleData}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
variant="text"
resetOpen={isSpeciesOpen}
>
<SpeciesChoose
showHeader={false}
onQuickSelect={handleSelectSpecies}
/>
</Accordion>
)}
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
<Button
className={styles.submitButton}
onClick={handleSubmit}
disabled={!species || !charClass}
variant="builder"
>
Create Character
</Button>
</div>
);
};
@@ -1,174 +0,0 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Select } from "@dndbeyond/character-components/es";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { Toggle } from "~/components/Toggle";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { builderActions } from "~/tools/js/CharacterBuilder/actions";
import ClassDisplaySimple from "~/tools/js/CharacterBuilder/components/ClassDisplaySimple";
import SpeciesDisplaySimple from "~/tools/js/CharacterBuilder/components/SpeciesDisplaySimple";
import { ClassDefinitionContract, RaceDefinitionContract } from "~/types";
import { PortraitName } from "../../components/PortraitName";
import { ClassChoose } from "../ClassChoose";
import { SpeciesChoose } from "../SpeciesChoose";
import styles from "./styles.module.css";
export interface RandomBuildProps extends HTMLAttributes<HTMLDivElement> {}
export const RandomBuild: FC<RandomBuildProps> = ({ className, ...props }) => {
const dispatch = useDispatch();
const { raceUtils, ruleData, helperUtils } = useCharacterEngine();
const [species, setSpecies] = useState<RaceDefinitionContract | null>(null);
const [charClass, setCharClass] = useState<ClassDefinitionContract | null>(
null
);
const [name, setName] = useState("");
const [level, setLevel] = useState<number | null>(null);
const [allowMulticlass, setAllowMulticlass] = useState(false);
const [allowFeats, setAllowFeats] = useState(false);
const [isClassesOpen, setIsClassesOpen] = useState(false);
const [isSpeciesOpen, setIsSpeciesOpen] = useState(false);
const handleSubmit = (): void => {
dispatch(
builderActions.randomBuildRequest(
level,
species ? raceUtils.getEntityRaceId(species) : null,
species ? raceUtils.getEntityRaceTypeId(species) : null,
charClass ? charClass.id : null,
allowMulticlass,
allowFeats,
name
)
);
};
const handleSelectSpecies = (species: RaceDefinitionContract): void => {
setSpecies(species);
};
const handleRemoveSpecies = (): void => {
setSpecies(null);
setIsSpeciesOpen(true);
};
const handleSelectClass = (charClass: ClassDefinitionContract): void => {
setCharClass(charClass);
};
const handleRemoveClass = (): void => {
setCharClass(null);
setIsClassesOpen(true);
};
const handleLevelChange = (level: string): void => {
setLevel(helperUtils.parseInputInt(level));
};
const handleToggleAllowMulticlass = (): void => {
setAllowMulticlass(!allowMulticlass);
};
const handleToggleAllowFeats = (): void => {
setAllowFeats(!allowFeats);
};
const levels = Array.from(
new Array(ruleData.maxCharacterLevel),
(val, index) => index + 1
);
return (
<div className={clsx([styles.page, className])} {...props}>
<h1 className={styles.title}>Random Build</h1>
<p>
Create a randomized character. Make it completely random, or make
choices in some categories and randomize the rest. Once you've made your
selections, click <strong>Create Character</strong> to generate a
character sheet and start playing!
</p>
<hr className={styles.divider} />
<div className={styles.levelSelect}>
<label className={styles.label} id="choose-level">
Choose Level
</label>
<Select
className={styles.select}
placeholder={"--"}
options={levels.map((level) => ({
value: level,
label: level,
}))}
onChange={handleLevelChange}
aria-labelledby="choose-level"
value={level}
/>
</div>
{charClass ? (
<ClassDisplaySimple
onRequestChange={handleRemoveClass}
charClass={charClass}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Class</h3>}
variant="text"
resetOpen={isClassesOpen}
>
<ClassChoose showHeader={false} onQuickSelect={handleSelectClass} />
</Accordion>
)}
{species ? (
<SpeciesDisplaySimple
onRequestAction={handleRemoveSpecies}
headingText="Selected Species"
species={species}
ruleData={ruleData}
/>
) : (
<Accordion
className={styles.accordion}
summary={<h3 className={styles.accordionHeading}>Choose Species</h3>}
variant="text"
resetOpen={isSpeciesOpen}
>
<SpeciesChoose
showHeader={false}
onQuickSelect={handleSelectSpecies}
/>
</Accordion>
)}
<div className={styles.toggle}>
<label className={styles.label} id="allow-multiclass-label">
Allow Multiclass
</label>
<Toggle
onClick={handleToggleAllowMulticlass}
checked={allowMulticlass}
color="secondary"
aria-labelledby="allow-multiclass-label"
/>
</div>
<div className={styles.toggle}>
<label className={styles.label} id="allow-feats-label">
Allow Feats
</label>
<Toggle
onClick={handleToggleAllowFeats}
checked={allowFeats}
color="secondary"
aria-labelledby="allow-feats-label"
/>
</div>
<PortraitName hidePortrait fullWidth handleNameUpdate={setName} />
<Button
className={styles.submitButton}
onClick={handleSubmit}
variant="builder"
>
Create Character
</Button>
</div>
);
};
@@ -6,22 +6,23 @@ import CircleInfo from "@dndbeyond/fontawesome-cache/svgs/regular/circle-info.sv
import { Accordion } from "~/components/Accordion";
import { HtmlContent } from "~/components/HtmlContent";
import { Link } from "~/components/Link";
import { NoResultsFound } from "~/components/NoResultsFound";
import { Select } from "~/components/Select";
import { Toggle } from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { orderBy } from "~/helpers/sortUtils";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import { Select } from "~/tools/js/smartComponents/legacy";
import { RaceDefinitionContract } from "~/types";
import { Button } from "../../../../components/Button";
import { ConfirmSpeciesModal } from "../../components/ConfirmSpeciesModal";
import { Listing } from "../../components/Listing";
import { PortraitName } from "../../components/PortraitName";
import { Search } from "../../components/Search";
import { SpeciesDisplay } from "../../components/SpeciesDisplay";
import { Spinner } from "../../components/Spinner";
import { useSpeciesContext } from "../../contexts/Species";
import pageStyles from "../../styles/page.module.css";
import styles from "./styles.module.css";
//showHeader and OnQuickSelect are only be used from QuickBuild.tsx and RandomBuild.tsx
@@ -97,15 +98,13 @@ export const SpeciesChoose: FC<SpeciesChooseProps> = ({
return (
<div
className={clsx([styles.speciesChoose, className])}
className={clsx([pageStyles.page, className])}
{...props}
data-testid="chooseSpeciesSection"
>
{showHeader && (
<>
<PortraitName />
<hr className={styles.divider} />
<h2 className={styles.title}>
<h2 className={pageStyles.title}>
{currentSpecies ? "Change Origin: " : "Choose Origin: "}
{"Species"}
</h2>
@@ -115,20 +114,23 @@ export const SpeciesChoose: FC<SpeciesChooseProps> = ({
headingText="Current Species"
actionText="Keep Species"
/>
<h3 className={styles.title}>Select New Species</h3>
<h3 className={pageStyles.title}>Select New Species</h3>
</>
)}
</>
)}
<div className={styles.filters}>
<div>
<div className={styles.filterSelect}>
<div className={styles.label}>Filter Species Source(s)</div>
<Select
className={styles.sourceSelect}
placeholder={"-- All Sources --"}
options={sourceOptions}
value={source}
onChange={(value) => setSource(value)}
onChange={(value) => setSource(value as string)}
name="species-sources"
optionsWidth="large"
/>
</div>
@@ -139,8 +141,9 @@ export const SpeciesChoose: FC<SpeciesChooseProps> = ({
checked={isLegacyShowing}
color="secondary"
aria-labelledby="legacy-content-label"
id="legacy-toggle"
/>
<label id="legacy-content-label">
<label id="legacy-content-label" htmlFor="legacy-toggle">
Show Legacy Content{" "}
<>
<CircleInfo
@@ -162,7 +165,7 @@ export const SpeciesChoose: FC<SpeciesChooseProps> = ({
</label>
</div>
</div>
<hr className={styles.divider} />
<hr className={pageStyles.divider} />
<div className={clsx([styles.text, styles.marketplace])}>
Check your source settings on the <strong>Home</strong> tab if you can't
find Species you've purchased.
@@ -243,7 +246,7 @@ export const SpeciesChoose: FC<SpeciesChooseProps> = ({
);
})
) : (
<p className={styles.notFound}>No Results Found</p>
<NoResultsFound message="No results found." />
)}
</>
)}
@@ -0,0 +1,211 @@
import { clsx } from "clsx";
import { FC, useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useSearchParams } from "react-router-dom";
import CharacterSheetSvg from "@dndbeyond/fontawesome-cache/svgs/light/address-card.svg";
import CheckMark from "@dndbeyond/fontawesome-cache/svgs/light/circle-check.svg";
import PdfSvg from "@dndbeyond/fontawesome-cache/svgs/light/file-pdf.svg";
import { Button } from "~/components/Button";
import { usePdfExport } from "~/hooks/usePdfExport";
import pageStyles from "~/subApps/builder/styles/page.module.css";
import {
builderEnvSelectors,
builderSelectors,
} from "~/tools/js/CharacterBuilder/selectors";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ClipboardUtils, MobileMessengerUtils } from "~/tools/js/Shared/utils";
import styles from "./styles.module.css";
export const WhatsNext: FC = () => {
const characterId = useSelector(appEnvSelectors.getCharacterId);
const characterListingUrl = useSelector(
builderEnvSelectors.getProfileCharacterListingUrl
);
const characterSheetUrl = useSelector(
builderEnvSelectors.getCharacterSheetUrl
);
const isCharacterSheetReady = useSelector(
builderSelectors.checkIsCharacterSheetReady
);
const [searchParams] = useSearchParams();
const isVttView = searchParams.get("view") === "vtt";
const urlInput = useRef<HTMLInputElement>(null);
const { exportPdf, isLoading, isFinished, pdfUrl } = usePdfExport();
const [hasCopied, setHasCopied] = useState(false);
useEffect(() => {
if (pdfUrl !== null && isFinished) {
urlInput.current?.focus();
urlInput.current?.setSelectionRange(0, urlInput.current.value.length);
}
}, [isFinished, pdfUrl]);
const handleExportPdf = (evt: React.MouseEvent): void => {
if (isCharacterSheetReady && !isLoading && !isFinished) {
exportPdf();
}
};
const handleClick = (evt: React.MouseEvent): void => {
ClipboardUtils.copyTextToClipboard(pdfUrl === null ? "" : pdfUrl);
setHasCopied(true);
};
const handleMobileSheetShowClick = () => {
if (characterId !== null && isCharacterSheetReady) {
MobileMessengerUtils.sendMessage(
MobileMessengerUtils.createShowCharacterSheetMessage(characterId)
);
}
};
const renderPdfData = (): React.ReactNode => {
if (!isFinished || pdfUrl === null) {
return null;
}
return (
<div className={styles.pdfSection}>
<div className={styles.pdfInputWrapper}>
<label className={styles.pdfDataLabel}>
<PdfSvg /> PDF Generated
</label>
<input
type="text"
value={pdfUrl ? pdfUrl.replace(/https*:\/\//, "") : ""}
className={styles.pdfDataInput}
ref={urlInput}
/>
</div>
<Button
className={styles.pdfDataClipboard}
size="small"
variant="text"
onClick={handleClick}
>
{hasCopied ? <>Copied! &#x2714;</> : "Click to Copy"}
</Button>
{pdfUrl !== null && (
<Button
className={styles.button}
href={pdfUrl}
size={"large"}
download={true}
variant="builder"
>
Click to Download
</Button>
)}
</div>
);
};
const getPdfButtonText = (): string => {
if (isLoading) {
return "Exporting PDF...";
} else if (isFinished) {
return "PDF Generated";
}
return "Export to PDF";
};
const ButtonLinkProps = {
href: characterSheetUrl,
};
return (
<div className={pageStyles.page}>
<p>
Once you have completed creating your character, you can view your
statistics on the digital character sheet or export it for printing.
</p>
<div className={styles.buttonWrapper}>
<Button
className={styles.button}
size="large"
variant="builder"
disabled={!isCharacterSheetReady}
onClick={handleMobileSheetShowClick}
{...(isCharacterSheetReady ? ButtonLinkProps : {})}
>
<CharacterSheetSvg />
<span className={styles.buttonText}>
View Character Sheet
{!isCharacterSheetReady && (
<span className={styles.buttonSubText}>
Unavailable - Character Incomplete
</span>
)}
</span>
</Button>
{!isVttView && (
<Button
className={clsx([
styles.button,
isFinished && styles.buttonComplete,
])}
size="large"
variant="builder"
disabled={!isCharacterSheetReady}
onClick={handleExportPdf}
>
{!isFinished ? <PdfSvg /> : <CheckMark />}
{getPdfButtonText()}
</Button>
)}
</div>
{renderPdfData()}
{!isCharacterSheetReady && (
<p className={styles.returnToHomeText}>
If you are unable to create a character due to missing options, return
to the <strong>Home</strong> tab and change your source settings.
</p>
)}
<div className={styles.section}>
{!isVttView && (
<Button
className={styles.button}
variant="builder-text"
href={characterListingUrl}
>
View all my characters
</Button>
)}
<h2 className={pageStyles.title}>Come Together</h2>
<p>
Most D&amp;D characters don't work alone. Each character plays a role
within a party, a group of adventurers working together for a common
purpose. Talk to your fellow players and your DM to decide whether
your characters know one another, how they met, and what sorts of
quests the group might undertake.
</p>
<p>
A campaign is a series of adventures undertaken by your group's
characters. If your DM has a campaign, you can join it by visiting the
invite link for the campaign. You can also start your own.
</p>
{!isVttView && (
<Button
variant="builder-text"
href="/campaigns/create"
className={styles.button}
>
Start a new campaign
</Button>
)}
</div>
</div>
);
};
@@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import ArrowDown from "@dndbeyond/fontawesome-cache/svgs/regular/arrow-down-to-line.svg";
import { Button } from "~/components/Button";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import config from "~/config";
import { UserPreferenceProvider } from "~/tools/js/smartComponents/UserPreference";
import { logUnlockSubscribeClicked } from "../../../../helpers/analytics";
import { byProp, createSortValue } from "../../../../helpers/sortUtils";
@@ -27,6 +27,7 @@ import {
SortOrderEnum,
SortTypeEnum,
} from "../../../../types";
import { UserPreferencesProvider } from "../../contexts/UserPreferences";
import { ApiStatusIndicator } from "../ApiStatusIndicator";
import { CharacterCard } from "../CharacterCard";
import { SubscriptionBanner } from "../SubscriptionBanner";
@@ -76,6 +77,7 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
}) => {
const userId = useUserId();
const subscriptionTier = useSubscriptionTier();
const { search: searchParams } = useLocation();
const [sortPreference, setSortPreference] = useLocalStorage<string>(
`MY_CHARACTERS_SORT_PREFERENCE:${userId}`,
@@ -190,15 +192,17 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
maxCharacterSlotsAllowed !== null &&
characterCount >= maxCharacterSlotsAllowed;
const createCharacterHref = !hasMaxCharacters
? `${config.basePathname}/builder`
: undefined;
const createCharacterOnClick = hasMaxCharacters
? () => setIsMaxCharacterMessageOpen(true)
: undefined;
const createCharacterHref =
!hasMaxCharacters && !hasLockedCharacters
? `${config.basePathname}/builder${searchParams}`
: undefined;
const createCharacterOnClick =
hasMaxCharacters || hasLockedCharacters
? () => setIsMaxCharacterMessageOpen(true)
: undefined;
return (
<UserPreferenceProvider>
<UserPreferencesProvider>
<div
className={[className, "ddb-characters-listing"]
.filter(Boolean)
@@ -403,8 +407,9 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
<MaxCharactersDialog
open={isMaxCharacterMessageOpen}
onClose={() => setIsMaxCharacterMessageOpen(false)}
hasLockedCharacters={hasLockedCharacters}
/>
</div>
</UserPreferenceProvider>
</UserPreferencesProvider>
);
};
@@ -14,11 +14,11 @@ import { Button } from "@dndbeyond/ttui/components/Button";
import { Select } from "@dndbeyond/ttui/components/Select";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import CharacterSettingsModal from "~/tools/js/smartComponents/CharacterSettingsModal";
import { SortOrderEnum, SortTypeEnum } from "~/types";
import { logListingSortChanged } from "../../../../../helpers/analytics";
import { createSortValue } from "../../../../../helpers/sortUtils";
import { CharacterSettingsModal } from "../../CharacterSettingsModal";
import { SortState } from "../CharacterGrid";
import styles from "./styles.module.css";
@@ -150,8 +150,7 @@ export const SearchSort: FC<SearchSortProps> = ({
</Button>
<CharacterSettingsModal
open={showSettingsModal}
updateLocation={PreferenceUpdateLocation.CharacterListing}
handleClose={() => setShowSettingsModal(false)}
onClose={() => setShowSettingsModal(false)}
/>
</>
</div>
@@ -0,0 +1,292 @@
import clsx from "clsx";
import { FC } from "react";
import { RuleDataUtils } from "@dndbeyond/character-rules-engine";
import GearIcon from "@dndbeyond/fontawesome-cache/svgs/regular/gear.svg";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import { Button } from "~/components/Button";
import { CheckboxGroup, CheckboxInfo } from "~/components/CheckboxGroup";
import { Dialog, DialogProps } from "~/components/Dialog";
import { PrivacyTypeRadio } from "~/components/PrivacyTypeRadio";
import { SourceCategoryDescription } from "~/constants";
import { PreferenceAbilityScoreDisplayTypeEnum } from "~/constants";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { generateCharacterPreferences } from "~/helpers/generateCharacterPreferences";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import AbilitySummary from "~/tools/js/smartComponents/AbilitySummary";
import { useUserPreferences } from "../../contexts/UserPreferences";
import styles from "./styles.module.css";
type AbilityScoreDisplayTypes = {
label: string;
value: number;
}[];
export interface CharacterSettingsModalProps extends DialogProps {}
export const CharacterSettingsModal: FC<CharacterSettingsModalProps> = ({
className,
open,
onClose,
...props
}) => {
const {
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updatePreferences,
ruleData,
defaultEnabledSourceCategories,
isHomebrewEnabled,
} = useUserPreferences();
const { featureFlags } = useFeatureFlags();
const updateLocation = PreferenceUpdateLocation.CharacterListing;
// Build an array of display types from enum
const abilityScoreDisplayTypes: AbilityScoreDisplayTypes = Object.entries(
PreferenceAbilityScoreDisplayTypeEnum
)
// Remove any duplicates which don't have numeric values
.filter((opt) => opt.some((val) => typeof val === "number"))
// Map to object with label and value formatting
.map(([label, value]) => ({
label: label.replace("_", " "),
value: Number(value),
}));
const preferences = generateCharacterPreferences(featureFlags);
const getTheme = (darkMode: boolean) => ({
name: "DDB Red",
backgroundColor: darkMode ? "#10161ADB" : "#FEFEFE",
isDefault: true,
themeColor: "#C53131",
themeColorId: null,
isDarkMode: darkMode || false,
});
const rollContext = {
entityId: "52962664",
entityType: "character",
name: "Stor Hornraven",
avatarUrl:
"https://stg.dndbeyond.com/avatars/18589/680/1581111423-52962664.jpeg?width=150&height=150&fit=crop&quality=95&auto=webp",
};
const handleSourceCategoryChange = (
sourceId: number,
isActive: boolean
): void => {
const newSourceCategories = {
...defaultEnabledSourceCategories,
[sourceId]: isActive,
};
updatePreferences({
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
defaultEnabledSourceCategories: newSourceCategories,
isHomebrewEnabled,
});
};
let sourceToggles: Array<CheckboxInfo> = [];
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
let allPartneredSources: Array<number> = [];
if (ruleData) {
const activeSourceCategories = Object.keys(defaultEnabledSourceCategories)
.filter((key) => defaultEnabledSourceCategories[key])
.map(Number);
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
if (!sourceCategory.isToggleable) {
return null;
}
const checkbox: CheckboxInfo = {
label: `${sourceCategory.name}`,
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
onChange: (e) =>
handleSourceCategoryChange(sourceCategory.id, e.target.checked),
sortOrder: sourceCategory.sortOrder,
description: sourceCategory.description ?? "",
};
if (sourceCategory.isPartneredContent) {
delete checkbox.description;
partneredSourceCheckboxes.push(checkbox);
allPartneredSources.push(sourceCategory.id);
} else {
sourceToggles.push(checkbox);
}
});
//Add Homebrew to sources for display
sourceToggles.push({
label: "Homebrew",
initiallyEnabled: isHomebrewEnabled,
description: SourceCategoryDescription.homebrew,
onChange: (e) => {
updatePreferences({
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
defaultEnabledSourceCategories,
isHomebrewEnabled: e.target.checked,
});
},
sortOrder: 0,
});
}
const handleChangeUnderdarkMode = () => {
const isChecked = !isDarkModeEnabled;
updatePreferences({
privacyType,
abilityScoreDisplayType,
updateLocation,
isDarkModeEnabled: isChecked,
defaultEnabledSourceCategories,
isHomebrewEnabled,
});
};
const handleChangeDisplayType = (value: number) => {
if (!isNaN(value))
updatePreferences({
privacyType,
isDarkModeEnabled,
updateLocation,
abilityScoreDisplayType: value,
defaultEnabledSourceCategories,
isHomebrewEnabled,
});
};
return (
<Dialog
className={clsx([styles.characterSettingsModal, className])}
open={open}
onClose={onClose}
modal
{...props}
>
<button
className={styles.closeButton}
onClick={onClose}
aria-label="Close the Character Settings Modal"
>
<CloseIcon />
</button>
<header className={styles.header}>
<GearIcon />
<h2>Default Character Settings</h2>
</header>
<section className={styles.body}>
<p className={styles.helpText}>
These settings apply defaults to all new Characters you create. Any
settings you apply to a specific Character will override the choices
you make here.
</p>
<CheckboxGroup
className={styles.checkboxGroup}
title="Sources"
description={SourceCategoryDescription.official}
checkboxes={sourceToggles}
name="sources"
showAccordion={true}
accordionDefaultOpen={true}
/>
<CheckboxGroup
className={styles.checkboxGroup}
title="Partnered Content"
description={SourceCategoryDescription.partnered}
checkboxes={partneredSourceCheckboxes}
name="partnered-content"
showAccordion={true}
accordionSummary="Choose Partners"
/>
<PrivacyTypeRadio
className={styles.privacyTypeRadio}
initialValue={privacyType}
onChange={(e) =>
updatePreferences({
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
privacyType: parseInt(e.target.value),
defaultEnabledSourceCategories,
isHomebrewEnabled,
})
}
/>
<div className={styles.displayGrid}>
<div className={styles.displayFields}>
<CheckboxGroup
className={styles.checkboxGroup}
title="Display"
name="underdark-mode"
data-testid="underdark-mode-toggle"
checkboxes={[
{
label: "Underdark Mode",
initiallyEnabled: isDarkModeEnabled,
onChange: handleChangeUnderdarkMode,
description: "Activate dark mode for Character Sheets.",
},
]}
/>
<div className={styles.abilityScores}>
<p className={styles.label}>Ability Scores / Modifiers</p>
<p className={styles.description}>
Reverse the display of Ability Scores and Modifiers.
</p>
<div className={styles.buttonGroup}>
{abilityScoreDisplayTypes
.reverse()
.map(({ label, value }, i) => (
<Button
className={clsx([
styles.buttonGroupButton,
abilityScoreDisplayType === value &&
styles.buttonGroupSelected,
])}
onClick={() => handleChangeDisplayType(Number(value))}
variant="outline"
size="small"
key={`ability-score-display-type-${value}`}
>
{label}
</Button>
))}
</div>
</div>
</div>
<div className={styles.displayPreview}>
<div className={styles.previewContainer}>
<p className={styles.previewText}>Preview</p>
<AbilitySummary
ability={{ name: "Strength", score: 14, modifier: 2 }}
preferences={{
...preferences,
abilityScoreDisplayType: abilityScoreDisplayType,
}}
theme={getTheme(isDarkModeEnabled || false)}
onClick={(e) => e.getBaseScore()}
rollContext={rollContext}
/>
</div>
</div>
</div>
</section>
</Dialog>
);
};
@@ -96,7 +96,6 @@ export const MyCharacters = ({ characterQuery }: Props) => {
const { isLoading, data, refetch, error } = characterQuery;
const { characters, hasLockedCharacters, maxCharacterSlotsAllowed } =
generateAppData(userName, data);
// Check for max active characters
const hasMaxCharacters =
characters?.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
@@ -0,0 +1,76 @@
import { createContext, useContext, useEffect, useState } from "react";
import { RuleData } from "@dndbeyond/character-rules-engine/es";
import {
getUserPreferences,
updateUserPreferences,
getRulesData,
} from "~/helpers/characterServiceApi";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import { UserPreferences } from "~/types";
interface UserPreferencesContextProps extends UserPreferences {
updatePreferences: (updated: UserPreferences) => void;
ruleData: RuleData | null;
}
const initialValue = {
abilityScoreDisplayType: 2,
privacyType: 2,
isDarkModeEnabled: false,
isDiceRollingEnabled: true,
updateLocation: PreferenceUpdateLocation.CharacterBuilder,
updatePreferences: () => {},
ruleData: null,
defaultEnabledSourceCategories: {},
isHomebrewEnabled: true,
};
export const UserPreferencesContext =
createContext<UserPreferencesContextProps>(initialValue);
export const UserPreferencesProvider = ({ children }) => {
const [preferences, setPreferences] = useState(initialValue);
const [isLoaded, setIsLoaded] = useState(false);
const [ruleData, setRuleData] = useState(null);
const updatePreferences = (updated: UserPreferences) => {
setPreferences((prev) => {
return { ...prev, ...updated };
});
};
const getPreferences = async () => {
const userPreferences = await getUserPreferences();
const userPreferencesJSON = await userPreferences.json();
const rulesData = await getRulesData();
const rulesDataJSON = await rulesData.json();
setRuleData(rulesDataJSON.data);
updatePreferences(userPreferencesJSON.data);
setIsLoaded(true);
};
useEffect(() => {
getPreferences();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isLoaded) {
updateUserPreferences(preferences);
}
}, [preferences]);
return (
<UserPreferencesContext.Provider
value={{ ...preferences, updatePreferences, ruleData }}
>
{children}
</UserPreferencesContext.Provider>
);
};
export const useUserPreferences = () => useContext(UserPreferencesContext);
@@ -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);