Files
dndbeyond_src/ddb_main/tools/js/CharacterBuilder/containers/CharacterBuilder/CharacterBuilder.tsx
T

543 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { connect, DispatchProp } from "react-redux";
import {
matchPath,
Route,
Routes,
useLocation,
useNavigate,
useSearchParams,
} from "react-router-dom";
import { AnyAction } from "redux";
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
import {
CampaignDataContract,
characterSelectors,
CharacterStatusSlug,
} from "@dndbeyond/character-rules-engine";
import {
DecorationInfo,
DecorationUtils,
FormatUtils,
rulesEngineSelectors,
characterActions,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import { NotificationSystem } from "~/components/NotificationSystem";
import config from "~/config";
import { useHeadContext } from "~/contexts/Head";
import { useDispatch } from "~/hooks/useDispatch";
import { PortraitName } from "~/subApps/builder/components/PortraitName/PortraitName";
import { BuilderMethod } from "~/subApps/builder/constants";
import { ClassProvider } from "~/subApps/builder/contexts/Class";
import { ModalManagerProvider } from "~/subApps/builder/contexts/ModalManager";
import { SpeciesProvider } from "~/subApps/builder/contexts/Species";
import { BuilderTypeChoicePage } from "~/subApps/builder/routes/BuilderTypeChoicePage";
import pageStyles from "~/subApps/builder/styles/page.module.css";
import { appEnvActions, toastMessageActions } from "../../../Shared/actions";
import LoadingBlocker from "../../../Shared/components/LoadingBlocker";
import { BuilderLinkButton } from "../../../Shared/components/common/LinkButton";
import AppErrorTypeEnum from "../../../Shared/constants/AppErrorTypeEnum";
import UserRoles from "../../../Shared/constants/UserRoles";
import { DiceContainer } from "../../../Shared/containers/DiceContainer";
import {
appEnvSelectors,
appInfoSelectors,
toastMessageSelectors,
} from "../../../Shared/selectors";
import { AppInfoErrorState } from "../../../Shared/stores/typings";
import { builderActions } from "../../actions/builder";
import { BUILDER_MQ_IS_MOBILE_SIZE } from "../../config";
import {
getCurrentRouteDef,
RouteDef,
ROUTE_DEFINITIONS,
} from "../../config/navigation";
import { builderSelectors } from "../../selectors";
import { BuilderAppState } from "../../typings";
import { getUrlWithParams } from "../../utils/navigationUtils";
import NavigationSections from "../NavigationSections";
import SynchronousBlocker from "../SynchronousBlocker";
import TodoNavigation from "../TodoNavigation";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
// TODO: We are typing with AnyAction here to work around issues with UnknownAction, the type that was introduced in Redux 5.
// If we decide to modernize to Redux 5, we will need to refactor much of the Rules Engine redux usage.
interface Props extends DispatchProp<AnyAction> {
appError: AppInfoErrorState | null;
toastMessages: any;
builderMethod: string | null;
isCharacterLoading: boolean;
isCharacterLoaded: boolean;
isMobile: boolean;
name: string | null;
username: string | null;
redirect: string;
characterId: number | null;
diceEnabled: boolean;
decorationInfo: DecorationInfo;
canEdit: boolean;
isReadonly: boolean;
userRoles: string[] | null | undefined;
characterStatus: string | null;
userId: number;
campaign: CampaignDataContract | null;
isVttView: boolean;
}
const RenderAuthError: React.FC<{ appError: Props["appError"] }> | null = ({
appError,
}) => {
if (appError === null) {
return null;
}
let contentNode: React.ReactNode;
switch (appError.type) {
case AppErrorTypeEnum.AUTH_MISSING:
let signInUrl: string = `/sign-in?returnUrl=${encodeURIComponent(
window.location.pathname
)}`;
contentNode = (
<>
<p>
You are no longer signed into D&D Beyond. Go to the{" "}
<Link href={signInUrl}>Sign In page</Link> to continue the
adventure!
</p>
</>
);
break;
case AppErrorTypeEnum.API_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.API_DATA_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API Data check. We're heading into town
to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.AUTH_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our Authentication check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.ACCESS_DENIED:
contentNode = (
<>
<p>You don't have access to this page.</p>
<p>
If you are trying to access a character sheet, make sure its privacy
setting is configured correctly for you to access it.
</p>
<p>
It is also possible that you are trying to enter the 403rd level of
the endless dungeon and the ancient dragon Rylzrayrth is rising up
to block your path...
</p>
</>
);
break;
case AppErrorTypeEnum.NOT_FOUND:
appError.errorId = null;
contentNode = (
<>
<p>
We live in a world of uncertainty. But certainly, the page you were
looking for isnt here. Perhaps this halfling has stolen it and
hidden it in another place. Try searching for what you were looking
for in another realm.
</p>
</>
);
break;
case AppErrorTypeEnum.GENERIC:
default:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our check. We're heading into town to visit
the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
}
return (
<div className="ct-character-sheet--failed">
<div
className={[
"ct-character-sheet__failed",
`ct-character-sheet__failed--${FormatUtils.slugify(appError.type)}`,
].join(" ")}
>
<div className="ct-character-sheet__failed-content">
{contentNode}
{appError.errorId && (
<>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Error Code
</div>
<div className="ct-character-sheet__failed-code-value">
{appError.errorId}
</div>
</div>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Version
</div>
<div className="ct-character-sheet__failed-code-value">
{config.version}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
};
const checkForMessages = (
toastMessages,
dispatch,
notificationSystem
): void => {
Object.keys(toastMessages).forEach((messageId) => {
if (!notificationSystem) {
return;
}
const message = toastMessages[messageId];
const { autoDismiss, dismissible, position } = message.meta;
if (!notificationSystem.current) {
return;
}
notificationSystem.current.addNotification({
title: message.payload.title,
message: message.payload.message,
level: message.meta.level,
autoDismiss,
dismissible,
position,
uid: messageId,
onRemove: () => dispatch(toastMessageActions.removeToast(messageId)),
});
});
};
const CharacterBuilder: React.FC<Props> | null = ({
appError,
toastMessages,
builderMethod,
isCharacterLoading,
isCharacterLoaded,
isMobile,
name,
username,
redirect,
characterId,
diceEnabled,
decorationInfo,
canEdit,
isReadonly,
userRoles,
characterStatus,
userId,
campaign,
isVttView,
}) => {
let mobileMql: MediaQueryList | null = null;
// useImperativeHandle is hard to type when we us our own context this will just disapear
const notificationSystem = React.createRef<any>();
const dispatch = useDispatch();
const navigate = useNavigate();
const { pathname } = useLocation();
const { setTitle } = useHeadContext();
const [isLoaded, setIsLoaded] = useState(false);
const prevPathnameRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (characterId !== null && !isLoaded) {
dispatch(builderActions.characterLoadRequest());
setIsLoaded(true);
}
mobileMql = window.matchMedia(BUILDER_MQ_IS_MOBILE_SIZE);
dispatch(appEnvActions.mobileSet(mobileMql.matches));
mobileMql.onchange = (e) => {
dispatch(appEnvActions.mobileSet(e.matches));
};
}, [characterId]);
React.useEffect(() => {
if (
isLoaded &&
characterId !== null &&
characterStatus === CharacterStatusSlug.PREMADE
) {
const isLorekeeper =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
isLorekeeper && dispatch(characterActions.premadeInfoGet(characterId));
}
}, [characterId, characterStatus, isLoaded, userRoles]);
React.useEffect(() => {
//check for isCharacterLoaded and canEdit to set isReadonly and redirect, or is Random build to set isReadonly
if (isCharacterLoaded) {
dispatch(
appEnvActions.dataSet({
isReadonly: !canEdit,
isUserDM: Number(userId) === campaign?.dmUserId,
})
);
if (!canEdit) {
navigate(getUrlWithParams(`${BASE_PATHNAME}/${characterId}`));
}
}
}, [
isCharacterLoaded,
canEdit,
characterId,
navigate,
dispatch,
builderMethod,
userId,
]);
React.useEffect(() => {
checkForMessages(toastMessages, dispatch, notificationSystem);
});
React.useEffect(() => {
setTitle("Character Builder");
}, [setTitle]);
if (appError !== null) {
return <RenderAuthError appError={appError} />;
}
let clsNames: Array<string> = ["character-builder"];
if (builderMethod) {
clsNames.push(`character-builder-${FormatUtils.slugify(builderMethod)}`);
}
// useEffect above handles redirect but this blocks UI from showing
if (isReadonly && isCharacterLoaded) {
return null;
}
if (!isCharacterLoading && builderMethod === null) {
let anonNode: React.ReactNode;
if (!username) {
anonNode = (
<div className="character-builder-anon">
<div className="character-builder-anon-heading">
Start Your Adventure
</div>
<div className="character-builder-anon-content">
<p>
You need to be signed in to a D&amp;D Beyond account in order to
save characters. You can use your character sheet anywhere, on any
device.
</p>
</div>
<div className="character-builder-anon-actions">
<BuilderLinkButton size="oversized" url={redirect}>
Sign in or Create Account
</BuilderLinkButton>
</div>
</div>
);
}
return (
<Routes>
<Route
path="/"
element={
<div className={styles.characterBuilder}>
<div className="character-builder-inner">
{anonNode}
{
<BuilderTypeChoicePage
className="builder-methods"
isEnabled={!!username}
/>
}
</div>
</div>
}
></Route>
</Routes>
);
}
clsNames.push(styles.characterBuilder);
if (diceEnabled) {
clsNames.push("character-builder--dice-enabled");
}
clsNames.push(styles.characterBuilder);
return (
<Routes>
<Route
path="/*"
element={
<div className={clsNames.join(" ")}>
{(characterId !== null || isCharacterLoading) && (
<LoadingBlocker isFinished={isCharacterLoaded} />
)}
{!isCharacterLoading && (
<ModalManagerProvider>
<ClassProvider>
<SpeciesProvider>
<SynchronousBlocker />
<div className="character-builder-inner">
{builderMethod === BuilderMethod.STEP_BY_STEP && (
<>
{!isMobile && (
<div className="character-builder-page-header">
<h1 className="character-builder-page-header-heading">
Character Builder
</h1>
{name && (
<div className="character-builder-page-header-summary">
<CharacterAvatarPortrait
className={
"character-builder-page-header-avatar"
}
avatarUrl={
DecorationUtils.getAvatarInfo(
decorationInfo
).avatarUrl
}
/>
<div className="character-builder-page-header-name">
{name}
</div>
</div>
)}
</div>
)}
<NavigationSections
pathname={pathname}
characterId={characterId}
/>
<TodoNavigation
pathname={pathname}
characterId={characterId}
/>
<PortraitName
useDefaultCharacterName={true}
className={styles.portraitName}
/>
<hr className={pageStyles.divider} />
</>
)}
<Routes>
{(Object.values(ROUTE_DEFINITIONS) as RouteDef[]).map(
({ key, path, getComponent }) => (
<Route
key={key}
path={path
.replace(`${BASE_PATHNAME}/builder/`, "")
.replace(
`${BASE_PATHNAME}/:characterId/builder/`,
""
)}
element={getComponent()}
/>
)
)}
<Route
path="/"
element={<BuilderTypeChoicePage />}
></Route>
</Routes>
</div>
</SpeciesProvider>
</ClassProvider>
</ModalManagerProvider>
)}
<NotificationSystem ref={notificationSystem} />
{diceEnabled && !isReadonly ? (
<DiceContainer
canShow={isCharacterLoaded}
isVttView={isVttView}
/>
) : null}
</div>
}
></Route>
</Routes>
);
};
const CharacterBuilderWrapper = (props) => {
const [searchParams] = useSearchParams();
const isVttView = searchParams.get("view") === "vtt";
return <CharacterBuilder {...props} isVttView={isVttView} />;
};
function mapStateToProps(state: BuilderAppState) {
return {
toastMessages: toastMessageSelectors.getToastMessages(state),
builderMethod: builderSelectors.getBuilderMethod(state),
isCharacterLoading: builderSelectors.getIsCharacterLoading(state),
isCharacterLoaded: builderSelectors.getIsCharacterLoaded(state),
isMobile: appEnvSelectors.getIsMobile(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
name: rulesEngineSelectors.getName(state),
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
canEdit: rulesEngineSelectors.getCanEdit(state),
username: appEnvSelectors.getUsername(state),
redirect: appEnvSelectors.getRedirect(state),
appError: appInfoSelectors.getError(state),
characterId: appEnvSelectors.getCharacterId(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
userRoles: appEnvSelectors.getUserRoles(state),
characterStatus: characterSelectors.getStatusSlug(state),
userId: appEnvSelectors.getUserId(state),
campaign: characterSelectors.getCampaign(state),
};
}
export default connect(mapStateToProps)(CharacterBuilderWrapper);