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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import ArrowDown from "@dndbeyond/fontawesome-cache/svgs/regular/arrow-down-to-line.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
|
||||
import config from "~/config";
|
||||
import { UserPreferenceProvider } from "~/tools/js/smartComponents/UserPreference";
|
||||
|
||||
import { logUnlockSubscribeClicked } from "../../../../helpers/analytics";
|
||||
import { byProp, createSortValue } from "../../../../helpers/sortUtils";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SortOrderEnum,
|
||||
SortTypeEnum,
|
||||
} from "../../../../types";
|
||||
import { UserPreferencesProvider } from "../../contexts/UserPreferences";
|
||||
import { ApiStatusIndicator } from "../ApiStatusIndicator";
|
||||
import { CharacterCard } from "../CharacterCard";
|
||||
import { SubscriptionBanner } from "../SubscriptionBanner";
|
||||
@@ -76,6 +77,7 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
|
||||
}) => {
|
||||
const userId = useUserId();
|
||||
const subscriptionTier = useSubscriptionTier();
|
||||
const { search: searchParams } = useLocation();
|
||||
|
||||
const [sortPreference, setSortPreference] = useLocalStorage<string>(
|
||||
`MY_CHARACTERS_SORT_PREFERENCE:${userId}`,
|
||||
@@ -190,15 +192,17 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
|
||||
maxCharacterSlotsAllowed !== null &&
|
||||
characterCount >= maxCharacterSlotsAllowed;
|
||||
|
||||
const createCharacterHref = !hasMaxCharacters
|
||||
? `${config.basePathname}/builder`
|
||||
: undefined;
|
||||
const createCharacterOnClick = hasMaxCharacters
|
||||
? () => setIsMaxCharacterMessageOpen(true)
|
||||
: undefined;
|
||||
const createCharacterHref =
|
||||
!hasMaxCharacters && !hasLockedCharacters
|
||||
? `${config.basePathname}/builder${searchParams}`
|
||||
: undefined;
|
||||
const createCharacterOnClick =
|
||||
hasMaxCharacters || hasLockedCharacters
|
||||
? () => setIsMaxCharacterMessageOpen(true)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<UserPreferenceProvider>
|
||||
<UserPreferencesProvider>
|
||||
<div
|
||||
className={[className, "ddb-characters-listing"]
|
||||
.filter(Boolean)
|
||||
@@ -403,8 +407,9 @@ export const CharacterGrid: React.FC<CharacterGridProps> = ({
|
||||
<MaxCharactersDialog
|
||||
open={isMaxCharacterMessageOpen}
|
||||
onClose={() => setIsMaxCharacterMessageOpen(false)}
|
||||
hasLockedCharacters={hasLockedCharacters}
|
||||
/>
|
||||
</div>
|
||||
</UserPreferenceProvider>
|
||||
</UserPreferencesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Button } from "@dndbeyond/ttui/components/Button";
|
||||
import { Select } from "@dndbeyond/ttui/components/Select";
|
||||
|
||||
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
|
||||
import CharacterSettingsModal from "~/tools/js/smartComponents/CharacterSettingsModal";
|
||||
import { SortOrderEnum, SortTypeEnum } from "~/types";
|
||||
|
||||
import { logListingSortChanged } from "../../../../../helpers/analytics";
|
||||
import { createSortValue } from "../../../../../helpers/sortUtils";
|
||||
import { CharacterSettingsModal } from "../../CharacterSettingsModal";
|
||||
import { SortState } from "../CharacterGrid";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
@@ -150,8 +150,7 @@ export const SearchSort: FC<SearchSortProps> = ({
|
||||
</Button>
|
||||
<CharacterSettingsModal
|
||||
open={showSettingsModal}
|
||||
updateLocation={PreferenceUpdateLocation.CharacterListing}
|
||||
handleClose={() => setShowSettingsModal(false)}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
|
||||
import { RuleDataUtils } from "@dndbeyond/character-rules-engine";
|
||||
import GearIcon from "@dndbeyond/fontawesome-cache/svgs/regular/gear.svg";
|
||||
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { CheckboxGroup, CheckboxInfo } from "~/components/CheckboxGroup";
|
||||
import { Dialog, DialogProps } from "~/components/Dialog";
|
||||
import { PrivacyTypeRadio } from "~/components/PrivacyTypeRadio";
|
||||
import { SourceCategoryDescription } from "~/constants";
|
||||
import { PreferenceAbilityScoreDisplayTypeEnum } from "~/constants";
|
||||
import { useFeatureFlags } from "~/contexts/FeatureFlag";
|
||||
import { generateCharacterPreferences } from "~/helpers/generateCharacterPreferences";
|
||||
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
|
||||
import AbilitySummary from "~/tools/js/smartComponents/AbilitySummary";
|
||||
|
||||
import { useUserPreferences } from "../../contexts/UserPreferences";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
type AbilityScoreDisplayTypes = {
|
||||
label: string;
|
||||
value: number;
|
||||
}[];
|
||||
|
||||
export interface CharacterSettingsModalProps extends DialogProps {}
|
||||
|
||||
export const CharacterSettingsModal: FC<CharacterSettingsModalProps> = ({
|
||||
className,
|
||||
open,
|
||||
onClose,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
privacyType,
|
||||
isDarkModeEnabled,
|
||||
abilityScoreDisplayType,
|
||||
updatePreferences,
|
||||
ruleData,
|
||||
defaultEnabledSourceCategories,
|
||||
isHomebrewEnabled,
|
||||
} = useUserPreferences();
|
||||
|
||||
const { featureFlags } = useFeatureFlags();
|
||||
|
||||
const updateLocation = PreferenceUpdateLocation.CharacterListing;
|
||||
|
||||
// Build an array of display types from enum
|
||||
const abilityScoreDisplayTypes: AbilityScoreDisplayTypes = Object.entries(
|
||||
PreferenceAbilityScoreDisplayTypeEnum
|
||||
)
|
||||
// Remove any duplicates which don't have numeric values
|
||||
.filter((opt) => opt.some((val) => typeof val === "number"))
|
||||
// Map to object with label and value formatting
|
||||
.map(([label, value]) => ({
|
||||
label: label.replace("_", " "),
|
||||
value: Number(value),
|
||||
}));
|
||||
|
||||
const preferences = generateCharacterPreferences(featureFlags);
|
||||
|
||||
const getTheme = (darkMode: boolean) => ({
|
||||
name: "DDB Red",
|
||||
backgroundColor: darkMode ? "#10161ADB" : "#FEFEFE",
|
||||
isDefault: true,
|
||||
themeColor: "#C53131",
|
||||
themeColorId: null,
|
||||
isDarkMode: darkMode || false,
|
||||
});
|
||||
|
||||
const rollContext = {
|
||||
entityId: "52962664",
|
||||
entityType: "character",
|
||||
name: "Stor Hornraven",
|
||||
avatarUrl:
|
||||
"https://stg.dndbeyond.com/avatars/18589/680/1581111423-52962664.jpeg?width=150&height=150&fit=crop&quality=95&auto=webp",
|
||||
};
|
||||
|
||||
const handleSourceCategoryChange = (
|
||||
sourceId: number,
|
||||
isActive: boolean
|
||||
): void => {
|
||||
const newSourceCategories = {
|
||||
...defaultEnabledSourceCategories,
|
||||
[sourceId]: isActive,
|
||||
};
|
||||
|
||||
updatePreferences({
|
||||
privacyType,
|
||||
isDarkModeEnabled,
|
||||
abilityScoreDisplayType,
|
||||
updateLocation,
|
||||
defaultEnabledSourceCategories: newSourceCategories,
|
||||
isHomebrewEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
let sourceToggles: Array<CheckboxInfo> = [];
|
||||
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
|
||||
let allPartneredSources: Array<number> = [];
|
||||
|
||||
if (ruleData) {
|
||||
const activeSourceCategories = Object.keys(defaultEnabledSourceCategories)
|
||||
.filter((key) => defaultEnabledSourceCategories[key])
|
||||
.map(Number);
|
||||
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
|
||||
if (!sourceCategory.isToggleable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkbox: CheckboxInfo = {
|
||||
label: `${sourceCategory.name}`,
|
||||
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
|
||||
onChange: (e) =>
|
||||
handleSourceCategoryChange(sourceCategory.id, e.target.checked),
|
||||
sortOrder: sourceCategory.sortOrder,
|
||||
description: sourceCategory.description ?? "",
|
||||
};
|
||||
|
||||
if (sourceCategory.isPartneredContent) {
|
||||
delete checkbox.description;
|
||||
partneredSourceCheckboxes.push(checkbox);
|
||||
allPartneredSources.push(sourceCategory.id);
|
||||
} else {
|
||||
sourceToggles.push(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
//Add Homebrew to sources for display
|
||||
sourceToggles.push({
|
||||
label: "Homebrew",
|
||||
initiallyEnabled: isHomebrewEnabled,
|
||||
description: SourceCategoryDescription.homebrew,
|
||||
onChange: (e) => {
|
||||
updatePreferences({
|
||||
privacyType,
|
||||
isDarkModeEnabled,
|
||||
abilityScoreDisplayType,
|
||||
updateLocation,
|
||||
defaultEnabledSourceCategories,
|
||||
isHomebrewEnabled: e.target.checked,
|
||||
});
|
||||
},
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const handleChangeUnderdarkMode = () => {
|
||||
const isChecked = !isDarkModeEnabled;
|
||||
|
||||
updatePreferences({
|
||||
privacyType,
|
||||
abilityScoreDisplayType,
|
||||
updateLocation,
|
||||
isDarkModeEnabled: isChecked,
|
||||
defaultEnabledSourceCategories,
|
||||
isHomebrewEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeDisplayType = (value: number) => {
|
||||
if (!isNaN(value))
|
||||
updatePreferences({
|
||||
privacyType,
|
||||
isDarkModeEnabled,
|
||||
updateLocation,
|
||||
abilityScoreDisplayType: value,
|
||||
defaultEnabledSourceCategories,
|
||||
isHomebrewEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={clsx([styles.characterSettingsModal, className])}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
modal
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Close the Character Settings Modal"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<header className={styles.header}>
|
||||
<GearIcon />
|
||||
<h2>Default Character Settings</h2>
|
||||
</header>
|
||||
<section className={styles.body}>
|
||||
<p className={styles.helpText}>
|
||||
These settings apply defaults to all new Characters you create. Any
|
||||
settings you apply to a specific Character will override the choices
|
||||
you make here.
|
||||
</p>
|
||||
<CheckboxGroup
|
||||
className={styles.checkboxGroup}
|
||||
title="Sources"
|
||||
description={SourceCategoryDescription.official}
|
||||
checkboxes={sourceToggles}
|
||||
name="sources"
|
||||
showAccordion={true}
|
||||
accordionDefaultOpen={true}
|
||||
/>
|
||||
<CheckboxGroup
|
||||
className={styles.checkboxGroup}
|
||||
title="Partnered Content"
|
||||
description={SourceCategoryDescription.partnered}
|
||||
checkboxes={partneredSourceCheckboxes}
|
||||
name="partnered-content"
|
||||
showAccordion={true}
|
||||
accordionSummary="Choose Partners"
|
||||
/>
|
||||
<PrivacyTypeRadio
|
||||
className={styles.privacyTypeRadio}
|
||||
initialValue={privacyType}
|
||||
onChange={(e) =>
|
||||
updatePreferences({
|
||||
isDarkModeEnabled,
|
||||
abilityScoreDisplayType,
|
||||
updateLocation,
|
||||
privacyType: parseInt(e.target.value),
|
||||
defaultEnabledSourceCategories,
|
||||
isHomebrewEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className={styles.displayGrid}>
|
||||
<div className={styles.displayFields}>
|
||||
<CheckboxGroup
|
||||
className={styles.checkboxGroup}
|
||||
title="Display"
|
||||
name="underdark-mode"
|
||||
data-testid="underdark-mode-toggle"
|
||||
checkboxes={[
|
||||
{
|
||||
label: "Underdark Mode",
|
||||
initiallyEnabled: isDarkModeEnabled,
|
||||
onChange: handleChangeUnderdarkMode,
|
||||
description: "Activate dark mode for Character Sheets.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className={styles.abilityScores}>
|
||||
<p className={styles.label}>Ability Scores / Modifiers</p>
|
||||
<p className={styles.description}>
|
||||
Reverse the display of Ability Scores and Modifiers.
|
||||
</p>
|
||||
<div className={styles.buttonGroup}>
|
||||
{abilityScoreDisplayTypes
|
||||
.reverse()
|
||||
.map(({ label, value }, i) => (
|
||||
<Button
|
||||
className={clsx([
|
||||
styles.buttonGroupButton,
|
||||
abilityScoreDisplayType === value &&
|
||||
styles.buttonGroupSelected,
|
||||
])}
|
||||
onClick={() => handleChangeDisplayType(Number(value))}
|
||||
variant="outline"
|
||||
size="small"
|
||||
key={`ability-score-display-type-${value}`}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.displayPreview}>
|
||||
<div className={styles.previewContainer}>
|
||||
<p className={styles.previewText}>Preview</p>
|
||||
<AbilitySummary
|
||||
ability={{ name: "Strength", score: 14, modifier: 2 }}
|
||||
preferences={{
|
||||
...preferences,
|
||||
abilityScoreDisplayType: abilityScoreDisplayType,
|
||||
}}
|
||||
theme={getTheme(isDarkModeEnabled || false)}
|
||||
onClick={(e) => e.getBaseScore()}
|
||||
rollContext={rollContext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -96,7 +96,6 @@ export const MyCharacters = ({ characterQuery }: Props) => {
|
||||
const { isLoading, data, refetch, error } = characterQuery;
|
||||
const { characters, hasLockedCharacters, maxCharacterSlotsAllowed } =
|
||||
generateAppData(userName, data);
|
||||
// Check for max active characters
|
||||
const hasMaxCharacters =
|
||||
characters?.filter(
|
||||
(character) => getStatus(character) === CharacterStatusEnum.Active
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
import { RuleData } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import {
|
||||
getUserPreferences,
|
||||
updateUserPreferences,
|
||||
getRulesData,
|
||||
} from "~/helpers/characterServiceApi";
|
||||
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
|
||||
import { UserPreferences } from "~/types";
|
||||
|
||||
interface UserPreferencesContextProps extends UserPreferences {
|
||||
updatePreferences: (updated: UserPreferences) => void;
|
||||
ruleData: RuleData | null;
|
||||
}
|
||||
|
||||
const initialValue = {
|
||||
abilityScoreDisplayType: 2,
|
||||
privacyType: 2,
|
||||
isDarkModeEnabled: false,
|
||||
isDiceRollingEnabled: true,
|
||||
updateLocation: PreferenceUpdateLocation.CharacterBuilder,
|
||||
updatePreferences: () => {},
|
||||
ruleData: null,
|
||||
defaultEnabledSourceCategories: {},
|
||||
isHomebrewEnabled: true,
|
||||
};
|
||||
|
||||
export const UserPreferencesContext =
|
||||
createContext<UserPreferencesContextProps>(initialValue);
|
||||
|
||||
export const UserPreferencesProvider = ({ children }) => {
|
||||
const [preferences, setPreferences] = useState(initialValue);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [ruleData, setRuleData] = useState(null);
|
||||
|
||||
const updatePreferences = (updated: UserPreferences) => {
|
||||
setPreferences((prev) => {
|
||||
return { ...prev, ...updated };
|
||||
});
|
||||
};
|
||||
|
||||
const getPreferences = async () => {
|
||||
const userPreferences = await getUserPreferences();
|
||||
const userPreferencesJSON = await userPreferences.json();
|
||||
|
||||
const rulesData = await getRulesData();
|
||||
const rulesDataJSON = await rulesData.json();
|
||||
|
||||
setRuleData(rulesDataJSON.data);
|
||||
updatePreferences(userPreferencesJSON.data);
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getPreferences();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
updateUserPreferences(preferences);
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
return (
|
||||
<UserPreferencesContext.Provider
|
||||
value={{ ...preferences, updatePreferences, ruleData }}
|
||||
>
|
||||
{children}
|
||||
</UserPreferencesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUserPreferences = () => useContext(UserPreferencesContext);
|
||||
@@ -0,0 +1,23 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { DeathCauseEnum, DefaultCharacterName } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface CharacterNameProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const CharacterName: FC<CharacterNameProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { deathCause, characterName } = useCharacterEngine();
|
||||
|
||||
return (
|
||||
<h1 className={clsx([styles.characterName, className])} {...props}>
|
||||
{deathCause !== DeathCauseEnum.NONE && "(Dead) "}
|
||||
{characterName || DefaultCharacterName}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { GuidedTour } from "~/components/GuidedTour";
|
||||
import { useFeatureFlags } from "~/contexts/FeatureFlag";
|
||||
|
||||
import { getCharacterSheetSteps } from "./getCharacterSheetSteps";
|
||||
|
||||
export const CharacterSheetGuidedTour = ({ children, ...props }) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const { characterSheetTourFlag } = useFeatureFlags();
|
||||
const isMobile = window.matchMedia("(max-width: 767px)").matches;
|
||||
const isTablet = window.matchMedia(
|
||||
"(min-width: 768px and max-width: 1023px)"
|
||||
).matches;
|
||||
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
|
||||
const ready = useSelector(rulesEngineSelectors.isCharacterSheetReady);
|
||||
// const hasSpells = useSelector(rulesEngineSelectors.hasSpells);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const setCurrentStep = (currentStep) => {
|
||||
switch (currentStep) {
|
||||
case 11: {
|
||||
if (isMobile) {
|
||||
const toggle = document.querySelector(
|
||||
"[class^='styles_navToggle']"
|
||||
) as HTMLButtonElement;
|
||||
toggle?.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 13: {
|
||||
if (isTablet) {
|
||||
const toggle = document.querySelector(
|
||||
"[class^='styles_navToggle']"
|
||||
) as HTMLButtonElement;
|
||||
toggle?.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setStep(currentStep);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// If character sheet has loaded, enable tour
|
||||
if (ready) setLoading(false);
|
||||
}, [ready]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{characterSheetTourFlag && !loading ? (
|
||||
<GuidedTour
|
||||
steps={getCharacterSheetSteps(false, isTablet, isDesktop)}
|
||||
step={step}
|
||||
setStep={setCurrentStep}
|
||||
showOnFirstLoad={true}
|
||||
cookieName="characterSheetGuidedTour"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</GuidedTour>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
import { StepType } from "@reactour/tour";
|
||||
|
||||
import { GuidedTourStep } from "~/components/GuidedTourStep";
|
||||
|
||||
/**
|
||||
* CHARACTER SHEET TOUR STEPS
|
||||
**/
|
||||
export const getCharacterSheetSteps = (
|
||||
hasSpells: boolean,
|
||||
isTablet: boolean,
|
||||
isDesktop: boolean
|
||||
): StepType[] => {
|
||||
const getSelector = (
|
||||
mobileSelector: string,
|
||||
tabletSelector?: string,
|
||||
desktopSelector?: string
|
||||
) =>
|
||||
isDesktop && desktopSelector
|
||||
? desktopSelector
|
||||
: isTablet && tabletSelector
|
||||
? tabletSelector
|
||||
: mobileSelector;
|
||||
|
||||
const stepList = [
|
||||
{
|
||||
selector: getSelector("html"),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Welcome!"
|
||||
content={
|
||||
<>
|
||||
Welcome to your character sheet! Here, you can find information
|
||||
about your character, roll dice, and find more information about
|
||||
other characters in your game. If you ever have any questions
|
||||
about rules or what something is for, you can click on almost
|
||||
anything to learn more!
|
||||
</>
|
||||
}
|
||||
showClose
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(".dice-toolbar"),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Digital Dice"
|
||||
content={
|
||||
<>
|
||||
Click here if you need to make a custom dice roll. Your dice
|
||||
collection can be found{" "}
|
||||
<a
|
||||
href="https://www.dndbeyond.com/my-dice"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-character-header-mobile__group-tidbits",
|
||||
".ct-character-header-tablet__group-tidbits",
|
||||
".ct-character-header-desktop__group-tidbits"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Character Overview"
|
||||
content={
|
||||
<>
|
||||
Basic information about your character such as Name, Species,
|
||||
Class, and Level can be found here. Click this area to change your
|
||||
character's name, sheet styles, and modify other settings for your
|
||||
character.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-character-header-desktop__group--builder",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Character Builder"
|
||||
content={
|
||||
<>
|
||||
Click here to visit the Character Builder. Each time you level up,
|
||||
check here to see how to develop your character.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-main-mobile__abilities",
|
||||
".ct-main-tablet__abilities",
|
||||
".ct-quick-info__abilities"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Ability Scores"
|
||||
content={
|
||||
<>
|
||||
Much of what your character does in the game depends on his or her
|
||||
six abilities: Strength, Dexterity, Constitution, Intelligence,
|
||||
Wisdom, and Charisma. Learn more about an ability by clicking on
|
||||
it.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-combat-mobile__extra--proficiency",
|
||||
".ct-combat-tablet__extra--proficiency",
|
||||
".ct-quick-info__box--proficiency"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Proficiency Bonus"
|
||||
content={
|
||||
<>
|
||||
Proficiency is added to rolls made to accomplish tasks with which
|
||||
your character is proficient. The bonus is automatically added to
|
||||
rolls when it is needed. Click here to learn more about how
|
||||
Proficiency Bonuses are used.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-combat-mobile__extra--speed",
|
||||
".ct-combat-tablet__extra--speed",
|
||||
".ct-quick-info__box--speed"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Speed"
|
||||
content={
|
||||
<>
|
||||
In each round of combat, your character can move up to the total
|
||||
distance indicated by your speed. Click here to learn more or
|
||||
customize your speed.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-status-summary-mobile__health",
|
||||
".ct-status-summary-mobile__health",
|
||||
".ct-health-summary"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Hit Points"
|
||||
content={
|
||||
<>
|
||||
In this area, you can manage your character's hit points. When
|
||||
your character reaches zero hit points, they are on the brink of
|
||||
death and begin making death saving throws.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-combat-mobile__extra--initiative",
|
||||
".ct-combat-tablet__extra--initiative",
|
||||
".ct-combat__summary-group--initiative"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Initiative"
|
||||
content={
|
||||
<>
|
||||
Initiative rolls determine the order in which you go in combat.
|
||||
The higher the number, the sooner you get to fight. Click here to
|
||||
roll initiative!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-combat-mobile__extra--ac",
|
||||
".ct-combat-tablet__extra--ac",
|
||||
".ct-combat__summary-group--ac"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Armor Class"
|
||||
content={
|
||||
<>
|
||||
Your Armor Class (AC) represents how well your character avoids
|
||||
being wounded in battle.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: getSelector(
|
||||
".ct-main-mobile__saving-throws",
|
||||
".ct-saving-throws-box",
|
||||
".ct-saving-throws-box"
|
||||
),
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Saving Throws"
|
||||
content={
|
||||
<>
|
||||
Your DM may ask you to roll a saving throw (or make a save) to
|
||||
resist an incoming effect. Click on a save to automatically roll.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(isDesktop || isTablet
|
||||
? [
|
||||
{
|
||||
selector: ".ct-proficiency-groups-box",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Proficiencies"
|
||||
content={
|
||||
<>
|
||||
Proficiencies tell you what tools, equipment, and languages
|
||||
your character is skilled at using.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-skills-box",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Skills"
|
||||
content={
|
||||
<>
|
||||
If you wish to perform an action, your DM will determine
|
||||
which of these skills you will use. Click on a skill to roll
|
||||
or learn more.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--skills",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Skills"
|
||||
content={
|
||||
<>
|
||||
If you wish to perform an action, your DM will determine
|
||||
which of these skills you will use. Click on a skill to roll
|
||||
or learn more.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
...(!isDesktop
|
||||
? [
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--actions",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Actions"
|
||||
content={
|
||||
<>
|
||||
When you take your action on your turn, you can take one of
|
||||
the actions presented here. You can track actions or make
|
||||
rolls to perform them by clicking in this panel.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--equipment",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Inventory"
|
||||
content={
|
||||
<>
|
||||
View and manage your character's items and coin from this
|
||||
panel.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--spells",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Spells"
|
||||
content={
|
||||
<>
|
||||
If your character has the ability to cast spells, look here
|
||||
for a list of spells and to track your spellcasting.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--features",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Features & Traits"
|
||||
content={
|
||||
<>
|
||||
This section describes the source of your character's
|
||||
abilities. How this manifests in your character's
|
||||
personality is up to you.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(!isTablet
|
||||
? [
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--proficiencies",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Proficiencies"
|
||||
content={
|
||||
<>
|
||||
Proficiencies tell you what tools, equipment, and
|
||||
languages your character is skilled at using.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
selector: ".ct-quick-nav__menu-item--description",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Description"
|
||||
content={
|
||||
<>
|
||||
Use the description panel to tell your character's story.
|
||||
Your character's background features are also found here.
|
||||
</>
|
||||
}
|
||||
showClose
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
selector: ".ct-primary-box__tab--actions",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Actions"
|
||||
content={
|
||||
<>
|
||||
When you take your action on your turn, you can take one of
|
||||
the actions presented here. You can track actions or make
|
||||
rolls to perform them by clicking in this panel.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(hasSpells
|
||||
? [
|
||||
{
|
||||
selector: ".ct-primary-box__tab--spells",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Spells"
|
||||
content={
|
||||
<>
|
||||
If your character has the ability to cast spells, look
|
||||
here for a list of spells and to track your
|
||||
spellcasting.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
selector: ".ct-primary-box__tab--equipment",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Inventory"
|
||||
content={
|
||||
<>
|
||||
View and manage your character's items and coin from this
|
||||
panel.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-primary-box__tab--features",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Features & Traits"
|
||||
content={
|
||||
<>
|
||||
This section describes the source of your character's
|
||||
abilities. How this manifests in your character's
|
||||
personality is up to you.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
selector: ".ct-primary-box__tab--description",
|
||||
content: (
|
||||
<GuidedTourStep
|
||||
title="Description"
|
||||
content={
|
||||
<>
|
||||
Use the description panel to tell your character's story.
|
||||
Your character's background features are also found here.
|
||||
</>
|
||||
}
|
||||
showClose
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
];
|
||||
return stepList.filter((s) => s);
|
||||
};
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import ChevronIcon from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog, DialogProps } from "~/components/Dialog";
|
||||
import characterGalleryImg from "~/images/character-gallery.png";
|
||||
import characterSheetImg from "~/images/character-sheet.png";
|
||||
import tadaEmoji from "~/images/tada-emoji.svg?url";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ClaimConfirmationDialogProps
|
||||
extends Omit<DialogProps, "onClose" | "open"> {
|
||||
characterId: number;
|
||||
campaignId?: number | null;
|
||||
}
|
||||
|
||||
export const ClaimConfirmationDialog: FC<ClaimConfirmationDialogProps> = ({
|
||||
className,
|
||||
characterId,
|
||||
campaignId,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const handleClose = () => {
|
||||
window.location.href = `/characters/${characterId}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (characterId) setIsOpen(true);
|
||||
}, [characterId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={clsx([styles.dialog, className])}
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
modal
|
||||
{...props}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>
|
||||
<img src={tadaEmoji} alt="Ta-da Emoji" />
|
||||
Congratulations!
|
||||
</h2>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
variant="tool"
|
||||
onClick={handleClose}
|
||||
aria-label="Close Modal"
|
||||
>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<p className={styles.text}>
|
||||
Your character has been claimed and is now available:
|
||||
</p>
|
||||
<div className={styles.card}>
|
||||
<img
|
||||
className={styles.cardImage}
|
||||
src={characterGalleryImg}
|
||||
alt="Premade character gallery"
|
||||
/>
|
||||
<Button
|
||||
className={styles.cardButton}
|
||||
href="/characters"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
>
|
||||
Go to My Characters <ChevronIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<img
|
||||
className={styles.cardImage}
|
||||
src={characterSheetImg}
|
||||
alt="Sample character sheet"
|
||||
/>
|
||||
<Button
|
||||
className={styles.cardButton}
|
||||
href={`/characters/${characterId}`}
|
||||
color="info"
|
||||
size="x-small"
|
||||
>
|
||||
View Your Character <ChevronIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
{campaignId && (
|
||||
<Button
|
||||
href={`/campaigns/${campaignId}`}
|
||||
variant="text"
|
||||
color="info"
|
||||
size="small"
|
||||
>
|
||||
or return to your campaign
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { Button, ButtonProps } from "~/components/Button";
|
||||
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
|
||||
import { useAuth } from "~/contexts/Authentication";
|
||||
import { useClaimCharacter } from "~/hooks/useClaimCharacter";
|
||||
import bgImage from "~/images/claim.png";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import { ClaimConfirmationDialog } from "../ClaimConfirmationDialog";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* This component is used when previewing a premade character. It is displayed on the character
|
||||
* sheet and renders with an onclick when signed in or a link to create an account when not signed
|
||||
* in.
|
||||
*/
|
||||
|
||||
export const ClaimPremadeButton: FC<ButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const createAccountLink = `/create-account?returnUrl=${window.location.pathname}`;
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const campaignJoinCode = params.get("campaignJoinCode");
|
||||
const isAssigned = params.get("isAssigned") === "true";
|
||||
const [isMaxCharacterOpen, setIsMaxCharacterOpen] = useState(false);
|
||||
const user = useAuth();
|
||||
const { characterSlotLimit, activeCharacterCount } = useSelector(
|
||||
appEnvSelectors.getCharacterSlots
|
||||
);
|
||||
|
||||
const [
|
||||
claimCharacter,
|
||||
isClaimingCharacter,
|
||||
isFinishedClaimingCharacter,
|
||||
newCharacterId,
|
||||
campaignId,
|
||||
] = useClaimCharacter({
|
||||
campaignJoinCode,
|
||||
isAssigned,
|
||||
});
|
||||
const isClaimed = isFinishedClaimingCharacter && !!newCharacterId;
|
||||
// Character slot limit is null for admin accounts
|
||||
const hasOpenSlot =
|
||||
characterSlotLimit === null || activeCharacterCount < characterSlotLimit;
|
||||
|
||||
const handleClick = () => {
|
||||
hasOpenSlot ? claimCharacter() : setIsMaxCharacterOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isClaimed && (
|
||||
<Button
|
||||
className={clsx([styles.claimPremadeButton, className])}
|
||||
href={!user ? createAccountLink : undefined}
|
||||
onClick={!!user ? handleClick : undefined}
|
||||
disabled={isClaimingCharacter}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<img className={styles.bgImage} src={bgImage} alt="" />
|
||||
<div className={styles.content}>
|
||||
{!user && "Create Account to "}
|
||||
Claim Character <ChevronRight />
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<MaxCharactersDialog
|
||||
open={isMaxCharacterOpen}
|
||||
onClose={() => setIsMaxCharacterOpen(false)}
|
||||
useMyCharactersLink
|
||||
/>
|
||||
{isClaimed && (
|
||||
<ClaimConfirmationDialog
|
||||
characterId={newCharacterId}
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
CampaignUtils,
|
||||
CharacterCurrencyContract,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import CurrencyButton from "~/tools/js/CharacterSheet/components/CurrencyButton";
|
||||
import { CoinManagerContext } from "~/tools/js/Shared/managers/CoinManagerContext";
|
||||
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
|
||||
import { PaneComponentEnum } from "../../Sidebar/types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface EquipmentOverviewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
activeEquipmentTab: "character" | "party";
|
||||
}
|
||||
|
||||
export const EquipmentOverview: FC<EquipmentOverviewProps> = ({
|
||||
className,
|
||||
activeEquipmentTab,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
partyInfo,
|
||||
currencies,
|
||||
totalCarriedWeight,
|
||||
currentCarriedWeightType,
|
||||
} = useCharacterEngine();
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const [searchParams] = useSearchParams();
|
||||
const isVttView = searchParams.get("view") === "vtt";
|
||||
|
||||
const isPartyTab = activeEquipmentTab === "party";
|
||||
const campaignName = partyInfo ? CampaignUtils.getName(partyInfo) : null;
|
||||
|
||||
const weightSpeedLabel = RuleDataUtils.getWeightSpeedTypeLabel(
|
||||
currentCarriedWeightType
|
||||
);
|
||||
|
||||
let coin: CharacterCurrencyContract | null = currencies;
|
||||
//if "coins in containers" is enabled, get the total coin for either the player or party
|
||||
if (isPartyTab && partyInfo) {
|
||||
coin = CampaignUtils.getCoin(partyInfo);
|
||||
}
|
||||
if (coinManager?.canUseCointainers()) {
|
||||
coin =
|
||||
isPartyTab && partyInfo
|
||||
? coinManager.getAllPartyCoin()
|
||||
: coinManager.getAllCharacterCoin();
|
||||
}
|
||||
|
||||
const handlePrimaryButtonClick = (
|
||||
evt: React.MouseEvent | React.KeyboardEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
if (isPartyTab) {
|
||||
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
|
||||
} else {
|
||||
paneHistoryStart(PaneComponentEnum.ENCUMBRANCE);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrencyClick = (
|
||||
evt: React.MouseEvent | React.KeyboardEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
const partyContainerKey =
|
||||
inventoryManager.getPartyEquipmentContainerDefinitionKey();
|
||||
const characterContainerKey =
|
||||
inventoryManager.getCharacterContainerDefinitionKey();
|
||||
|
||||
paneHistoryStart(
|
||||
PaneComponentEnum.CURRENCY,
|
||||
PaneIdentifierUtils.generateCurrencyContext(
|
||||
isPartyTab && partyContainerKey
|
||||
? partyContainerKey
|
||||
: characterContainerKey
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.equipmentOverview, className])} {...props}>
|
||||
{isPartyTab ? (
|
||||
!isVttView ? (
|
||||
<Button
|
||||
variant="text"
|
||||
themed
|
||||
className={styles.overviewPrimaryButton}
|
||||
onClick={handlePrimaryButtonClick}
|
||||
size="x-small"
|
||||
>
|
||||
{`Campaign: ${campaignName}`}
|
||||
</Button>
|
||||
) : (
|
||||
<p className={styles.overviewText}>{`Campaign: ${campaignName}`}</p>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="text"
|
||||
themed
|
||||
className={styles.overviewPrimaryButton}
|
||||
onClick={handlePrimaryButtonClick}
|
||||
size="x-small"
|
||||
>
|
||||
<div className={styles.weightButton}>
|
||||
<span className={styles.weightButtonText}>
|
||||
Weight Carried:{" "}
|
||||
<NumberDisplay
|
||||
className={styles.weight}
|
||||
data-testid="weight-carried-number"
|
||||
type="weightInLb"
|
||||
number={totalCarriedWeight}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.weightSecondaryText}>
|
||||
{weightSpeedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<CurrencyButton
|
||||
handleCurrencyClick={handleCurrencyClick}
|
||||
coin={coin}
|
||||
isDarkMode={isDarkMode}
|
||||
shouldShowPartyInventory={isPartyTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keyBy } from "lodash";
|
||||
import React from "react";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Snippet } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
@@ -23,16 +23,18 @@ import {
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Reference } from "~/components/Reference";
|
||||
import FeatureSnippetActions from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetActions";
|
||||
import FeatureSnippetChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetChoices";
|
||||
import FeatureSnippetInfusionChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetInfusionChoices";
|
||||
import FeatureSnippetOption from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetOption";
|
||||
import FeatureSnippetSpells from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetSpells";
|
||||
import {
|
||||
FeatureSnippetActions,
|
||||
FeatureSnippetChoices,
|
||||
FeatureSnippetInfusionChoices,
|
||||
FeatureSnippetOption,
|
||||
FeatureSnippetSpells,
|
||||
} from "~/tools/js/CharacterSheet/components/FeatureSnippet";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//TODO: tools/js/CharacterSheet/components/FeatureSnipper components still need to be gradually migrated to FC
|
||||
interface Props {
|
||||
interface Props extends PropsWithChildren {
|
||||
heading: React.ReactNode;
|
||||
dataOriginExtra?: string;
|
||||
extraMeta: Array<string>;
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState, useRef } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import accessibility from "~/styles/accessibility.module.css";
|
||||
import { HP_TEMP_VALUE } from "~/subApps/sheet/constants";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
AdvantageIcon,
|
||||
BoxBackground,
|
||||
DigitalDiceWrapper,
|
||||
InitiativeBoxSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { DiceTools, RollKind, RollType } from "@dndbeyond/dice";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
import {
|
||||
RollKinds,
|
||||
RollTypes,
|
||||
} from "@dndbeyond/pocket-dimension-dice/constants";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import a11yStyles from "~/styles/accessibility.module.css";
|
||||
import {
|
||||
appEnvSelectors,
|
||||
characterRollContextSelectors,
|
||||
} from "~/tools/js/Shared/selectors";
|
||||
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript/utils";
|
||||
|
||||
import { PaneComponentEnum } from "../Sidebar/types";
|
||||
import styles from "./styles.module.css";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
isMobile?: boolean;
|
||||
isTablet?: boolean;
|
||||
}
|
||||
export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
|
||||
useContext(GameLogContext);
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
@@ -40,9 +42,10 @@ export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
|
||||
|
||||
const {
|
||||
hasInitiativeAdvantage,
|
||||
processedInitiative: initiative,
|
||||
processedInitiative,
|
||||
characterTheme: theme,
|
||||
} = useCharacterEngine();
|
||||
const initiative = Number(processedInitiative);
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
|
||||
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
||||
@@ -71,35 +74,32 @@ export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
|
||||
{!isMobile && (
|
||||
<>
|
||||
<BoxBackground StyleComponent={InitiativeBoxSvg} theme={theme} />
|
||||
<h2 style={visuallyHidden}>Initiative</h2>
|
||||
<h2 className={a11yStyles.screenreaderOnly}>Initiative</h2>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DigitalDiceWrapper
|
||||
diceNotation={DiceTools.CustomD20(initiative)}
|
||||
rollType={RollType.Roll}
|
||||
rollAction={"Initiative"}
|
||||
rollKind={hasInitiativeAdvantage ? RollKind.Advantage : RollKind.None}
|
||||
diceEnabled={diceEnabled}
|
||||
themeColor={theme.themeColor}
|
||||
rollContext={rollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions?.entities
|
||||
? Object.values(messageTargetOptions?.entities).filter(
|
||||
isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
userId={Number(userId)}
|
||||
diceNotation={`1d20${
|
||||
initiative > 0
|
||||
? `+${initiative}`
|
||||
: initiative !== 0
|
||||
? initiative
|
||||
: ""
|
||||
}`}
|
||||
rollType={RollTypes.Roll}
|
||||
action="Initiative"
|
||||
rollKind={hasInitiativeAdvantage ? RollKinds.Advantage : undefined}
|
||||
isDiceEnabled={diceEnabled}
|
||||
entityId={String(rollContext.entityId)}
|
||||
entityType={String(rollContext.entityType)}
|
||||
name={String(rollContext.name)}
|
||||
>
|
||||
<NumberDisplay
|
||||
number={initiative}
|
||||
type="signed"
|
||||
size="large"
|
||||
className={clsx([
|
||||
isMobile && !theme.isDarkMode && styles.numberColorOverride,
|
||||
])}
|
||||
number={initiative}
|
||||
type="signed"
|
||||
size="large"
|
||||
className={clsx([
|
||||
isMobile && !theme.isDarkMode && styles.numberColorOverride,
|
||||
])}
|
||||
/>
|
||||
</DigitalDiceWrapper>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
Item,
|
||||
ItemPlan,
|
||||
ItemPlanUtils,
|
||||
ItemUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
import ItemPreview from "~/tools/js/smartComponents/ItemPreview";
|
||||
|
||||
import { PaneComponentEnum } from "../Sidebar/types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface MagicItemPlansProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const MagicItemPlans: FC<MagicItemPlansProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { characterTheme, itemPlans, maxReplicatedItemsCount } =
|
||||
useCharacterEngine();
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const itemPlanWithMappingsCount = itemPlans.filter((plan) =>
|
||||
ItemPlanUtils.getItem(plan)
|
||||
).length;
|
||||
|
||||
const atMax =
|
||||
maxReplicatedItemsCount !== null &&
|
||||
itemPlanWithMappingsCount >= maxReplicatedItemsCount;
|
||||
|
||||
const handleItemPlansManagePaneShow = (
|
||||
e: MouseEvent<HTMLButtonElement>
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
paneHistoryStart(PaneComponentEnum.ITEM_PLANS_MANAGE);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: Item): void => {
|
||||
const inventoryMappingId = ItemUtils.getMappingId(item);
|
||||
|
||||
if (inventoryMappingId) {
|
||||
paneHistoryStart(
|
||||
PaneComponentEnum.ITEM_DETAIL,
|
||||
PaneIdentifierUtils.generateItem(inventoryMappingId)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicateItem = (itemPlan: ItemPlan): void => {
|
||||
const itemDefinitionKey = ItemPlanUtils.getItemDefinitionKey(itemPlan);
|
||||
const itemPlanOriginDefinitionKey =
|
||||
ItemPlanUtils.getOriginDefinitionKey(itemPlan);
|
||||
|
||||
if (itemDefinitionKey) {
|
||||
dispatch(
|
||||
characterActions.itemPlanInventoryMappingCreate(
|
||||
itemDefinitionKey,
|
||||
1,
|
||||
itemPlanOriginDefinitionKey
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.magicItemPlans, className])} {...props}>
|
||||
{itemPlans.length > 0 ? (
|
||||
itemPlans.map((plan) => {
|
||||
const planOriginDefinitionKey =
|
||||
ItemPlanUtils.getOriginDefinitionKey(plan);
|
||||
const planName = ItemPlanUtils.getItemName(plan);
|
||||
const itemDefinitionKey = ItemPlanUtils.getItemDefinitionKey(plan);
|
||||
const item = ItemPlanUtils.getItem(plan);
|
||||
const planDescription = ItemPlanUtils.getDescription(plan);
|
||||
|
||||
return itemDefinitionKey ? (
|
||||
<div className={styles.itemPlan} key={planOriginDefinitionKey}>
|
||||
<div className={styles.planHeader}>
|
||||
<p className={styles.planName}>{planName}</p>
|
||||
<p className={styles.description}>{planDescription}</p>
|
||||
</div>
|
||||
<div className={styles.itemContent}>
|
||||
{item ? (
|
||||
<div className={styles.itemPreview}>
|
||||
<ItemPreview
|
||||
theme={characterTheme}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.replicateButton}
|
||||
themed
|
||||
size="xx-small"
|
||||
onClick={() => handleReplicateItem(plan)}
|
||||
disabled={atMax}
|
||||
>
|
||||
{atMax ? "At Max Replicated Items" : "Replicate Item"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
No Magic Item plans have been selected for this character
|
||||
<Button
|
||||
className={styles.emptyButton}
|
||||
themed
|
||||
size="x-small"
|
||||
onClick={handleItemPlansManagePaneShow}
|
||||
>
|
||||
Add Magic Item Plans
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,8 @@ import { createPortal } from "react-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
|
||||
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
|
||||
|
||||
import { Dialog, DialogProps } from "~/components/Dialog";
|
||||
import { useSheetContext } from "~/subApps/sheet/contexts/Sheet";
|
||||
import {
|
||||
ActionsIcon,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
|
||||
import GearIcon from "@dndbeyond/fontawesome-cache/svgs/solid/gear.svg";
|
||||
|
||||
import { Button, ButtonProps } from "~/components/Button";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
|
||||
import { PaneComponentEnum } from "../Sidebar/types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SettingsButtonProps extends ButtonProps {
|
||||
context: string;
|
||||
}
|
||||
|
||||
export const SettingsButton: FC<SettingsButtonProps> = ({
|
||||
className,
|
||||
context,
|
||||
...props
|
||||
}) => {
|
||||
const { pane } = useSidebar();
|
||||
const { SETTINGS } = PaneComponentEnum;
|
||||
|
||||
const handleClick = () => pane?.paneHistoryPush(SETTINGS, { context });
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={clsx([styles.settingsButton, className])}
|
||||
onClick={handleClick}
|
||||
variant="text"
|
||||
size="small"
|
||||
themed
|
||||
{...props}
|
||||
>
|
||||
<GearIcon />
|
||||
Settings
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingFocusManager,
|
||||
offset,
|
||||
Placement,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
FC,
|
||||
Fragment,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { GroupedMenuOption } from "@dndbeyond/character-rules-engine";
|
||||
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
||||
|
||||
import { Button, ButtonProps } from "~/components/Button";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* A button that displays a menu of options when clicked. If there is only one option, it will
|
||||
* render as a simple button that triggers the onSelect callback when clicked unless the
|
||||
* `showSingleOption` prop is set to true, in which case it will still show the dropdown with only
|
||||
* one option.
|
||||
*/
|
||||
|
||||
export interface ButtonWithMenuProps
|
||||
extends PropsWithChildren,
|
||||
Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
||||
placement?: Placement;
|
||||
groupedOptions: Array<GroupedMenuOption>;
|
||||
showSingleOption?: boolean;
|
||||
onSelect: (containerDefinitionKey: string) => void;
|
||||
variant?: ButtonProps["variant"];
|
||||
}
|
||||
|
||||
export const ButtonWithMenu: FC<ButtonWithMenuProps> = ({
|
||||
className,
|
||||
children,
|
||||
groupedOptions,
|
||||
showSingleOption,
|
||||
placement,
|
||||
onSelect,
|
||||
variant,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// Set up floating element and trigger
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement,
|
||||
strategy: "fixed",
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
middleware: [offset(), flip(), shift()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
// Handle interactions for the floating element
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
useClick(context),
|
||||
useDismiss(context),
|
||||
useRole(context),
|
||||
]);
|
||||
// Determine whether we should show the dropdown
|
||||
const hasSingleOption =
|
||||
groupedOptions.length === 1 &&
|
||||
groupedOptions[0].options?.length === 1 &&
|
||||
!showSingleOption;
|
||||
|
||||
const handleClickTrigger = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasSingleOption) {
|
||||
onSelect(groupedOptions[0].options[0]?.value);
|
||||
} else {
|
||||
setIsOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOption = (e, value: string) => {
|
||||
e.stopPropagation();
|
||||
onSelect(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const getTestId = (value: ReactNode) => {
|
||||
const content = value?.toString() ?? "Move";
|
||||
return `theme-button-with-menu-${content}`
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.buttonWithMenu, className])} {...props}>
|
||||
<span
|
||||
className={styles.trigger}
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
<Button
|
||||
size="xx-small"
|
||||
variant={variant}
|
||||
themed
|
||||
data-testid={getTestId(children)}
|
||||
onClick={handleClickTrigger}
|
||||
>
|
||||
{children}
|
||||
{!hasSingleOption && <ChevronDown className={styles.buttonIcon} />}
|
||||
</Button>
|
||||
</span>
|
||||
{!hasSingleOption && isOpen && (
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<menu
|
||||
className={styles.menu}
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{groupedOptions.map((option) => (
|
||||
<Fragment key={option.label}>
|
||||
<li
|
||||
className={styles.menuHeading}
|
||||
data-testid={getTestId(`subheader-${option.label}`)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
<ul>
|
||||
{option.options?.map((item) => (
|
||||
<li key={item.value}>
|
||||
<button
|
||||
className={styles.menuButton}
|
||||
onClick={(e) => handleClickOption(e, item.value)}
|
||||
data-testid={getTestId(`item-${item.label}`)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment>
|
||||
))}
|
||||
</menu>
|
||||
</FloatingFocusManager>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ export const Pane: FC<PaneProps> = ({
|
||||
forceDarkMode && styles.dark,
|
||||
isFullWidth && styles.fullWidth,
|
||||
])}
|
||||
data-scrollable-container="true"
|
||||
>
|
||||
<PaneControls handlePrevious={handlePrevious} handleNext={handleNext} />
|
||||
<PaneContent activePane={activePane} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PaneContent: FC<PaneContentProps> = ({ activePane, ...props }) => {
|
||||
|
||||
// Return correct component
|
||||
return (
|
||||
<div className={styles.maxHeight} {...props}>
|
||||
<div className={styles.wrapper} {...props}>
|
||||
<Component
|
||||
theme={isDarkMode ? "DARK" : "LIGHT"}
|
||||
identifiers={activePane.identifiers}
|
||||
|
||||
+15
-5
@@ -1,28 +1,38 @@
|
||||
import { FC, useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { ToastMessageMeta } from "~/tools/js/Shared/stores/typings";
|
||||
|
||||
import { toastMessageActions } from "../../../../../../tools/js/Shared/actions";
|
||||
|
||||
interface Props {
|
||||
errorTitle?: string;
|
||||
errorMessage?: string;
|
||||
toastMeta?: ToastMessageMeta;
|
||||
}
|
||||
export const PaneInitFailureContent: FC<Props> = ({ errorMessage }) => {
|
||||
export const PaneInitFailureContent: FC<Props> = ({
|
||||
errorTitle,
|
||||
errorMessage,
|
||||
toastMeta,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
sidebar: { setIsVisible },
|
||||
pane: { resetActivePane },
|
||||
} = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
resetActivePane();
|
||||
setIsVisible(false);
|
||||
dispatch(
|
||||
toastMessageActions.toastError(
|
||||
"Content Error",
|
||||
errorMessage ?? "We couldn't find that content! Please try again."
|
||||
errorTitle ?? "Content Error",
|
||||
errorMessage ?? "We couldn't find that content! Please try again.",
|
||||
toastMeta
|
||||
)
|
||||
);
|
||||
}, [errorMessage, dispatch]);
|
||||
}, [errorMessage, errorTitle, toastMeta]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
|
||||
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
|
||||
import { ItemPane } from "~/subApps/sheet/components/Sidebar/panes/ItemPane";
|
||||
import { ItemPlansManagePane } from "~/subApps/sheet/components/Sidebar/panes/ItemPlansManagePane";
|
||||
import { LongRestPane } from "~/subApps/sheet/components/Sidebar/panes/LongRestPane";
|
||||
import { SpeciesTraitPane } from "~/subApps/sheet/components/Sidebar/panes/SpeciesTraitPane";
|
||||
import BlessingPane from "~/tools/js/Shared/containers/panes/BlessingPane";
|
||||
import CharacterSpellPane from "~/tools/js/Shared/containers/panes/CharacterSpellPane";
|
||||
import ClassSpellPane from "~/tools/js/Shared/containers/panes/ClassSpellPane";
|
||||
import ConditionManagePane from "~/tools/js/Shared/containers/panes/ConditionManagePane";
|
||||
@@ -18,8 +20,6 @@ import EquipmentManagePane from "~/tools/js/Shared/containers/panes/EquipmentMan
|
||||
import { ExportPdfPane } from "~/tools/js/Shared/containers/panes/ExportPdfPane";
|
||||
import ExtraManagePane from "~/tools/js/Shared/containers/panes/ExtraManagePane";
|
||||
import InfusionChoicePane from "~/tools/js/Shared/containers/panes/InfusionChoicePane";
|
||||
import ItemPane from "~/tools/js/Shared/containers/panes/ItemPane";
|
||||
import LongRestPane from "~/tools/js/Shared/containers/panes/LongRestPane";
|
||||
import NoteManagePane from "~/tools/js/Shared/containers/panes/NoteManagePane";
|
||||
import { PreferencesHitPointConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesHitPointConfirmPane";
|
||||
import PreferencesOptionalClassFeaturesConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalClassFeaturesConfirmPane/PreferencesOptionalClassFeaturesConfirmPane";
|
||||
@@ -66,6 +66,9 @@ export const getActiveEntryComponent = (
|
||||
if (!componentType) return null;
|
||||
|
||||
switch (componentType) {
|
||||
case PaneComponentEnum.ITEM_PLANS_MANAGE:
|
||||
return ItemPlansManagePane;
|
||||
|
||||
case PaneComponentEnum.HEALTH_MANAGE:
|
||||
return HitPointsManagePane;
|
||||
|
||||
@@ -237,8 +240,7 @@ export const getActiveEntryComponent = (
|
||||
case PaneComponentEnum.SETTINGS:
|
||||
return SettingsPane;
|
||||
|
||||
case PaneComponentEnum.BLESSING_DETAIL:
|
||||
default:
|
||||
return BlessingPane;
|
||||
return CharacterManagePane;
|
||||
}
|
||||
};
|
||||
|
||||
+5
-8
@@ -1,9 +1,8 @@
|
||||
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
AbilityIcon,
|
||||
Collapsible,
|
||||
DarkModeNegativeBonusNegativeSvg,
|
||||
DarkModePositiveBonusPositiveSvg,
|
||||
NegativeBonusNegativeSvg,
|
||||
@@ -18,12 +17,14 @@ import {
|
||||
ValueUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { RuleKeyEnum } from "~/constants";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { useRuleData } from "~/hooks/useRuleData";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
@@ -126,11 +127,7 @@ export const AbilitySavingThrowsPane: FC<Props> = ({
|
||||
</span>
|
||||
</Header>
|
||||
{!isReadonly && (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header="Customize"
|
||||
className={styles.customize}
|
||||
>
|
||||
<Accordion variant="minimal" size="x-small" summary="Customize" themed>
|
||||
<EditorBox className={styles.editorBox}>
|
||||
<ValueEditor
|
||||
dataLookup={ValueUtils.getEntityData(
|
||||
@@ -148,7 +145,7 @@ export const AbilitySavingThrowsPane: FC<Props> = ({
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
</Accordion>
|
||||
)}
|
||||
{situationalBonusSavingThrowsInfo && (
|
||||
<div className={styles.situational}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { ActionName } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import { ItemName } from "~/components/ItemName";
|
||||
import { DataOriginTypeEnum } from "~/constants";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { BaseInventoryContract } from "~/types";
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export const ArmorClassDetail: FC<ArmorClassDetailProps> = ({
|
||||
amountDisplay = <NumberDisplay type="signed" number={amount} />;
|
||||
}
|
||||
|
||||
let extraDisplay: ReactNode;
|
||||
let extraDisplay;
|
||||
switch (type) {
|
||||
case ArmorClassTypeEnum.DEX_BONUS:
|
||||
extraDisplay = extra === null ? "" : `Max ${extra}`;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { Collapsible } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
ValueUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Accordion } from "~/components/Accordion";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { AdjustmentTypeEnum, RuleKeyEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { useRuleData } from "~/hooks/useRuleData";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import EditorBox from "~/tools/js/Shared/components/EditorBox";
|
||||
@@ -75,10 +76,12 @@ export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
|
||||
<ArmorClassDetail />
|
||||
{/* AC CUSTOMIZATION */}
|
||||
{!isReadonly && (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header="Customize"
|
||||
<Accordion
|
||||
className={styles.customize}
|
||||
summary="Customize"
|
||||
variant="minimal"
|
||||
size="x-small"
|
||||
themed
|
||||
>
|
||||
<EditorBox className={styles.editorBox}>
|
||||
<ValueEditor
|
||||
@@ -91,7 +94,7 @@ export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
|
||||
ruleData={ruleData}
|
||||
/>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
</Accordion>
|
||||
)}
|
||||
{/* AC Description */}
|
||||
{acRule && (
|
||||
|
||||
+81
-70
@@ -1,12 +1,10 @@
|
||||
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
LightLinkOutSvg,
|
||||
ThemedBackdropSvg,
|
||||
ThemedBuilderSvg,
|
||||
ThemedExportSvg,
|
||||
ThemedFrameSvg,
|
||||
ThemedLongRestSvg,
|
||||
ThemedManageLevelSvg,
|
||||
@@ -16,9 +14,10 @@ import {
|
||||
ThemedShareSvg,
|
||||
ThemedShortRestSvg,
|
||||
ThemedThemeIconSvg,
|
||||
ThemedChatBubbleSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
|
||||
import PdfSvg from "@dndbeyond/fontawesome-cache/svgs/light/file-pdf.svg";
|
||||
import ListTimeline from "@dndbeyond/fontawesome-cache/svgs/light/list-timeline.svg";
|
||||
import LinkIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-up-right-from-square.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import config from "~/config";
|
||||
@@ -49,6 +48,8 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { search } = useLocation();
|
||||
const isVttView = new URLSearchParams(search).get("view") === "vtt";
|
||||
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
const builderUrl = useSelector(sheetAppSelectors.getBuilderUrl);
|
||||
@@ -107,15 +108,23 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
share: ThemedShareSvg,
|
||||
managelevel: ThemedManageLevelSvg,
|
||||
managexp: ThemedManageXpSvg,
|
||||
gamelog: ThemedChatBubbleSvg,
|
||||
frame: ThemedFrameSvg,
|
||||
backdrop: ThemedBackdropSvg,
|
||||
theme: ThemedThemeIconSvg,
|
||||
portrait: ThemedPortraitSvg,
|
||||
preferences: ThemedPreferencesSvg,
|
||||
downloadpdf: ThemedExportSvg,
|
||||
downloadpdf: PdfSvg,
|
||||
};
|
||||
|
||||
if (menuKey === "gamelog") {
|
||||
return (
|
||||
<ListTimeline
|
||||
className="ddbc-svg ddbc-svg--themed"
|
||||
style={{ fill: characterTheme.themeColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const IconNode: ComponentType<any> | null =
|
||||
menuIconLookup[menuKey] &&
|
||||
helperUtils.lookupDataOrFallback(menuIconLookup, menuKey);
|
||||
@@ -124,17 +133,12 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (menuKey === "gamelog") {
|
||||
return (
|
||||
<D6
|
||||
className="ddbc-svg ddbc-svg--themed"
|
||||
style={{ fill: characterTheme.themeColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconNode theme={decorationUtils.getCharacterTheme(decorationInfo)} />
|
||||
<IconNode
|
||||
className="ddbc-svg ddbc-svg--themed"
|
||||
style={{ fill: characterTheme.themeColor }}
|
||||
theme={decorationUtils.getCharacterTheme(decorationInfo)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -144,7 +148,7 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
data-testid="character-manage-pane"
|
||||
{...props}
|
||||
>
|
||||
<Overview />
|
||||
<Overview isVttView={isVttView} />
|
||||
{/* Display Readonly CTA or MENU */}
|
||||
{isReadonly ? (
|
||||
<div className={styles.readonlyBackground}>
|
||||
@@ -162,25 +166,27 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
variant="solid"
|
||||
target="_blank"
|
||||
themed
|
||||
href={`${BASE_PATHNAME}/builder`}
|
||||
href={`${BASE_PATHNAME}/builder${search}`}
|
||||
forceThemeMode="dark"
|
||||
>
|
||||
<span>Create a Character</span>
|
||||
<LightLinkOutSvg />
|
||||
<LinkIcon className={styles.linkIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PaneMenu>
|
||||
<PaneMenuGroup label="My Character">
|
||||
<PaneMenuItem
|
||||
menukey="builder"
|
||||
prefixIcon={getMenuIconNode("builder")}
|
||||
suffixIcon={<LightLinkOutSvg />}
|
||||
onClick={handleBuilderClick}
|
||||
>
|
||||
Manage Character & Levels
|
||||
</PaneMenuItem>
|
||||
{!isVttView && (
|
||||
<PaneMenuItem
|
||||
menukey="builder"
|
||||
prefixIcon={getMenuIconNode("builder")}
|
||||
suffixIcon={<LinkIcon className={styles.linkIcon} />}
|
||||
onClick={handleBuilderClick}
|
||||
>
|
||||
Manage Character & Levels
|
||||
</PaneMenuItem>
|
||||
)}
|
||||
{preferences.progressionType ===
|
||||
PreferenceProgressionTypeEnum.XP && (
|
||||
<PaneMenuItem
|
||||
@@ -199,47 +205,52 @@ export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
|
||||
Character Settings
|
||||
</PaneMenuItem>
|
||||
</PaneMenuGroup>
|
||||
<PaneMenuGroup label="Play">
|
||||
<PaneMenuItem
|
||||
menukey="gamelog"
|
||||
prefixIcon={getMenuIconNode("gamelog")}
|
||||
onClick={handleGameLogMenuClick}
|
||||
>
|
||||
Game Log
|
||||
</PaneMenuItem>
|
||||
<PaneMenuItem
|
||||
menukey="shortrest"
|
||||
prefixIcon={getMenuIconNode("shortrest")}
|
||||
onClick={handleShortRestMenuClick}
|
||||
>
|
||||
Short Rest
|
||||
</PaneMenuItem>
|
||||
<PaneMenuItem
|
||||
menukey="longrest"
|
||||
prefixIcon={getMenuIconNode("longrest")}
|
||||
onClick={handleLongRestMenuClick}
|
||||
>
|
||||
Long Rest
|
||||
</PaneMenuItem>
|
||||
</PaneMenuGroup>
|
||||
<PaneMenuGroup label="Share">
|
||||
{preferences.privacyType === PreferencePrivacyTypeEnum.PUBLIC && (
|
||||
<PaneMenuItem
|
||||
menukey="share"
|
||||
prefixIcon={getMenuIconNode("share")}
|
||||
onClick={handleShareMenuClick}
|
||||
>
|
||||
Shareable Link
|
||||
</PaneMenuItem>
|
||||
)}
|
||||
<PaneMenuItem
|
||||
menukey="downloadpdf"
|
||||
prefixIcon={getMenuIconNode("downloadpdf")}
|
||||
onClick={handlePdfClick}
|
||||
>
|
||||
Export to PDF
|
||||
</PaneMenuItem>
|
||||
</PaneMenuGroup>
|
||||
{!isVttView && (
|
||||
<>
|
||||
<PaneMenuGroup label="Play">
|
||||
<PaneMenuItem
|
||||
menukey="gamelog"
|
||||
prefixIcon={getMenuIconNode("gamelog")}
|
||||
onClick={handleGameLogMenuClick}
|
||||
>
|
||||
Game Log
|
||||
</PaneMenuItem>
|
||||
<PaneMenuItem
|
||||
menukey="shortrest"
|
||||
prefixIcon={getMenuIconNode("shortrest")}
|
||||
onClick={handleShortRestMenuClick}
|
||||
>
|
||||
Short Rest
|
||||
</PaneMenuItem>
|
||||
<PaneMenuItem
|
||||
menukey="longrest"
|
||||
prefixIcon={getMenuIconNode("longrest")}
|
||||
onClick={handleLongRestMenuClick}
|
||||
>
|
||||
Long Rest
|
||||
</PaneMenuItem>
|
||||
</PaneMenuGroup>
|
||||
<PaneMenuGroup label="Share">
|
||||
{preferences.privacyType ===
|
||||
PreferencePrivacyTypeEnum.PUBLIC && (
|
||||
<PaneMenuItem
|
||||
menukey="share"
|
||||
prefixIcon={getMenuIconNode("share")}
|
||||
onClick={handleShareMenuClick}
|
||||
>
|
||||
Shareable Link
|
||||
</PaneMenuItem>
|
||||
)}
|
||||
<PaneMenuItem
|
||||
menukey="downloadpdf"
|
||||
prefixIcon={getMenuIconNode("downloadpdf")}
|
||||
onClick={handlePdfClick}
|
||||
>
|
||||
Export to PDF
|
||||
</PaneMenuItem>
|
||||
</PaneMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+18
-18
@@ -8,11 +8,10 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
CharacterAvatarPortrait,
|
||||
CharacterName,
|
||||
CharacterProgressionSummary,
|
||||
CharacterSummary,
|
||||
LightPencilSvg,
|
||||
@@ -20,16 +19,14 @@ import {
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { Button } from "~/components/Button/Button";
|
||||
import {
|
||||
CharacterNameLimitMsg,
|
||||
DeathCauseEnum,
|
||||
InputLimits,
|
||||
} from "~/constants";
|
||||
import { CharacterNameLimitMsg, InputLimits } from "~/constants";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { useMaxLengthErrorHandling } from "~/hooks/useErrorHandling/useMaxLengthErrorHandling";
|
||||
import useUserId from "~/hooks/useUserId";
|
||||
import { CharacterName } from "~/subApps/sheet/components/CharacterName";
|
||||
import { appEnvActions } from "~/tools/js/Shared/actions";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { CharacterStatusSlug } from "~/types";
|
||||
@@ -39,16 +36,21 @@ import { PopoverContent } from "../../../../../../../components/PopoverContent";
|
||||
import { PaneComponentEnum } from "../../../types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isVttView?: boolean;
|
||||
}
|
||||
|
||||
export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
|
||||
export const Overview: FC<OverviewProps> = ({
|
||||
isVttView = false,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
ruleData,
|
||||
characterName,
|
||||
characterGender: gender,
|
||||
race: species,
|
||||
experienceInfo: xpInfo,
|
||||
deathCause,
|
||||
preferences,
|
||||
decorationInfo,
|
||||
playerName,
|
||||
@@ -138,14 +140,16 @@ export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
|
||||
{
|
||||
<div
|
||||
className={clsx([
|
||||
styles.characterName,
|
||||
styles.characterNameContainer,
|
||||
!isReadonly && !isVttView && styles.editableName,
|
||||
isEditingCharacterName && styles.editName,
|
||||
])}
|
||||
onClick={isReadonly ? undefined : handleNameClick}
|
||||
onClick={isReadonly || isVttView ? undefined : handleNameClick}
|
||||
>
|
||||
{isEditingCharacterName ? (
|
||||
<>
|
||||
<input
|
||||
className={styles.nameInput}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={characterName ?? ""}
|
||||
@@ -158,12 +162,8 @@ export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CharacterName
|
||||
name={characterName ?? ""}
|
||||
isDead={deathCause !== DeathCauseEnum.NONE}
|
||||
isFaceMenu={true}
|
||||
/>
|
||||
{!isReadonly && <LightPencilSvg />}
|
||||
<CharacterName />
|
||||
{!isReadonly && !isVttView && <LightPencilSvg />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+10
-2
@@ -1,15 +1,17 @@
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { FeatManager } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { FeatureChoices } from "~/components/FeatureChoices";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import { ClassFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
|
||||
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
|
||||
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
@@ -87,6 +90,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
if (!charClass || !classFeature) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
characterActions.classFeatureChoiceSetRequest(
|
||||
classUtils.getActiveId(charClass),
|
||||
@@ -94,7 +98,10 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
type,
|
||||
id,
|
||||
helperUtils.parseInputInt(value),
|
||||
parentChoiceId
|
||||
parentChoiceId,
|
||||
classFeatureUtils.getHasItemMappings(classFeature)
|
||||
? inventoryManager.handleAcceptOnSuccess(true)
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -215,6 +222,7 @@ export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
<FeatureChoices
|
||||
choices={classFeatureUtils.getChoices(classFeature)}
|
||||
charClass={charClass}
|
||||
classFeature={classFeature}
|
||||
onChoiceChange={handleChoiceChange}
|
||||
collapseDescription={true}
|
||||
showHeading={true}
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderCallout,
|
||||
CollapsibleHeaderContent,
|
||||
CreatureName,
|
||||
InfusionPreview,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
characterActions,
|
||||
@@ -30,10 +29,12 @@ import {
|
||||
import { Button } from "~/components/Button";
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { InfusionPreview } from "~/components/InfusionPreview";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { TagGroup } from "~/components/TagGroup";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
@@ -67,9 +68,6 @@ export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
ruleData,
|
||||
creatures,
|
||||
entityValueLookup,
|
||||
snippetData,
|
||||
infusionChoiceLookup,
|
||||
proficiencyBonus,
|
||||
characterTheme: theme,
|
||||
hpInfo,
|
||||
deathCause,
|
||||
@@ -498,14 +496,7 @@ export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
if (infusion) {
|
||||
return (
|
||||
<div className={styles.hasSeparator}>
|
||||
<InfusionPreview
|
||||
infusion={infusion}
|
||||
snippetData={snippetData}
|
||||
ruleData={ruleData}
|
||||
infusionChoiceLookup={infusionChoiceLookup}
|
||||
onClick={handleInfusionClick}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
<InfusionPreview infusion={infusion} onClick={handleInfusionClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { ChoiceUtils } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "~/components/Button";
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
|
||||
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
|
||||
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
|
||||
@@ -145,7 +146,7 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
<HelperTextAccordion
|
||||
builderHelperText={feat.getHelperText()}
|
||||
size="small"
|
||||
useTheme
|
||||
themed
|
||||
/>
|
||||
{prerequisiteDescription && (
|
||||
<div className={styles.prereq}>
|
||||
@@ -198,6 +199,7 @@ export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
choiceInfo={choiceInfo}
|
||||
showBackgroundProficiencyOptions={true}
|
||||
description={description}
|
||||
collapseDescription={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
import { FC, useContext, useEffect, useState } from "react";
|
||||
|
||||
import { FeatureManager } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
|
||||
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
|
||||
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
|
||||
import { AppLoggerUtils, AppNotificationUtils } from "~/tools/js/Shared/utils";
|
||||
import Collapsible, {
|
||||
CollapsibleHeaderContent,
|
||||
} from "~/tools/js/smartComponents/Collapsible";
|
||||
|
||||
export const BlessingShoppe: FC<{}> = () => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
|
||||
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
|
||||
DataLoadingStatusEnum.NOT_INITIALIZED
|
||||
);
|
||||
const [blessingShoppe, setBlessingShoppe] = useState<Array<FeatureManager>>(
|
||||
[]
|
||||
);
|
||||
const [blessings, setBlessings] = useState<Array<FeatureManager>>([]);
|
||||
|
||||
async function fetchBlessings() {
|
||||
const blessings = await characterFeaturesManager.getBlessings();
|
||||
setBlessings(blessings);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
characterFeaturesManager
|
||||
.getBlessingShoppe()
|
||||
.then((blessingShoppe) => {
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
setBlessingShoppe(blessingShoppe);
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
fetchBlessings();
|
||||
}, [loadingStatus, characterFeaturesManager]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
A blessing is usually bestowed by a god or a godlike being. A character
|
||||
might receive a blessing from a deity for doing something truly
|
||||
momentous — an accomplishment that catches the attention of both gods
|
||||
and mortals.
|
||||
</p>
|
||||
<div>
|
||||
{blessingShoppe.map((blessing) => {
|
||||
return (
|
||||
<Collapsible
|
||||
key={blessing.getId()}
|
||||
layoutType={"minimal"}
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={blessing.getName()}
|
||||
callout={
|
||||
characterFeaturesManager.hasBlessing(blessing) ? (
|
||||
<ThemeButton
|
||||
onClick={() => {
|
||||
blessing.handleRemove(() => {
|
||||
fetchBlessings();
|
||||
// AppNotificationUtils.dispatchSuccess(
|
||||
// 'Removed Blessing',
|
||||
// 'You removed a blessing',
|
||||
// );
|
||||
});
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
Remove
|
||||
</ThemeButton>
|
||||
) : (
|
||||
<ThemeButton
|
||||
onClick={() => {
|
||||
blessing.handleAdd(() => {
|
||||
fetchBlessings();
|
||||
AppNotificationUtils.dispatchSuccess(
|
||||
"Added Blessing",
|
||||
"You added a blessing"
|
||||
);
|
||||
});
|
||||
}}
|
||||
size="small"
|
||||
style="outline"
|
||||
>
|
||||
Add
|
||||
</ThemeButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{blessing.getDescription()}
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,4 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
+6
-41
@@ -1,23 +1,14 @@
|
||||
import { FC } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { useFeatureFlags } from "~/contexts/FeatureFlag";
|
||||
import { SettingsButton } from "~/subApps/sheet/components/SettingsButton";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import SettingsButton from "~/tools/js/CharacterSheet/components/SettingsButton";
|
||||
import { SettingsContextsEnum } from "~/tools/js/Shared/containers/panes/SettingsPane/typings";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import Collapsible, {
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
} from "~/tools/js/smartComponents/Collapsible";
|
||||
|
||||
import { BlessingShoppe } from "./BlessingShoppe";
|
||||
import { FeatShoppe } from "./FeatShoppe";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export const FeatsManagePane: FC<{}> = () => {
|
||||
const { gfsBlessingsUiFlag } = useFeatureFlags();
|
||||
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
if (isReadonly) {
|
||||
return null;
|
||||
@@ -27,40 +18,14 @@ export const FeatsManagePane: FC<{}> = () => {
|
||||
<div>
|
||||
<Header
|
||||
callout={
|
||||
<SettingsButton
|
||||
context={SettingsContextsEnum.FEATURES}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
!isReadonly && (
|
||||
<SettingsButton context={SettingsContextsEnum.FEATURES} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
|
||||
Manage Feats
|
||||
</Header>
|
||||
{gfsBlessingsUiFlag ? (
|
||||
<Collapsible
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={<CollapsibleHeading>Add Feats</CollapsibleHeading>}
|
||||
/>
|
||||
}
|
||||
className={styles.featList}
|
||||
>
|
||||
<FeatShoppe />
|
||||
</Collapsible>
|
||||
) : (
|
||||
<FeatShoppe />
|
||||
)}
|
||||
{gfsBlessingsUiFlag && (
|
||||
<Collapsible
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={<CollapsibleHeading>Add Blessings</CollapsibleHeading>}
|
||||
/>
|
||||
}
|
||||
className={styles.featList}
|
||||
>
|
||||
<BlessingShoppe />
|
||||
</Collapsible>
|
||||
)}
|
||||
<FeatShoppe />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { Dice } from "@dndbeyond/dice";
|
||||
import { GameLog } from "@dndbeyond/game-log-components";
|
||||
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { appEnvActions } from "~/tools/js/Shared/actions/appEnv";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
@@ -36,9 +36,6 @@ export const GameLogPane = () => {
|
||||
lastMessageTime.toString()
|
||||
);
|
||||
} catch (e) {}
|
||||
|
||||
// turn the dice notifications off if the panel is open
|
||||
Dice.setDiceNotificationEnabled(!isOpen);
|
||||
};
|
||||
|
||||
updateOpenState(true);
|
||||
@@ -52,8 +49,8 @@ export const GameLogPane = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.gameLogPane)} data-testid="gamelog-pane">
|
||||
{characterId && <GameLog />}
|
||||
<div className={styles.gameLogPane} data-testid="gamelog-pane">
|
||||
<GameLog isVisible={Boolean(characterId)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+21
-24
@@ -1,17 +1,23 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
DigitalDiceWrapper,
|
||||
LightDiceSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
import {
|
||||
RollKinds,
|
||||
RollTypes,
|
||||
} from "@dndbeyond/pocket-dimension-dice/constants";
|
||||
import {
|
||||
RollDicePayload,
|
||||
RollKind,
|
||||
} from "@dndbeyond/pocket-dimension-dice/types";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { isNotNullOrUndefined } from "~/helpers/validation";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
|
||||
@@ -46,8 +52,6 @@ export const DeathSavesManager: FC<Props> = ({
|
||||
const characterRollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
|
||||
useContext(GameLogContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
@@ -60,12 +64,12 @@ export const DeathSavesManager: FC<Props> = ({
|
||||
|
||||
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
|
||||
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
|
||||
let rollKind: RollKind | undefined;
|
||||
|
||||
let rollKind = RollKind.None;
|
||||
if (hasAdvantage && !hasDisadvantage) {
|
||||
rollKind = RollKind.Advantage;
|
||||
rollKind = RollKinds.Advantage;
|
||||
} else if (hasDisadvantage && !hasAdvantage) {
|
||||
rollKind = RollKind.Disadvantage;
|
||||
rollKind = RollKinds.Disadvantage;
|
||||
}
|
||||
|
||||
/* --- On Click Functions --- */
|
||||
@@ -134,7 +138,7 @@ export const DeathSavesManager: FC<Props> = ({
|
||||
{isDiceEnabled && (
|
||||
<DigitalDiceWrapper
|
||||
diceNotation={"1d20"}
|
||||
onRollResults={(rollRequest: RollRequest) => {
|
||||
rollCallback={(rollRequest: RollDicePayload["data"]) => {
|
||||
const roll = rollRequest.rolls[0].result?.total;
|
||||
|
||||
if (roll) {
|
||||
@@ -149,20 +153,13 @@ export const DeathSavesManager: FC<Props> = ({
|
||||
}
|
||||
}
|
||||
}}
|
||||
rollType={RollType.Save}
|
||||
rollType={RollTypes.Save}
|
||||
rollKind={rollKind}
|
||||
rollAction={"Death"}
|
||||
diceEnabled={isDiceEnabled}
|
||||
rollContext={characterRollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions?.entities
|
||||
? Object.values(messageTargetOptions.entities).filter(
|
||||
isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
userId={Number(userId)}
|
||||
action="Death"
|
||||
isDiceEnabled={isDiceEnabled}
|
||||
entityId={String(characterRollContext.entityId)}
|
||||
entityType={String(characterRollContext.entityType)}
|
||||
name={String(characterRollContext.name)}
|
||||
>
|
||||
<LightDiceSvg />
|
||||
<span>Roll</span>
|
||||
|
||||
+2
-2
@@ -1,6 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { DataOriginName } from "@dndbeyond/character-components/es";
|
||||
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
|
||||
@@ -9,6 +8,7 @@ import { Button } from "~/components/Button";
|
||||
import { Checkbox } from "~/components/Checkbox";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
|
||||
import { Item, HitPointInfo, Creature } from "~/types";
|
||||
|
||||
@@ -378,7 +378,7 @@ export const HitPointsAdjuster: FC<Props> = ({
|
||||
themed
|
||||
darkMode={characterTheme.isDarkMode}
|
||||
id={`${protectionSupplier.key}-checkbox`}
|
||||
onClick={() => {
|
||||
onChange={() => {
|
||||
isEnabled = !isEnabled;
|
||||
setActiveProtectionSupplierKey(
|
||||
isEnabled ? protectionSupplier.key : null
|
||||
|
||||
+1
-1
@@ -1,11 +1,11 @@
|
||||
import { FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { Constants } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { RuleKeyEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import { toastMessageActions } from "~/tools/js/Shared/actions";
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import { createRef, FC, HTMLAttributes, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import {
|
||||
HP_BONUS_VALUE,
|
||||
HP_DAMAGE_TAKEN_VALUE,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { FC, HTMLAttributes, useContext, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
Action,
|
||||
ActionUtils,
|
||||
Constants,
|
||||
DataOrigin,
|
||||
EntityUtils,
|
||||
HelperUtils,
|
||||
Infusion,
|
||||
InfusionUtils,
|
||||
ItemPlanUtils,
|
||||
ItemUtils,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { EditableName } from "~/components/EditableName";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
getDataOriginComponentInfo,
|
||||
getSpellComponentInfo,
|
||||
} from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneIdentifiersItem,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
import ItemDetail from "~/tools/js/Shared/components/ItemDetail";
|
||||
import ItemListInformationCollapsible from "~/tools/js/Shared/components/ItemListInformationCollapsible";
|
||||
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
|
||||
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
identifiers: PaneIdentifiersItem | null;
|
||||
}
|
||||
export const ItemPane: FC<Props> = ({ identifiers, ...props }) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
const {
|
||||
ruleData,
|
||||
allInventoryItems: items,
|
||||
weaponSpellDamageGroups,
|
||||
entityValueLookup,
|
||||
snippetData,
|
||||
infusionChoiceLookup,
|
||||
characterTheme: theme,
|
||||
containerLookup,
|
||||
partyInfo,
|
||||
itemPlans,
|
||||
} = useCharacterEngine();
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
|
||||
const [isCustomizeClosed, setIsCustomizeClosed] = useState(true);
|
||||
const [shouldShowPaneInitFailure, setShouldShowPaneInitFailure] =
|
||||
useState(true);
|
||||
|
||||
const item =
|
||||
items.find((item) => identifiers?.id === ItemUtils.getMappingId(item)) ??
|
||||
null;
|
||||
|
||||
const handleToggleCustomize = () => {
|
||||
setIsCustomizeClosed(!isCustomizeClosed);
|
||||
};
|
||||
|
||||
const handleCustomItemEdit = (adjustments: Record<string, any>): void => {
|
||||
if (item === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (adjustments.notes === "") {
|
||||
adjustments.notes = null;
|
||||
}
|
||||
|
||||
inventoryManager.handleCustomEdit({
|
||||
item,
|
||||
adjustments,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomDataUpdate = (
|
||||
adjustmentType: Constants.AdjustmentTypeEnum,
|
||||
value: any
|
||||
): void => {
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationSet({
|
||||
item,
|
||||
adjustmentType,
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomizations = (): void => {
|
||||
if (item) {
|
||||
inventoryManager.handleCustomizationsRemove({ item });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataOriginClick = (dataOrigin: DataOrigin) => {
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpellClick = (spell: Spell): void => {
|
||||
let component = getSpellComponentInfo(spell);
|
||||
if (component.type) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInfusionClick = (infusion: Infusion): void => {
|
||||
const choiceKey = InfusionUtils.getChoiceKey(infusion);
|
||||
if (choiceKey !== null) {
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.INFUSION_CHOICE,
|
||||
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionClick = (action: Action): void => {
|
||||
const dataOrigin = ActionUtils.getDataOrigin(action);
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleParentClick = (): void => {
|
||||
if (item) {
|
||||
const itemPlan = ItemUtils.getItemPlan(item, itemPlans);
|
||||
if (itemPlan) {
|
||||
const dataOrigin = ItemPlanUtils.getDataOrigin(itemPlan);
|
||||
let component = getDataOriginComponentInfo(dataOrigin);
|
||||
if (component.type !== PaneComponentEnum.ERROR_404) {
|
||||
paneHistoryPush(component.type, component.identifiers);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (item === null) {
|
||||
return shouldShowPaneInitFailure ? (
|
||||
<PaneInitFailureContent
|
||||
errorTitle="Item Not Found"
|
||||
errorMessage="That item is no longer in your inventory! Please try again."
|
||||
toastMeta={{ level: "info" }}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
let parentName: string | null = null;
|
||||
const itemPlan = ItemUtils.getItemPlan(item, itemPlans);
|
||||
if (itemPlan) {
|
||||
const dataOrigin = ItemPlanUtils.getDataOrigin(itemPlan);
|
||||
parentName = EntityUtils.getDataOriginName(dataOrigin);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<Header
|
||||
parent={parentName}
|
||||
onClick={handleParentClick}
|
||||
preview={<Preview imageUrl={ItemUtils.getAvatarUrl(item)} />}
|
||||
>
|
||||
<EditableName onClick={handleToggleCustomize}>
|
||||
<ItemName item={item} />
|
||||
</EditableName>
|
||||
</Header>
|
||||
<ItemListInformationCollapsible item={item} ruleData={ruleData} />
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
key={ItemUtils.getUniqueKey(item)}
|
||||
item={item}
|
||||
weaponSpellDamageGroups={weaponSpellDamageGroups}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
onCustomDataUpdate={handleCustomDataUpdate}
|
||||
onCustomizationsRemove={handleRemoveCustomizations}
|
||||
onDataOriginClick={handleDataOriginClick}
|
||||
onSpellClick={handleSpellClick}
|
||||
onInfusionClick={handleInfusionClick}
|
||||
onMasteryActionClick={handleActionClick}
|
||||
entityValueLookup={entityValueLookup}
|
||||
infusionChoiceLookup={infusionChoiceLookup}
|
||||
isReadonly={isReadonly}
|
||||
container={HelperUtils.lookupDataOrFallback(
|
||||
containerLookup,
|
||||
ItemUtils.getContainerDefinitionKey(item)
|
||||
)}
|
||||
showCustomize={!isReadonly}
|
||||
onPostRemoveNavigation={PaneComponentEnum.EQUIPMENT_MANAGE}
|
||||
partyInfo={partyInfo}
|
||||
onCustomItemEdit={handleCustomItemEdit}
|
||||
isCustomizeClosed={isCustomizeClosed}
|
||||
onCustomizeClick={handleToggleCustomize}
|
||||
onRemoveItem={() => setShouldShowPaneInitFailure(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import clsx from "clsx";
|
||||
import { sortBy } from "lodash";
|
||||
import { FC, HTMLAttributes, useContext, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
CharClass,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
ContainerManager,
|
||||
HelperUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import ArrowRightIcon from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { FeatureChoices } from "~/components/FeatureChoices";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
|
||||
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
import Collapsible from "~/tools/js/smartComponents/Collapsible";
|
||||
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
|
||||
|
||||
import { Header } from "../../components/Header";
|
||||
import { Heading } from "../../components/Heading";
|
||||
import { PaneComponentEnum } from "../../types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ItemPlansManagePaneProps
|
||||
extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const ItemPlansManagePane: FC<ItemPlansManagePaneProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { classes } = useCharacterEngine();
|
||||
const {
|
||||
pane: { paneHistoryPush },
|
||||
} = useSidebar();
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
|
||||
const [itemShoppe, setItemShoppe] = useState<ContainerManager | null>(null);
|
||||
const [itemsLoadingStatus, setItemsLoadingStatus] =
|
||||
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (
|
||||
itemShoppe === null &&
|
||||
itemsLoadingStatus !== DataLoadingStatusEnum.LOADED
|
||||
) {
|
||||
if (inventoryManager) {
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
|
||||
inventoryManager
|
||||
.getInventoryShoppe({
|
||||
onSuccess: (shoppeContainer: ContainerManager) => {
|
||||
setItemShoppe(shoppeContainer);
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
},
|
||||
additionalApiConfig: {
|
||||
signal: abortController.signal,
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
AppLoggerUtils.handleAdhocApiError(e);
|
||||
});
|
||||
} else {
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [itemsLoadingStatus, inventoryManager, itemShoppe]);
|
||||
|
||||
const handleClassFeatureClick = (
|
||||
charClass: CharClass,
|
||||
classFeature: ClassFeature
|
||||
): void => {
|
||||
paneHistoryPush(
|
||||
PaneComponentEnum.CLASS_FEATURE_DETAIL,
|
||||
PaneIdentifierUtils.generateClassFeature(
|
||||
ClassFeatureUtils.getId(classFeature),
|
||||
ClassUtils.getMappingId(charClass)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleChoiceChange = (
|
||||
charClass: CharClass,
|
||||
classFeature: ClassFeature,
|
||||
id: string,
|
||||
type: number,
|
||||
value: any,
|
||||
parentChoiceId: string | null
|
||||
): void => {
|
||||
if (!charClass || !classFeature) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
characterActions.classFeatureChoiceSetRequest(
|
||||
ClassUtils.getActiveId(charClass),
|
||||
ClassFeatureUtils.getId(classFeature),
|
||||
type,
|
||||
id,
|
||||
HelperUtils.parseInputInt(value),
|
||||
parentChoiceId,
|
||||
ClassFeatureUtils.getHasItemMappings(classFeature)
|
||||
? inventoryManager.handleAcceptOnSuccess(true)
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.itemPlansManagePane, className])} {...props}>
|
||||
<Header>Manage Magic Item Plans</Header>
|
||||
{classes.map((charClass) => {
|
||||
const subclass = ClassUtils.getSubclass(charClass);
|
||||
const subclassId = subclass?.id;
|
||||
const subclassName = subclass?.name;
|
||||
const baseClassId = ClassUtils.getId(charClass);
|
||||
|
||||
const activeFeaturesWithItemMappings =
|
||||
ClassUtils.getActiveClassFeatures(charClass).filter(
|
||||
ClassFeatureUtils.getHasItemMappings
|
||||
);
|
||||
|
||||
const baseClassFeatures: ClassFeature[] = [];
|
||||
const subClassFeatures: ClassFeature[] = [];
|
||||
activeFeaturesWithItemMappings.forEach((feature) => {
|
||||
const featureClassId = ClassFeatureUtils.getClassId(feature);
|
||||
if (featureClassId === baseClassId) {
|
||||
baseClassFeatures.push(feature);
|
||||
} else if (featureClassId === subclassId) {
|
||||
subClassFeatures.push(feature);
|
||||
}
|
||||
});
|
||||
|
||||
const sortedBaseFeatures = sortBy(baseClassFeatures, [
|
||||
(feature) => ClassFeatureUtils.getName(feature),
|
||||
]);
|
||||
|
||||
const sortedSubclassFeatures = sortBy(subClassFeatures, [
|
||||
(feature) => ClassFeatureUtils.getName(feature),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div key={ClassUtils.getMappingId(charClass)}>
|
||||
<Heading>{ClassUtils.getName(charClass)}</Heading>
|
||||
{itemsLoadingStatus !== DataLoadingStatusEnum.LOADED && (
|
||||
<LoadingPlaceholder />
|
||||
)}
|
||||
{itemsLoadingStatus === DataLoadingStatusEnum.LOADED &&
|
||||
sortedBaseFeatures.map((feature) => {
|
||||
return (
|
||||
<Collapsible
|
||||
className={styles.classFeature}
|
||||
key={ClassFeatureUtils.getUniqueKey(feature)}
|
||||
header={`${ClassFeatureUtils.getName(feature)} (${
|
||||
ClassFeatureUtils.getChoices(feature).length
|
||||
})`}
|
||||
initiallyCollapsed={false}
|
||||
>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
themed
|
||||
size="xx-small"
|
||||
className={styles.viewDetailsButton}
|
||||
onClick={() =>
|
||||
handleClassFeatureClick(charClass, feature)
|
||||
}
|
||||
>
|
||||
View {ClassFeatureUtils.getName(feature)} Details
|
||||
<ArrowRightIcon className={styles.arrowIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
<FeatureChoices
|
||||
className={styles.featureChoices}
|
||||
choices={ClassFeatureUtils.getChoices(feature)}
|
||||
charClass={charClass}
|
||||
classFeature={feature}
|
||||
onChoiceChange={(id, type, value, parentChoiceId) =>
|
||||
handleChoiceChange(
|
||||
charClass,
|
||||
feature,
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
parentChoiceId
|
||||
)
|
||||
}
|
||||
collapseDescription={true}
|
||||
shouldFetch={false}
|
||||
itemShoppe={itemShoppe}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{itemsLoadingStatus === DataLoadingStatusEnum.LOADED &&
|
||||
sortedSubclassFeatures.length > 0 && (
|
||||
<>
|
||||
<Heading>{subclassName}</Heading>
|
||||
{sortedSubclassFeatures.map((feature) => {
|
||||
return (
|
||||
<Collapsible
|
||||
className={styles.classFeature}
|
||||
key={ClassFeatureUtils.getUniqueKey(feature)}
|
||||
header={`${ClassFeatureUtils.getName(feature)} (${
|
||||
ClassFeatureUtils.getChoices(feature).length
|
||||
})`}
|
||||
initiallyCollapsed={false}
|
||||
>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
themed
|
||||
size="xx-small"
|
||||
className={styles.viewDetailsButton}
|
||||
onClick={() =>
|
||||
handleClassFeatureClick(charClass, feature)
|
||||
}
|
||||
>
|
||||
View {ClassFeatureUtils.getName(feature)} Details
|
||||
<ArrowRightIcon className={styles.arrowIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
<FeatureChoices
|
||||
className={styles.featureChoices}
|
||||
choices={ClassFeatureUtils.getChoices(feature)}
|
||||
charClass={charClass}
|
||||
classFeature={feature}
|
||||
onChoiceChange={(id, type, value, parentChoiceId) =>
|
||||
handleChoiceChange(
|
||||
charClass,
|
||||
feature,
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
parentChoiceId
|
||||
)
|
||||
}
|
||||
collapseDescription={true}
|
||||
shouldFetch={false}
|
||||
itemShoppe={itemShoppe}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
ShortModelInfoContract,
|
||||
ApiRequests,
|
||||
serviceDataActions,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Checkbox } from "~/components/Checkbox";
|
||||
import { RadioGroup } from "~/components/RadioGroup";
|
||||
import { PreferenceLongRestTypeEnum } from "~/constants";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { toastMessageActions } from "~/tools/js/Shared/actions";
|
||||
import ThemeButton from "~/tools/js/Shared/components/common/Button/ThemeButton/ThemeButton";
|
||||
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
|
||||
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
|
||||
|
||||
import { RestoreLifeManager } from "../HitPointsManagePane/RestoreLifeManager";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface LongRestPaneProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const LongRestPane: FC<LongRestPaneProps> = ({ ...props }) => {
|
||||
const [resetMaxHpModifier, setResetMaxHpModifier] = useState(true);
|
||||
const [adjustConditionLevel, setAdjustConditionLevel] = useState(false);
|
||||
const [restMessageLoadingStatus, setRestMessageLoadingStatus] =
|
||||
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
|
||||
|
||||
const {
|
||||
activeConditions,
|
||||
isDead,
|
||||
preferences,
|
||||
characterUtils,
|
||||
helperUtils,
|
||||
apiAdapterUtils,
|
||||
characterActions,
|
||||
longRestText,
|
||||
} = useCharacterEngine();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSave = () => {
|
||||
dispatch(
|
||||
characterActions.longRest(resetMaxHpModifier, adjustConditionLevel)
|
||||
);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Long Rest Taken",
|
||||
"You have completed a long rest. Relevant abilities have been reset."
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleRestoreToLife = (restoreType: ShortModelInfoContract) => {
|
||||
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
|
||||
|
||||
dispatch(characterActions.restoreLife(restoreType.id));
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Character Restored to Life",
|
||||
`You have been restored to life with ${restoreChoice} HP.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeLongRestType = (value: number | null) => {
|
||||
const typedPrefKey = characterUtils.getPreferenceKey("longRestType");
|
||||
|
||||
if (typedPrefKey !== null) {
|
||||
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (restMessageLoadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
setRestMessageLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
|
||||
ApiRequests.getCharacterRestLong({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
let message = apiAdapterUtils.getResponseData(response);
|
||||
if (message !== null) {
|
||||
dispatch(serviceDataActions.longRestTextSet(message));
|
||||
}
|
||||
setRestMessageLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
AppLoggerUtils.handleAdhocApiError(e);
|
||||
});
|
||||
}
|
||||
|
||||
// If the pane is quickly opened and closed multiple times, we cancel any in-flight requests.
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [restMessageLoadingStatus]);
|
||||
|
||||
const renderRecover = () => {
|
||||
if (isDead) {
|
||||
return <p>Your character is dead</p>;
|
||||
}
|
||||
const exhaustionIsActive = activeConditions.find(
|
||||
(condition) => condition.level !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{longRestText === null ||
|
||||
restMessageLoadingStatus === DataLoadingStatusEnum.LOADING
|
||||
? "Asking the server what will be reset..."
|
||||
: longRestText}
|
||||
</p>
|
||||
<div className={styles.recoverMaxHp}>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
id="reset-max-hp"
|
||||
label="Reset Maximum HP changes during this rest"
|
||||
checked={resetMaxHpModifier}
|
||||
onChange={() => setResetMaxHpModifier(!resetMaxHpModifier)}
|
||||
themed
|
||||
/>
|
||||
</div>
|
||||
{exhaustionIsActive && (
|
||||
<div className={styles.recoverExhaustion}>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
id="recover-exhaustion"
|
||||
label="Recover 1 Level of Exhaustion during this rest (requires food and drink)"
|
||||
checked={adjustConditionLevel}
|
||||
onChange={() => setAdjustConditionLevel(!adjustConditionLevel)}
|
||||
themed
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = () => {
|
||||
if (isDead) {
|
||||
return (
|
||||
<>
|
||||
<RestoreLifeManager onSave={handleRestoreToLife} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.recoverActions}>
|
||||
<ThemeButton onClick={handleSave} enableConfirm={true}>
|
||||
Take Long Rest
|
||||
</ThemeButton>
|
||||
<ThemeButton
|
||||
onClick={() => {
|
||||
setResetMaxHpModifier(true);
|
||||
setAdjustConditionLevel(false);
|
||||
}}
|
||||
style="outline"
|
||||
>
|
||||
Reset
|
||||
</ThemeButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>Long Rest</Header>
|
||||
<div>
|
||||
<p>
|
||||
A Long Rest is a period of extended downtime—at least 8
|
||||
hours—available to any creature. During a Long Rest, you sleep for at
|
||||
least 6 hours and perform no more than 2 hours of light activity, such
|
||||
as reading, talking, eating, or standing watch.
|
||||
</p>
|
||||
<p>
|
||||
While asleep, you have the Unconscious condition. After you finish a
|
||||
Long Rest, you must wait at least 16 hours before starting another
|
||||
one.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.longRestType}>
|
||||
<RadioGroup
|
||||
className={styles.radioGroup}
|
||||
themed
|
||||
title="Long Rest Rules"
|
||||
name="long-rest-type"
|
||||
initialValue={preferences.longRestType}
|
||||
onChange={(e) =>
|
||||
handleChangeLongRestType(
|
||||
helperUtils.parseInputInt(e?.target?.value)
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
label: "Recover 1/2 Hit Dice",
|
||||
description: "Use 5e Rules",
|
||||
value: PreferenceLongRestTypeEnum.HALF,
|
||||
},
|
||||
{
|
||||
label: "Recover all Hit Dice",
|
||||
description: "Use 5.5e Rules",
|
||||
value: PreferenceLongRestTypeEnum.FULL,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.recover}>
|
||||
<h2 className={styles.recoverHeading}>Recover</h2>
|
||||
{renderRecover()}
|
||||
</div>
|
||||
{renderActions()}
|
||||
<div className={styles.longRestInfo}>
|
||||
<p>
|
||||
<strong>
|
||||
<em>Benefits of the Rest.</em>
|
||||
</strong>{" "}
|
||||
To start a Long Rest, you must have at least 1 HP. When you finish the
|
||||
rest, you gain the following benefits:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
You regain all lost HP and all spent Hit Point Dice. If your HP
|
||||
maximum was reduced, it returns to normal.
|
||||
</li>
|
||||
<li>
|
||||
If any of your ability scores were reduced, they return to normal.
|
||||
</li>
|
||||
<li>
|
||||
If you have the Exhaustion condition, its level decreases by 1.
|
||||
</li>
|
||||
<li>
|
||||
If you have a feature that is recharged by a Long Rest, it recharges
|
||||
in the way specified in its description.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>
|
||||
<em>Interrupting the Rest.</em>
|
||||
</strong>{" "}
|
||||
A Long Rest is stopped by the following interruptions:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Rolling Initiative</li>
|
||||
<li>Casting a spell other than a cantrip</li>
|
||||
<li>Taking any damage</li>
|
||||
<li>1 hour of walking or other physical exertion</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you rested at least 1 hour before the interruption, you gain the
|
||||
benefits of a Short Rest.
|
||||
</p>
|
||||
<p>
|
||||
You can resume a Long Rest immediately after an interruption. If you
|
||||
do so, the rest requires 1 additional hour per interruption to finish.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
Action,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { FeatureChoices } from "~/components/FeatureChoices";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
|
||||
import {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { characterActions } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { XpManager } from "~/components/XpManager";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
|
||||
import { toastMessageActions } from "~/tools/js/Shared/actions";
|
||||
|
||||
|
||||
@@ -3,14 +3,7 @@ import { FC, HTMLAttributes } from "react";
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const ArrowsLeftIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="leftArrowsTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="leftArrowsTitle">Left Arrows Icon</title>
|
||||
<svg viewBox="0 0 16 16" width="1em" height="1em" {...props}>
|
||||
<path d="M11,2.48,5,8l6,5.52a1.3,1.3,0,0,1-.21,2.12h0a2.25,2.25,0,0,1-2.68-.17L0,8,8.11.53A2.25,2.25,0,0,1,10.79.36h0A1.3,1.3,0,0,1,11,2.48Z" />
|
||||
<polygon points="6.92 8 16 0 16 16 6.92 8" />
|
||||
</svg>
|
||||
|
||||
@@ -3,14 +3,7 @@ import { FC, HTMLAttributes } from "react";
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const ArrowsRightIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="rightArrowsTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="rightArrowsTitle">Right Arrows Icon</title>
|
||||
<svg viewBox="0 0 16 16" width="1em" height="1em" {...props}>
|
||||
<path d="M5.21.36h0A2.25,2.25,0,0,1,7.89.53L16,8,7.89,15.47a2.25,2.25,0,0,1-2.68.17h0A1.3,1.3,0,0,1,5,13.52L11,8,5,2.48A1.3,1.3,0,0,1,5.21.36Z" />
|
||||
<polygon points="9.09 8 0 0 0 16 9.09 8" />
|
||||
</svg>
|
||||
|
||||
@@ -90,10 +90,6 @@ export interface PaneIdentifiersSettingsContext {
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersBlessing {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersTrait {
|
||||
type: Constants.TraitTypeEnum;
|
||||
}
|
||||
@@ -125,7 +121,6 @@ export type PaneIdentifiers =
|
||||
| PaneIdentifiersContainer
|
||||
| PaneIdentifiersCurrencyContext
|
||||
| PaneIdentifiersSettingsContext
|
||||
| PaneIdentifiersBlessing
|
||||
| PaneIdentifiersTrait;
|
||||
|
||||
export interface PaneComponentProperties {
|
||||
@@ -148,7 +143,6 @@ export enum PaneComponentEnum {
|
||||
ARMOR_MANAGE = "ARMOR_MANAGE",
|
||||
BASIC_ACTION = "BASIC_ACTION",
|
||||
BACKGROUND = "BACKGROUND",
|
||||
BLESSING_DETAIL = "BLESSING_DETAIL",
|
||||
CAMPAIGN = "CAMPAIGN",
|
||||
CHARACTER_MANAGE = "CHARACTER_MANAGE",
|
||||
CHARACTER_SPELL_DETAIL = "CHARACTER_SPELL_DETAIL",
|
||||
@@ -176,6 +170,7 @@ export enum PaneComponentEnum {
|
||||
INITIATIVE = "INITIATIVE",
|
||||
INSPIRATION = "INSPIRATION",
|
||||
ITEM_DETAIL = "ITEM_DETAIL",
|
||||
ITEM_PLANS_MANAGE = "ITEM_PLANS_MANAGE",
|
||||
LONG_REST = "LONG_REST",
|
||||
NOTE_MANAGE = "NOTE_MANAGE",
|
||||
POSSESSIONS_MANAGE = "POSSESSIONS_MANAGE",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import VideoIcon from "@dndbeyond/fontawesome-cache/svgs/solid/video.svg";
|
||||
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
|
||||
import { useAuth } from "~/contexts/Authentication";
|
||||
import { useClaimCharacter } from "~/hooks/useClaimCharacter";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import { ClaimConfirmationDialog } from "../ClaimConfirmationDialog";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface WatchTourDialogProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const WatchTourDialog: FC<WatchTourDialogProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMaxCharacterMessageOpen, setIsMaxCharacterMessageOpen] =
|
||||
useState(false);
|
||||
const user = useAuth();
|
||||
const { characterSlotLimit, activeCharacterCount } = useSelector(
|
||||
appEnvSelectors.getCharacterSlots
|
||||
);
|
||||
|
||||
// Character slot limit is null for admin accounts
|
||||
const hasOpenSlot =
|
||||
characterSlotLimit === null || activeCharacterCount < characterSlotLimit;
|
||||
const signedIn = !!user;
|
||||
const signupLink = `/create-account?returnUrl=${window.location.pathname}`;
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const campaignJoinCode = params.get("campaignJoinCode");
|
||||
const isAssigned = params.get("isAssigned") !== "false";
|
||||
|
||||
const [
|
||||
claimCharacter,
|
||||
isClaimingCharacter,
|
||||
isFinishedClaimingCharacter,
|
||||
newCharacterId,
|
||||
campaignId,
|
||||
] = useClaimCharacter({
|
||||
campaignJoinCode,
|
||||
isAssigned,
|
||||
});
|
||||
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
const handleClaim = () => {
|
||||
if (hasOpenSlot) {
|
||||
claimCharacter();
|
||||
} else {
|
||||
setIsMaxCharacterMessageOpen(true);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isFinishedClaimingCharacter) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isFinishedClaimingCharacter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx([styles.watchTourDialog, className])} {...props}>
|
||||
<Button
|
||||
className={styles.trigger}
|
||||
onClick={handleOpen}
|
||||
color="secondary"
|
||||
size="x-small"
|
||||
>
|
||||
<VideoIcon />
|
||||
Watch Tour
|
||||
</Button>
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
modal
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>D&D Beyond Character Sheet Tour</h2>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
variant="tool"
|
||||
onClick={handleClose}
|
||||
aria-label="Close Modal"
|
||||
>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.videoWrapper}>
|
||||
<iframe
|
||||
className={styles.video}
|
||||
src="https://www.youtube-nocookie.com/embed/ChYPIdCrBdE"
|
||||
title="D&D Beyond Sheet Tour"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
For a complete guide to getting your game started, see{" "}
|
||||
<a href="https://www.dndbeyond.com/posts/754-how-to-play-dungeons-dragons-using-d-d-beyond">
|
||||
How to Play Dungeons & Dragons Using D&D Beyond
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button
|
||||
className={styles.claimButton}
|
||||
{...(!signedIn && { href: signupLink })}
|
||||
{...(signedIn && { onClick: handleClaim })}
|
||||
disabled={isClaimingCharacter}
|
||||
color="info"
|
||||
size="x-small"
|
||||
>
|
||||
Claim Character
|
||||
</Button>
|
||||
</footer>
|
||||
</Dialog>
|
||||
</div>
|
||||
<MaxCharactersDialog
|
||||
open={isMaxCharacterMessageOpen}
|
||||
onClose={() => setIsMaxCharacterMessageOpen(false)}
|
||||
useMyCharactersLink
|
||||
/>
|
||||
{isFinishedClaimingCharacter && !!newCharacterId && (
|
||||
<ClaimConfirmationDialog
|
||||
characterId={newCharacterId ?? 0}
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MobileSections } from "../../types";
|
||||
|
||||
@@ -11,7 +17,7 @@ export interface SheetContextType {
|
||||
|
||||
export const SheetContext = createContext<SheetContextType>(null!);
|
||||
|
||||
export const SheetProvider: FC = ({ children }) => {
|
||||
export const SheetProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [mobileActiveSectionId, setMobileActiveSectionId] =
|
||||
useState<MobileSections>("main");
|
||||
const [swipedAmount, setSwipedAmount] = useState(0);
|
||||
|
||||
Reference in New Issue
Block a user