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>
);
};