New source found from dndbeyond.com
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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! ✔</> : "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&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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user