Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
David Kruger 2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions

8
README.md Normal file
View File

@ -0,0 +1,8 @@
This is the main javascript logic from the dndbeyond.com character sheets
The data is downloaded using `sourcemapper` from https://github.com/denandz/sourcemapper
Given the main JS file URL, the source code can be downloaded and mapped using:
```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```

View File

@ -0,0 +1,270 @@
import clsx from "clsx";
import { HTMLAttributes, ReactNode, useState } from "react";
import {
AbilityManager,
AbilityScoreSupplierData,
DataOrigin,
DataOriginUtils,
HelperUtils,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DataOriginName from "~/tools/js/smartComponents/DataOriginName";
import { AbilityIcon } from "~/tools/js/smartComponents/Icons";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
ability: AbilityManager;
showHeader?: boolean;
isReadonly: boolean;
isBuilder?: boolean;
}
export function AbilityScoreManager({
ability,
isReadonly,
showHeader = true,
className = "",
isBuilder = false,
...props
}: Props) {
const { characterTheme: theme, originRef } = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const [overrideScore, setOverrideScore] = useState(
ability.getOverrideScore()
);
const [otherBonus, setOtherBonus] = useState(ability.getOtherBonus());
const handleOtherBonusBlur = (
evt: React.FocusEvent<HTMLInputElement>
): void => {
let value: number | null = HelperUtils.parseInputInt(evt.target.value);
setOtherBonus(ability.handleOtherBonusChange(value));
};
const handleOverrideScoreBlur = (
evt: React.FocusEvent<HTMLInputElement>
): void => {
const value: number | null = HelperUtils.parseInputInt(evt.target.value);
setOverrideScore(ability.handleOverrideScoreChange(value));
};
const handleOtherBonusChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setOtherBonus(HelperUtils.parseInputInt(evt.target.value));
};
const handleOverrideScoreChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setOverrideScore(HelperUtils.parseInputInt(evt.target.value));
};
const handleDataOriginClick = (dataOrigin: DataOrigin) => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const label = ability.getLabel();
const modifier = ability.getModifier();
const baseScore = ability.getBaseScore();
const statId = ability.getId();
const totalScore = ability.getTotalScore();
const speciesBonus = ability.getRacialBonus();
const classBonuses = ability.getClassBonuses();
const miscBonus = ability.getMiscBonus();
const stackingBonus = ability.getStackingBonus();
const setScore = ability.getSetScore();
const allStatsBonusSuppliers = ability.getAllStatBonusSuppliers();
const statSetScoreSuppliers = ability.getStatSetScoreSuppliers();
const stackingBonusSuppliers = ability.getStackingBonusSuppliers();
const getSupplierData = (
suppliers: Array<AbilityScoreSupplierData>
): ReactNode => {
return (
<div className={styles.suppliers}>
{suppliers.map((supplier) => {
const origin = supplier.dataOrigin;
let expandedOrigin: DataOrigin | null = null;
if (supplier.expandedOriginRef) {
const primaryOrigin = DataOriginUtils.getRefPrimary(
supplier.expandedOriginRef,
originRef
);
if (primaryOrigin && primaryOrigin["componentId"] !== null) {
expandedOrigin = primaryOrigin["dataOrigin"];
}
}
return (
<div
className={clsx(styles.supplier, isBuilder && styles.builder)}
key={supplier.key}
>
<div className={styles.label}>
<DataOriginName
className={isBuilder ? styles.builder : ""}
dataOrigin={origin}
tryParent
theme={theme}
onClick={!isBuilder ? handleDataOriginClick : undefined}
/>
{expandedOrigin && (
<span>
{" "}
(
<DataOriginName
className={isBuilder ? styles.builder : ""}
dataOrigin={expandedOrigin}
tryParent
theme={theme}
onClick={!isBuilder ? handleDataOriginClick : undefined}
/>
)
</span>
)}
</div>
<div className={styles.value}>
(
{supplier.type === "set" ? (
supplier.value
) : (
<NumberDisplay
className={styles.number}
type="signed"
number={supplier.value}
/>
)}
)
</div>
</div>
);
})}
</div>
);
};
return (
<div {...props}>
{showHeader && (
<div className={styles.header}>
<AbilityIcon
statId={statId}
themeMode="light"
className={styles.icon}
/>
<div className={styles.name}>{label}</div>
</div>
)}
<div className={styles.table}>
<div className={clsx([styles.row, isBuilder && styles.highlight])}>
<div className={styles.label}>Total Score</div>
<div className={styles.value}>
{totalScore === null ? "--" : totalScore}
</div>
</div>
<div className={clsx([styles.row, isBuilder && styles.highlight])}>
<div className={styles.label}>Modifier</div>
<div className={styles.value}>
<NumberDisplay type="signed" number={modifier} />
</div>
</div>
<div className={styles.row}>
<div className={styles.label}>Base Score</div>
<div className={styles.value}>
{baseScore === null ? "--" : baseScore}
</div>
</div>
<div
className={clsx(
styles.row,
allStatsBonusSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Bonus</div>
</div>
<div className={styles.value}>
<NumberDisplay
type="signed"
number={classBonuses + speciesBonus + miscBonus}
/>
</div>
</div>
{getSupplierData(allStatsBonusSuppliers)}
<div
className={clsx(
styles.row,
statSetScoreSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Set Score</div>
</div>
<div className={styles.value}>{setScore}</div>
</div>
{getSupplierData(statSetScoreSuppliers)}
<div
className={clsx(
styles.row,
stackingBonusSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Stacking Bonus</div>
</div>
<div className={styles.value}>
<NumberDisplay type="signed" number={stackingBonus} />
</div>
</div>
{getSupplierData(stackingBonusSuppliers)}
</div>
<div className={styles.overrides}>
<div className={styles.override}>
<div className={styles.label}>Other Modifier</div>
<div className={styles.value}>
<input
type="number"
value={otherBonus === null ? "" : otherBonus}
placeholder="--"
onChange={handleOtherBonusChange}
onBlur={handleOtherBonusBlur}
readOnly={isReadonly}
/>
</div>
</div>
<div className={styles.override}>
<div className={styles.label}>Override Score</div>
<div className={styles.value}>
<input
type="number"
value={overrideScore === null ? "" : overrideScore}
placeholder="--"
onChange={handleOverrideScoreChange}
onBlur={handleOverrideScoreBlur}
readOnly={isReadonly}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,148 @@
import clsx from "clsx";
import {
FC,
HTMLAttributes,
MouseEvent,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { v4 as uuidv4 } from "uuid";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import styles from "./styles.module.css";
export interface AccordionProps extends HTMLAttributes<HTMLElement> {
variant?: "default" | "text" | "paper";
summary: ReactNode;
description?: ReactNode;
size?: "small" | "medium";
forceShow?: boolean;
useTheme?: boolean;
resetOpen?: boolean;
override?: boolean | null;
handleIsOpen?: (id: string, isOpen: boolean) => void;
summaryAction?: ReactNode;
summaryImage?: string;
summaryImageAlt?: string;
summaryImageSize?: number;
summaryMetaItems?: Array<ReactNode>;
showAlert?: boolean;
}
export const Accordion: FC<AccordionProps> = ({
className,
summary,
description,
size = "medium",
variant = "default",
children,
forceShow = false,
// style,
useTheme,
resetOpen,
handleIsOpen,
override = null,
id = "",
summaryAction,
summaryImage,
summaryImageAlt,
summaryImageSize = 32,
summaryMetaItems,
showAlert,
...props
}) => {
const summaryRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const loaded = useRef(false);
const [isOpen, setIsOpen] = useState<boolean>(forceShow);
const [height, setHeight] = useState<number | string>();
const handleToggle = (e: MouseEvent) => {
e.preventDefault();
setIsOpen((current) => {
if (handleIsOpen && id) handleIsOpen(id, !current);
return !current;
});
};
useEffect(() => {
const summaryHeight = summaryRef.current?.clientHeight;
const contentHeight = contentRef.current?.getBoundingClientRect().height;
if (isOpen) {
// If the dialog is open and it's not the first load, set the height to the summary + content
if (summaryHeight && contentHeight && loaded.current === true)
setHeight(summaryHeight + contentHeight);
} else {
// If the dialog is closed, set the height to the summary
if (summaryHeight) setHeight(summaryHeight);
}
loaded.current = true;
}, [isOpen, resetOpen]);
useEffect(() => {
if (resetOpen && !isOpen) {
setIsOpen(true);
}
}, [resetOpen]);
useEffect(() => {
if (override !== null) {
setIsOpen(override);
}
}, [override]);
return (
<details
className={clsx([
styles.details,
styles[size],
styles[variant],
useTheme && styles.theme,
showAlert && styles.alert,
className,
])}
open={isOpen}
// style={{ height, ...style }} INVESTIGATE THIS LATER
{...props}
>
<summary
className={styles.summary}
onClick={handleToggle}
ref={summaryRef}
>
{summaryImage && (
<img
className={styles.image}
src={summaryImage}
alt={summaryImageAlt}
width={summaryImageSize}
/>
)}
<div className={styles.heading}>
<div>
{summary}
{summaryMetaItems && (
<div className={styles.metaItems}>
{summaryMetaItems.map((metaItem, idx) => (
<div key={uuidv4()} className={styles.metaItem}>
{metaItem}
</div>
))}
</div>
)}
</div>
<div className={styles.actions}>{summaryAction}</div>
<ChevronDown className={styles.icon} />
</div>
{description && <div className={styles.description}>{description}</div>}
</summary>
<div className={styles.content} ref={contentRef}>
{children}
</div>
</details>
);
};

View File

@ -0,0 +1,59 @@
import clsx from "clsx";
import { FC } from "react";
import {
Button as TtuiButton,
ButtonProps as TtuiButtonProps,
} from "@dndbeyond/ttui/components/Button";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { ThemeMode } from "~/types";
import styles from "./styles.module.css";
export interface ButtonProps
extends Omit<TtuiButtonProps, "size" | "variant" | "color"> {
size?: "xx-small" | TtuiButtonProps["size"];
themed?: boolean;
forceThemeMode?: ThemeMode;
variant?: "builder" | "builder-text" | TtuiButtonProps["variant"];
color?: "builder-green" | TtuiButtonProps["color"];
}
export const Button: FC<ButtonProps> = ({
className,
size = "medium",
themed,
variant = "solid",
forceThemeMode,
color,
...props
}) => {
// Check if the button has a custom size
const isCustomSize = size === "xx-small";
const isCustomVariant = variant === "builder" || variant === "builder-text";
const isCustomColor = color === "builder-green";
const { isDarkMode } = useCharacterTheme();
return (
<TtuiButton
// If the button has a custom size, use local styles
className={clsx([
styles[size],
themed && styles.themed,
forceThemeMode && styles[forceThemeMode],
styles[variant],
!forceThemeMode && isDarkMode && styles.dark,
isCustomColor && styles[color],
className,
])}
// If the button has a normal size, pass prop normally
size={!isCustomSize ? size : undefined}
// If the button is themed, use theme colors
variant={!isCustomVariant ? variant : undefined}
// Children are included in props
color={!isCustomColor ? color : undefined}
{...props}
/>
);
};

View File

@ -0,0 +1,82 @@
import clsx from "clsx";
import { ChangeEvent, FC, useEffect, useState } from "react";
import {
Checkbox as TtuiCheckbox,
CheckboxProps as TtuiCheckboxProps,
} from "@dndbeyond/ttui/components/Checkbox";
import styles from "./styles.module.css";
interface CheckboxProps extends TtuiCheckboxProps {
themed?: boolean;
darkMode?: boolean;
variant?: "default" | "builder" | "sidebar";
onClick?: (isEnabled: boolean) => void;
onChangePromise?: (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
}
export const Checkbox: FC<CheckboxProps> = ({
className,
themed,
variant = "default",
darkMode,
checked = false,
onChangePromise,
onClick,
disabled,
...props
}) => {
const [isChecked, setIsChecked] = useState(checked);
useEffect(() => {
setIsChecked(checked);
}, [checked]);
const handleChange = (evt: ChangeEvent<HTMLInputElement>): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
const newCheckedState = !isChecked;
// promise-based logic
if (onChangePromise) {
onChangePromise(
newCheckedState,
() => {
// Promise accepted, update the state
setIsChecked(newCheckedState);
},
() => {
// Promise rejected, do nothing
}
);
} else {
// Update the state immediately if no promise handling - only IF onClick is provided
if (onClick) {
onClick(newCheckedState);
setIsChecked(newCheckedState);
}
}
};
return (
<TtuiCheckbox
className={clsx([
themed && styles.themed,
darkMode && styles[darkMode ? "darkMode" : "lightMode"],
className,
variant === "default" && styles.default,
variant === "builder" && styles.builder,
])}
onChange={handleChange}
aria-pressed={isChecked}
checked={isChecked}
disabled={disabled}
{...props}
/>
);
};

View File

@ -0,0 +1,62 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode, useState } from "react";
import { HtmlContent } from "../HtmlContent";
import styles from "./styles.module.css";
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
children: ReactNode;
className?: string;
forceShow?: boolean;
heading?: ReactNode;
maxLength?: number;
}
/**
* A component that will display a "Show More" button if the content is longer
* than the maxLength prop. It will also accept a heading prop to display text
* above the content.
*/
export const CollapsibleContent: FC<Props> = ({
forceShow,
className,
children,
heading,
maxLength = 600,
...props
}) => {
const [isOpen, setIsOpen] = useState(forceShow);
const handleToggleClick = (): void => {
setIsOpen((prev) => !prev);
};
if (typeof children === "string" && children?.length <= maxLength && !heading)
return (
<HtmlContent html={children as string} className={className} {...props} />
);
return (
<div
className={clsx([
styles.collapsible,
heading && styles.withHeading,
isOpen && styles.open,
className,
])}
{...props}
>
{heading && <div>{heading}</div>}
{typeof children === "string" ? (
<HtmlContent className={styles.content} html={children as string} />
) : (
<div className={styles.content}>{children}</div>
)}
{!forceShow && (
<button className={styles.button} onClick={handleToggleClick}>
{isOpen ? "Show Less" : "Show More"}
</button>
)}
</div>
);
};

View File

@ -0,0 +1,116 @@
import clsx from "clsx";
import { FC, useEffect } from "react";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
import { Button, ButtonProps } from "../Button";
import styles from "./styles.module.css";
/**
* @heading Text for the title of the modal - defaults to "Confirm"
* @onClose Function to call when the Close button is clicked
* @onConfirm Function to call when the Confirm button is clicked
* @confirmButtonText Provide text to the Confirm button - defaults to "Confirm"
* @closeButtonText Provide text to the Close button - defaults to "Cancel"
* @variant "default" | "remove" | "confirm-only"
* --default: default modal with both Confirm and Close buttons using the "success" button color
* --remove: modal with both Confirm and Close buttons using the "secondary" color for the header and Confirm button
* --confirm-only: modal with only the Confirm button using the "success" button color
* @size "default" | "fit-content"
* --default: default modal size
* --fit-content: modal size adjusts to the content
* @color Passes a ButtonProps color to the confirm button
* @useMobileFullScreen Boolean to set the modal to full screen on mobile breakpoint
*/
export interface ConfirmModalProps extends DialogProps {
heading?: string;
onClose: () => void;
onConfirm: () => void;
confirmButtonText?: string;
closeButtonText?: string;
variant?: "default" | "remove" | "confirm-only";
size?: "default" | "fit-content";
color?: ButtonProps["color"];
useMobileFullScreen?: boolean;
}
export const ConfirmModal: FC<ConfirmModalProps> = ({
children,
className,
heading = "Confirm",
onClose,
onConfirm,
confirmButtonText = "Confirm",
closeButtonText = "Cancel",
open,
variant = "default",
size = "default",
color = "success",
useMobileFullScreen,
...props
}) => {
const isConfirmOnly = variant === "confirm-only";
useEffect(() => {
const body = document.querySelector("body");
if (!body) return;
// Prevent scrolling when modal is visible
if (open) {
body.style.overflow = "hidden";
} else {
body.style.overflow = "";
}
}, [open]);
return (
<Dialog
className={clsx([
styles.confirmModal,
useMobileFullScreen && styles.fullScreen,
styles[variant],
styles[size],
className,
])}
onClose={onClose}
modal
open={open}
{...props}
>
<header className={styles.header}>
<h2>{heading}</h2>
<Button
className={styles.closeButton}
size="x-small"
variant="text"
onClick={onClose}
aria-label={`Close ${heading} Modal`}
forceThemeMode="light"
>
<CloseIcon className={styles.closeIcon} />
</Button>
</header>
<div className={styles.content}>{children}</div>
<footer className={styles.footer}>
{!isConfirmOnly && (
<Button className={styles.footerButton} onClick={onClose}>
{closeButtonText}
</Button>
)}
<Button
className={clsx([
styles.footerButton,
isConfirmOnly && styles.confirmOnly,
])}
color={variant === "remove" ? "secondary" : color}
onClick={onConfirm}
forceThemeMode="light"
size={isConfirmOnly ? "x-small" : undefined}
>
{confirmButtonText}
</Button>
</footer>
</Dialog>
);
};

View File

@ -0,0 +1,40 @@
import clsx from "clsx";
import { HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import Pen from "@dndbeyond/fontawesome-cache/svgs/light/pen.svg";
import { appEnvSelectors } from "../../tools/js/Shared/selectors";
import styles from "./styles.module.css";
interface EditableNameProps extends HTMLAttributes<HTMLDivElement> {
onClick: () => void;
}
/**
* Component which displays children with an edit button to the right. It is
* used in panes to give custom names to items, etc.
*/
export const EditableName = ({
children,
className,
onClick,
...props
}: EditableNameProps) => {
const isReadOnly = useSelector(appEnvSelectors.getIsReadonly);
return (
<div className={clsx([styles.editableName, className])} {...props}>
<div>{children}</div>
{!isReadOnly && (
<button
className={styles.button}
onClick={onClick}
aria-label="Edit name"
>
<Pen className={styles.icon} />
</button>
)}
</div>
);
};

View File

@ -0,0 +1,306 @@
import { FC, HTMLAttributes } from "react";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { useSource } from "~/hooks/useSource";
import {
DetailChoice,
DetailChoiceFeat,
} from "~/tools/js/Shared/containers/DetailChoice";
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
import {
CharClass,
Choice,
ClassDefinitionContract,
ClassFeature,
Feat,
FeatLookup,
FeatureChoiceOption,
HtmlSelectOptionGroup,
SourceData,
} from "~/types";
export interface FeatureChoiceProps extends HTMLAttributes<HTMLDivElement> {
choice: Choice;
charClass?: CharClass;
feature?: ClassFeature;
featsData: Feat[];
subclassData?: ClassDefinitionContract[];
onChoiceChange: (
choiceId: string,
type: number,
value: any,
parentChoiceId: string | null
) => void;
collapseDescription?: boolean;
}
/**
* Component for rendering a features choice(s) - for species traits and class features - using DetailChoiceFeat for a selected feat's choices and DetailChoice for all others.
It is used in both the builder and character sheet sidebar panes.
*/
export const FeatureChoice: FC<FeatureChoiceProps> = ({
charClass,
choice,
feature,
featsData,
subclassData,
onChoiceChange,
className,
collapseDescription,
...props
}) => {
const {
choiceUtils,
featUtils,
helperUtils,
prerequisiteUtils,
classUtils,
featLookup,
preferences,
prerequisiteData,
choiceInfo,
ruleData,
entityRestrictionData,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const {
getGroupedOptionsBySourceCategory,
getSimpleSourcedDefinitionContracts,
} = useSource();
const optionValue = choiceUtils.getOptionValue(choice);
const options = choiceUtils.getOptions(choice);
const type = choiceUtils.getType(choice);
const tagConstraints = choiceUtils.getTagConstraints(choice);
let availableOptions: Array<FeatureChoiceOption> = [];
let availableGroupedOptions: HtmlSelectOptionGroup[] = [];
let detailChoiceDesc: string | null = null;
let subchoicesNode: React.ReactNode;
const handleChoiceChange = (
id: string,
type: number,
subType: number | null,
value: any,
parentChoiceId: string | null
): void => {
onChoiceChange(id, type, value, parentChoiceId);
};
const getSubclassData = (): ClassDefinitionContract[] => {
if (!subclassData || !charClass) {
return [];
}
let data: ClassDefinitionContract[] = [...subclassData];
let existingSubclass = classUtils.getSubclass(charClass);
if (
existingSubclass !== null &&
!data.some(
(classDefinition) =>
existingSubclass !== null &&
classDefinition.id === existingSubclass.id
)
) {
data.push(existingSubclass);
}
return data;
};
const getAvailableFeatChoices = (
existingFeatId: number | null,
featData: Feat[],
featLookup: FeatLookup
): Feat[] => {
let data: Feat[] = [...featData];
if (existingFeatId !== null) {
let existingFeat = helperUtils.lookupDataOrFallback(
featLookup,
existingFeatId
);
if (
existingFeat !== null &&
!data.some((feat) => featUtils.getId(feat) === existingFeatId)
) {
data.push(existingFeat);
}
}
return data;
};
const getSubclassSources = (
subclass: ClassDefinitionContract
): SourceData[] => {
if (subclass.sources === null) {
return [];
}
return subclass.sources
.map((sourceMapping) =>
helperUtils.lookupDataOrFallback(
ruleDataUtils.getSourceDataLookup(ruleData),
sourceMapping.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
};
switch (type) {
case BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION:
const availableFeats = getAvailableFeatChoices(
optionValue,
featsData,
featLookup
);
const repeatableFeatTracker = new Set();
// Add selected feat to repeatable tracker if repeatable
const selectedFeat = optionValue ? featLookup[optionValue] : null;
if (selectedFeat && featUtils.isRepeatable(selectedFeat)) {
const parentId = featUtils.getRepeatableGroupId(selectedFeat);
if (parentId) {
repeatableFeatTracker.add(parentId);
}
}
const filteredFeats = availableFeats.filter((feat) => {
const featId = featUtils.getId(feat);
const isRepeatable = featUtils.isRepeatable(feat);
// If the feat is the currently selected, always include it
if (featId === optionValue) {
return true;
}
// Always exclude all previous selected feats
if (featLookup[featId]) {
return false;
}
// If the Feat does not meet the tag constraints, should they exist, exclude it
const tagCategories = featUtils.getCategories(feat);
if (
tagConstraints &&
!featUtils.doesSatisfyTagConstraints(tagCategories, tagConstraints)
) {
return false;
}
// Handle prerequisites when enforcing feat rules
if (
preferences.enforceFeatRules &&
!prerequisiteUtils.validatePrerequisiteGrouping(
featUtils.getPrerequisites(feat),
prerequisiteData
)
) {
return false;
}
// Special handling for repeatable feats, there can be only one
if (isRepeatable) {
const parentId = featUtils.getRepeatableGroupId(feat);
// If a feat from this repeatable group exist exclude all others
if (repeatableFeatTracker.has(parentId)) {
return false;
}
repeatableFeatTracker.add(parentId);
}
// If none of the exclusions above are met, include the feat
return true;
});
//Group available feats by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(
filteredFeats
.map((feat) => featUtils.getDefinition(feat))
.filter(TypeScriptUtils.isNotNullOrUndefined)
);
if (selectedFeat && optionValue !== null) {
detailChoiceDesc = featUtils.getDescription(selectedFeat);
subchoicesNode = <DetailChoiceFeat featId={optionValue} />;
}
break;
case BuilderChoiceTypeEnum.SUB_CLASS_OPTION:
const subclassData = getSubclassData();
//Group available subclasses by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(subclassData);
const chosenSubclass = subclassData.find(
(subclass) => subclass.id === optionValue
);
if (chosenSubclass) {
detailChoiceDesc = "";
let sources = getSubclassSources(chosenSubclass);
sources.forEach((source) => {
if (source.sourceCategory && source.sourceCategory.isToggleable) {
detailChoiceDesc += source.sourceCategory.description
? source.sourceCategory.description
: "";
}
});
}
break;
case BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
// Map over the options to mock parts of a SpellDefinitionContract.
const spellOptions = getSimpleSourcedDefinitionContracts(options);
availableGroupedOptions = getGroupedOptionsBySourceCategory(
spellOptions,
optionValue,
entityRestrictionData
);
// If there is a chosen spell, set detailChoiceDesc to its description.
const chosenSpell = spellOptions.find(
(spell) => spell.id === optionValue
);
if (chosenSpell) {
detailChoiceDesc = chosenSpell.description ?? "";
}
break;
default:
availableOptions = options.map((option) => ({
...option,
value: option.id,
}));
}
return (
<div className={className} {...props}>
<DetailChoice
{...choice}
choice={choice}
options={
availableGroupedOptions.length > 0
? availableGroupedOptions
: availableOptions
}
onChange={handleChoiceChange}
description={detailChoiceDesc || ""}
choiceInfo={choiceInfo}
classId={charClass && classUtils.getId(charClass)}
showBackgroundProficiencyOptions={true}
collapseDescription={collapseDescription}
/>
{subchoicesNode}
</div>
);
};

View File

@ -0,0 +1,214 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { Search } from "~/subApps/builder/components/Search";
import Checkbox from "~/tools/js/smartComponents/Checkbox";
import { Accordion } from "../Accordion";
import { Button } from "../Button";
import styles from "./styles.module.css";
export interface FilterButtonData {
label: ReactNode;
type: number | string;
className?: string;
sortOrder?: number;
}
export interface FilterCheckboxData {
label: string;
type: string;
initiallyEnabled: boolean;
onChange: () => void;
className?: string;
}
export interface FilterGroupProps extends HTMLAttributes<HTMLDivElement> {
filterQuery: string;
onQueryChange: (value: string) => void;
searchPlaceholder?: string;
filterButtonData: Array<FilterButtonData>;
activeFilterButtonTypes: Array<number | string>;
onFilterButtonClick: (filterType: number | string) => void;
filterCheckboxData?: Array<FilterCheckboxData>;
sourceCategoryButtonData: Array<FilterButtonData>;
onSourceCategoryClick: (categoryId: number) => void;
activeFilterSourceCategories: Array<number>;
themed?: boolean;
buttonGroupLabel: string;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
shouldOpenFilterButtons?: boolean;
shouldOpenSourceCategoryButtons?: boolean;
onSourceCategoriesCollapse?: () => void;
onFilterButtonsCollapse?: () => void;
}
export const FilterGroup: FC<FilterGroupProps> = ({
filterQuery,
searchPlaceholder = "Search",
filterButtonData,
activeFilterButtonTypes,
onQueryChange,
onFilterButtonClick,
filterCheckboxData,
sourceCategoryButtonData,
onSourceCategoryClick,
activeFilterSourceCategories,
themed,
buttonGroupLabel,
buttonSize = "xx-small",
filterStyle,
shouldOpenFilterButtons,
shouldOpenSourceCategoryButtons,
onSourceCategoriesCollapse,
onFilterButtonsCollapse,
className,
...props
}) => {
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
};
const handleSourceCategoryClick = (evt: React.MouseEvent, id: number) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onSourceCategoryClick(id);
};
const handleFilterButtonClick = (
evt: React.MouseEvent,
type: string | number
) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onFilterButtonClick(type);
};
return (
<div {...props}>
<div className={styles.filterGroup}>
<label htmlFor="filter-input" className={styles.filterLabel}>
Filter
</label>
<Search
id="filter-input"
value={filterQuery}
onChange={handleQueryChange}
placeholder={searchPlaceholder}
/>
</div>
{/* FILTER BUTTONS */}
<div className={styles.filterGroup}>
<Accordion
variant="text"
size="small"
summary={<div className={styles.filterLabel}>{buttonGroupLabel}</div>}
className={styles.accordion}
forceShow={shouldOpenFilterButtons}
onClick={onFilterButtonsCollapse}
>
<div className={styles.buttonGroup}>
{filterButtonData.map((button) => {
return (
<div
className={clsx([button.className, styles.button])}
key={button.type}
>
<Button
color={
filterStyle === "builder" ? "builder-green" : undefined
}
themed={themed}
variant={
activeFilterButtonTypes.includes(button.type)
? "solid"
: "outline"
}
size={buttonSize}
onClick={(evt) => handleFilterButtonClick(evt, button.type)}
>
{button.label}
</Button>
</div>
);
})}
</div>
{filterCheckboxData && filterCheckboxData.length > 0 && (
<div className={styles.checkboxGroup}>
{filterCheckboxData.map((checkbox) => {
return (
<div
className={clsx([
styles.checkbox,
filterStyle === "builder" && styles.builder,
])}
key={checkbox.type}
>
<Checkbox
stopPropagation={true}
initiallyEnabled={checkbox.initiallyEnabled}
onChange={checkbox.onChange}
label={checkbox.label}
/>
</div>
);
})}
</div>
)}
</Accordion>
</div>
{/* SOURCE CATEGORY FILTERS */}
<div className={styles.filterGroup}>
<Accordion
variant="text"
size="small"
summary={
<div className={styles.filterLabel}>Filter By Source Category</div>
}
className={styles.accordion}
forceShow={shouldOpenSourceCategoryButtons}
onClick={onSourceCategoriesCollapse}
>
<div className={styles.buttonGroup}>
{sourceCategoryButtonData.map((button) => {
return (
<div
className={clsx([
button.className,
styles.button,
styles.sourceCategoryButton,
filterStyle === "builder" && styles.builder,
])}
key={button.type}
>
<Button
color={
filterStyle === "builder" ? "builder-green" : undefined
}
themed={themed}
variant={
activeFilterSourceCategories.includes(
button.type as number
)
? "solid"
: "outline"
}
size={buttonSize}
onClick={(evt) =>
handleSourceCategoryClick(evt, button.type as number)
}
>
{button.label}
</Button>
</div>
);
})}
</div>
</Accordion>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { FC } from "react";
import { v4 as uuidv4 } from "uuid";
import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Accordion, AccordionProps } from "../Accordion";
import { HtmlContent } from "../HtmlContent";
import styles from "./styles.module.css";
export interface HelperTextAccordionProps
extends Omit<AccordionProps, "summary"> {
builderHelperText: BuilderHelperTextInfoContract[];
}
export const HelperTextAccordion: FC<HelperTextAccordionProps> = ({
builderHelperText,
...props
}) => {
return (
<>
{builderHelperText.map((helperText, idx) => (
<Accordion
{...props}
key={uuidv4()}
summary={
<span className={styles.summary}>
<DdbBadgeSvg /> {helperText.label}
</span>
}
>
<HtmlContent html={helperText.description} />
</Accordion>
))}
</>
);
};

View File

@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
interface HtmlContentProps extends HTMLAttributes<HTMLDivElement> {
html: string;
className?: string;
withoutTooltips?: boolean;
}
export const HtmlContent: FC<HtmlContentProps> = ({
html,
className = "",
withoutTooltips,
...props
}) => (
<div
className={clsx(["ddbc-html-content", className])}
dangerouslySetInnerHTML={{
__html: withoutTooltips ? FormatUtils.stripTooltipTags(html) : html,
}}
{...props}
/>
);

View File

@ -0,0 +1,35 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { InfoItem as TtuiInfoItem } from "@dndbeyond/ttui/components/InfoItem";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import styles from "./styles.module.css";
interface InfoItemProps extends HTMLAttributes<HTMLElement> {
label: string;
color?: "primary"; // Not needed here, but fixes ts error
inline?: boolean;
}
/**
* This component is an attribute for a given item, spell, or other entity.
* The InfoItemList component is a customized version of the InfoItem component from
* the @dndbeyond/ttui library.
**/
export const InfoItem: FC<InfoItemProps> = ({
className,
onClick,
...props
}) => {
const handleClick = useUnpropagatedClick(onClick);
return (
<TtuiInfoItem
className={clsx([styles.item, className])}
onClick={onClick ? handleClick : undefined}
{...props}
/>
);
};

View File

@ -0,0 +1,152 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useFiltersContext } from "~/contexts/Filters";
import { SimpleSourceCategoryContract } from "~/types";
import { FilterGroup } from "../FilterGroup";
import {
FilterButtonData,
FilterCheckboxData,
} from "../FilterGroup/FilterGroup";
import styles from "./styles.module.css";
export interface ItemFilterProps extends HTMLAttributes<HTMLDivElement> {
filterQuery: string;
onQueryChange: (value: string) => void;
filterTypes: Array<string>;
onFilterButtonClick: (type: string) => void;
onCheckboxChange: (type: string) => void;
sourceCategories: Array<SimpleSourceCategoryContract>;
onSourceCategoryClick: (categoryId: number) => void;
filterSourceCategories: Array<number>;
filterProficient: boolean;
filterBasic: boolean;
filterMagic: boolean;
filterContainer: boolean;
themed?: boolean;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
}
export const ItemFilter: FC<ItemFilterProps> = ({
filterQuery,
onQueryChange,
filterTypes,
onFilterButtonClick,
onCheckboxChange,
sourceCategories,
onSourceCategoryClick,
filterSourceCategories,
filterProficient,
filterBasic,
filterMagic,
filterContainer,
themed,
buttonSize = "xx-small",
filterStyle,
className,
...props
}) => {
const {
showItemTypes,
showItemSourceCategories,
setShowItemSourceCategories,
setShowItemTypes,
} = useFiltersContext();
const itemTypes: Array<string> = [
"Armor",
"Potion",
"Ring",
"Rod",
"Scroll",
"Staff",
"Wand",
"Weapon",
"Wondrous item",
"Other Gear",
];
const toggleTypes: Array<string> = [
"Proficient",
"Common",
"Magical",
"Container",
];
const isChecked = (type: string): boolean => {
switch (type) {
case "Proficient":
return filterProficient;
case "Common":
return filterBasic;
case "Magical":
return filterMagic;
case "Container":
return filterContainer;
default:
return false;
}
};
const filterCheckboxData: Array<FilterCheckboxData> = toggleTypes.map(
(type) => {
return {
label: type,
type: type,
onChange: () => onCheckboxChange(type),
initiallyEnabled: isChecked(type),
};
}
);
const filterButtons: Array<FilterButtonData> = itemTypes.map((itemType) => {
return {
type: itemType,
label: itemType === "Wondrous item" ? "Wondrous" : itemType,
className: clsx([
styles.filterButton,
buttonSize === "xx-small" && styles.filterButtonSmall,
]),
};
});
const sourcesData: Array<FilterButtonData> = sourceCategories.map(
(sourceCategory) => {
return {
label: sourceCategory.name,
type: sourceCategory.id,
className: styles.sourceCategoryButton,
sortOrder: sourceCategory.sortOrder,
};
}
);
return (
<FilterGroup
searchPlaceholder={"Weapon, Longsword, Vorpal Longsword, etc."}
filterQuery={filterQuery}
filterButtonData={filterButtons}
activeFilterButtonTypes={filterTypes}
onQueryChange={onQueryChange}
themed={themed}
onFilterButtonClick={onFilterButtonClick}
onSourceCategoryClick={onSourceCategoryClick}
activeFilterSourceCategories={filterSourceCategories}
sourceCategoryButtonData={sourcesData}
buttonGroupLabel={"Filter By Type"}
buttonSize={buttonSize}
filterStyle={filterStyle}
filterCheckboxData={filterCheckboxData}
shouldOpenSourceCategoryButtons={showItemSourceCategories}
shouldOpenFilterButtons={showItemTypes}
onFilterButtonsCollapse={() => setShowItemTypes(!showItemTypes)}
onSourceCategoriesCollapse={() =>
setShowItemSourceCategories(!showItemSourceCategories)
}
className={className}
{...props}
/>
);
};

View File

@ -0,0 +1,109 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import {
Item,
Constants,
ItemUtils,
} from "@dndbeyond/character-rules-engine/es";
import { AttunementIcon } from "~/tools/js/smartComponents/Icons";
import { LegacyBadge } from "../LegacyBadge";
import { Tooltip } from "../Tooltip";
import styles from "./styles.module.css";
/**
* Component to display the name of an item with an attunement icon and a color
* to identify the rarity of the object. It is used in the equipment tab on the
* character sheet as well as the manage inventory pane.
*/
interface ItemNameProps extends HTMLAttributes<HTMLSpanElement> {
item: Item;
className?: string;
onClick?: () => void;
showAttunement?: boolean;
showLegacy?: boolean;
showLegacyBadge?: boolean;
}
export const ItemName: FC<ItemNameProps> = ({
showAttunement = true,
showLegacy = false,
showLegacyBadge,
className,
onClick,
item,
...props
}) => {
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClick();
}
};
const getDisplayName = () => {
const name = ItemUtils.getName(item);
if (!name) return ItemUtils.getDefinitionName(item);
if (!name) return "Item";
return name;
};
const getItemRarity = () => {
switch (ItemUtils.getRarity(item)) {
case Constants.ItemRarityNameEnum.ARTIFACT:
return "artifact";
case Constants.ItemRarityNameEnum.LEGENDARY:
return "legendary";
case Constants.ItemRarityNameEnum.VERY_RARE:
return "veryrare";
case Constants.ItemRarityNameEnum.RARE:
return "rare";
case Constants.ItemRarityNameEnum.UNCOMMON:
return "uncommon";
case Constants.ItemRarityNameEnum.COMMON:
default:
return "common";
}
};
const tooltipId = `itemName-${uuidv4()}`;
return (
<>
<span
className={clsx([styles.itemName, styles[getItemRarity()], className])}
onClick={handleClick}
{...props}
>
{getDisplayName()}
{item.isCustomized && (
<>
<span
className={styles.asterisk}
data-tooltip-id={tooltipId}
data-tooltip-content="Item is Customized"
>
*
</span>
<Tooltip id={tooltipId} />
</>
)}
{showAttunement && item.isAttuned && (
<span className={styles.icon}>
<AttunementIcon />
</span>
)}
{showLegacy && ItemUtils.isLegacy(item) && (
<span className={styles.legacy}>(Legacy)</span>
)}
</span>
{showLegacyBadge && ItemUtils.isLegacy(item) && (
<LegacyBadge variant="margin-left" />
)}
</>
);
};

View File

@ -0,0 +1,42 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useMatch } from "react-router-dom";
import { Footer } from "@dndbeyond/ttui/components/Footer";
import { MegaMenu } from "@dndbeyond/ttui/components/MegaMenu";
import { Sitebar } from "@dndbeyond/ttui/components/Sitebar";
import config from "~/config";
import useUser from "~/hooks/useUser";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface LayoutProps extends HTMLAttributes<HTMLDivElement> {}
export const Layout: FC<LayoutProps> = ({ children, ...props }) => {
const isDev = process.env.NODE_ENV === "development";
const user = useUser();
const matchSheet = useMatch(`${BASE_PATHNAME}/:characterId/`);
// Don't show the navigation in production
if (!isDev) return <>{children}</>;
return (
<div {...props}>
{/* TODO: fetch navItems */}
<div className={styles.siteStyles}>
<Sitebar user={user as any} navItems={[]} />
{/* TODO: fetch sources */}
<MegaMenu sources={[]} />
</div>
<div className={clsx(["container", styles.devContainer])}>{children}</div>
{!matchSheet && (
<div className={styles.siteStyles}>
<Footer />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,41 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
import { Link } from "../Link";
import styles from "./styles.module.css";
interface LegacyBadgeProps extends HTMLAttributes<HTMLDivElement> {
variant?: "margin-left";
}
export const LegacyBadge: FC<LegacyBadgeProps> = ({
variant,
className,
...props
}) => (
<LabelChip
className={clsx([styles.chip, variant && styles[variant], className])}
data-tooltip-id={`legacybadge_${uuidv4()}`}
tooltipContent={
<span>
This doesn't reflect the latest rules and lore.{" "}
<Link
className={styles.tooltipLink}
href="https://dndbeyond.com/legacy"
target="_blank"
rel="noopener noreferrer"
onClick={() => {}} //TODO - can fix this after we refactor Link to not stop propgation inside the component
>
Learn More
</Link>
</span>
}
tooltipClickable
{...props}
>
Legacy
</LabelChip>
);

View File

@ -0,0 +1,52 @@
import clsx from "clsx";
import { AnchorHTMLAttributes, FC } from "react";
import { Link as RouterLink } from "react-router-dom";
interface LinkProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "onClick"> {
onClick?: Function;
useTheme?: boolean;
useRouter?: boolean;
}
export const Link: FC<LinkProps> = ({
children,
className,
href,
onClick,
useTheme,
useRouter,
...props
}) => {
//TODO - refactor to handle stop propagation on the oustide of this component (about 7 or so files to change)
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClick();
}
};
if (useRouter)
return (
<RouterLink
className={className}
onClick={handleClick}
to={href || ""}
{...props}
>
{children}
</RouterLink>
);
return (
<a
className={clsx(["ddbc-link", useTheme && "ddbc-theme-link", className])}
onClick={handleClick}
href={href}
{...props}
>
{children}
</a>
);
};

View File

@ -0,0 +1,65 @@
import { HTMLAttributes, useEffect, useRef } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import { MaxCharactersMessageText } from "../MaxCharactersMessageText";
import styles from "./styles.module.css";
interface MaxCharactersDialogProps extends HTMLAttributes<HTMLDialogElement> {
open: boolean;
onClose: () => void;
useMyCharactersLink?: boolean;
}
export const MaxCharactersDialog: React.FC<MaxCharactersDialogProps> = ({
open,
onClose,
useMyCharactersLink,
...props
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const handleClickBackdrop = (e) => {
const rect = dialogRef.current?.getBoundingClientRect();
const isClickInDialog =
rect &&
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!isClickInDialog) (dialogRef.current as any)?.close();
};
useEffect(() => {
if (open) {
// Hacky workaround since showModal() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.showModal();
} else {
// Hacky workaround since close() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.close();
}
}, [onClose, open]);
return (
<dialog
className={styles.maxCharactersDialog}
ref={dialogRef}
onClick={handleClickBackdrop}
{...props}
>
<header>
<h2 className={styles.dialogTitle}>Your character slots are full</h2>
</header>
<MaxCharactersMessageText useMyCharactersLink={useMyCharactersLink} />
<footer className={styles.dialogFooter}>
<Button href="/store/subscribe" variant="outline">
Subscribe
</Button>
<Button onClick={onClose}>Close</Button>
</footer>
</dialog>
);
};

View File

@ -0,0 +1,47 @@
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface MaxCharactersMessageTextProps extends HTMLAttributes<HTMLDivElement> {
includeTitle?: boolean;
useLinks?: boolean;
useMyCharactersLink?: boolean;
}
export const MaxCharactersMessageText: FC<MaxCharactersMessageTextProps> = ({
includeTitle,
useLinks,
useMyCharactersLink,
...props
}) => {
const subscribe = useLinks ? (
<a className={styles.link} href="/store/subscribe">
Subscribe
</a>
) : (
"Subscribe"
);
const myCharacters =
useMyCharactersLink || useLinks ? (
<a className={styles.link} href="/characters">
My Characters
</a>
) : (
"My Characters"
);
return (
<div {...props}>
{includeTitle && (
<p className={styles.messageTitle}>
Oops! Your character slots are full.
</p>
)}
<p className={styles.messageSubtext}>
You&apos;re quite the adventurer! {subscribe} to unlock additional
slots, or return to {myCharacters} and delete a character to make room.
</p>
</div>
);
};

View File

@ -0,0 +1,117 @@
import clsx from "clsx";
import {
forwardRef,
HTMLAttributes,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { createPortal } from "react-dom";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import { Toast } from "@dndbeyond/ttui/components/Toast";
import { Notification } from "~/tools/js/Shared/stores/typings";
import styles from "./styles.module.css";
export interface NotificationSystemHandlers {
addNotification: (notification: Notification) => void;
removeNotification: (notification: Notification) => void;
}
const timeoutDuration = 5000;
interface NotificationSystemProps
extends Omit<HTMLAttributes<HTMLElement>, "open" | "contentEditable"> {}
/**
* NotificationSystem is a component that manages the display of notifications
* in toast messages which appear in the character sheet.
*/
export const NotificationSystem = forwardRef<
HTMLDialogElement,
NotificationSystemProps
>(({ className, ...props }, ref) => {
const [isOpen, setIsOpen] = useState(true);
const [notifications, setNotifications] = useState<Array<Notification>>([]);
const [notificationTimeoutHandle, setNotificationTimeoutHandle] = useState<
number | undefined
>();
const [portal, setPortal] = useState<HTMLDivElement | null>(null);
const addNotification = (notification) => {
if (notifications.filter((n) => n.uid === notification.uid).length === 0) {
setNotifications([...notifications, notification]);
}
clearTimeout(notificationTimeoutHandle);
(function openNotification() {
if (!isOpen) {
setIsOpen(true);
} else {
setNotificationTimeoutHandle(() => {
const handle = window.setTimeout(() => {
openNotification();
}, timeoutDuration + 100);
return handle;
});
}
})();
};
const removeNotification = (notification) => {
notifications[0].onRemove?.();
setNotifications(notifications.filter((n) => n.uid !== notification.uid));
};
useImperativeHandle<any, NotificationSystemHandlers>(
ref,
(): NotificationSystemHandlers => ({
addNotification,
removeNotification,
})
);
const handleClose = () => {
setIsOpen(false);
removeNotification(notifications[0]);
};
useEffect(() => {
const portalEl = document.createElement("div");
portalEl.classList.add("ct-notification__portal");
portalEl.style.zIndex = "100000";
portalEl.style.position = "fixed";
document.body.appendChild(portalEl);
setPortal(portalEl);
return () => {
document.body.removeChild(portalEl);
};
}, []);
return notifications[0] && portal
? createPortal(
<Toast
className={clsx([
styles.toast,
notifications[0].level && styles[notifications[0].level],
])}
open={isOpen}
onClose={handleClose}
autoHideDuration={timeoutDuration}
{...props}
>
<button
className={styles.closeButton}
onClick={handleClose}
aria-label="Dismiss notification"
>
<XMark className={styles.closeIcon} />
</button>
<p className={styles.title}>{notifications[0].title}</p>
<p className={styles.message}>{notifications[0].message}</p>
</Toast>,
portal
)
: null;
});

View File

@ -0,0 +1,95 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import { NumberDisplayType } from "~/types";
import styles from "./styles.module.css";
const { FEET_IN_MILES } = Constants;
export interface NumberDisplayProps extends HTMLAttributes<HTMLSpanElement> {
number: number | null;
type: NumberDisplayType;
isModified?: boolean;
size?: "large";
numberFallback?: ReactNode;
}
export const NumberDisplay: FC<NumberDisplayProps> = ({
number,
type,
isModified = false,
size,
numberFallback = "--",
className,
...props
}) => {
let label = "";
let sign = "";
switch (type) {
case "weightInLb":
label = "lb.";
number = number && Math.round((number + Number.EPSILON) * 100) / 100;
break;
case "distanceInFt":
if (number && number % FEET_IN_MILES === 0) {
number = number / FEET_IN_MILES;
label = `mile${Math.abs(number) === 1 ? "" : "s"}`;
} else {
label = "ft.";
}
break;
case "signed":
if (number != null) {
sign = number >= 0 ? "+" : "-";
number = Math.abs(number);
}
break;
default:
break;
}
return (
<span
className={clsx([
styles.numberDisplay,
type === "signed" && styles.signed,
type === "distanceInFt" && size && styles[size + "Distance"],
isModified && styles.modified,
size && styles[size],
className,
])}
{...props}
>
{type === "signed" && (
/* NOTE: aria-label needs to go on an interactable element, not a span.
This will need to be refactored onto the containing button.
*/
<span
className={clsx([
styles.sign,
size && styles[size + "Sign"],
styles.labelSignColor,
])}
aria-label={sign === "+" ? "plus" : "minus"}
>
{sign}
</span>
)}
<span>{number === null ? numberFallback : number}</span>
{type !== "signed" && (
<span
className={clsx([
styles.label,
size && styles[size + "Label"],
styles.labelSignColor,
])}
>
{label}
</span>
)}
</span>
);
};

View File

@ -0,0 +1,121 @@
import clsx from "clsx";
import React, {
FC,
ReactElement,
ReactNode,
cloneElement,
useEffect,
} from "react";
import { Dialog } from "@dndbeyond/ttui/components/Dialog";
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import styles from "./styles.module.css";
/**
* This is a popover component to handle dialogs that are dependent on a location from a trigger element.
* It uses the useIsVisible hook to handle the visibility of the dialog. The position of the dialog
* is determined by the position prop, which can be set to topLeft, topRight, left, right, bottomLeft, or bottomRight.
* The maxWidth prop can be used to set the maximum width of the dialog. The trigger prop is the element that will trigger
* the dialog to open.The popover component also handles the positioning of the dialog based on the trigger element. The
* handleToggle function toggles the visibility of the dialog, and the handleClose function closes the dialog. The popover
* component also adds an event listener to the cancel button in the dialog to close the dialog when clicked.
*
* In order for a cancel button to work, it must have a `data-cancel` attribute.
**/
interface PopoverProps {
className?: string;
children?: ReactNode;
position?:
| "topLeft"
| "topRight"
| "left"
| "right"
| "bottomLeft"
| "bottomRight"
| "bottom";
trigger: ReactElement;
maxWidth?: number;
variant?: "default" | "menu";
"data-testid"?: string;
}
export const Popover: FC<PopoverProps> = ({
children,
className,
maxWidth = 250,
position = "bottomRight",
trigger,
variant = "default",
...props
}) => {
const { ref: popoverRef, isVisible, setIsVisible } = useIsVisible(false);
const { isDarkMode } = useCharacterTheme();
const handleToggle = (e: MouseEvent) => {
e?.stopPropagation();
setIsVisible(!isVisible);
};
const handleClose = (e: MouseEvent) => {
e?.stopPropagation();
setIsVisible(false);
};
// Make a copy of the trigger element with an added `onClick` event listener
const triggerWithHandlers = cloneElement(trigger, {
onClick: (e: MouseEvent) => handleToggle(e),
});
useEffect(() => {
// Check to see if the popover has a cancel button based on `data-cancel` attribute
const cancelButton = popoverRef.current?.querySelector("[data-cancel]");
// If there is a cancel button, add an event listener to close the popover
if (cancelButton) {
cancelButton.addEventListener("click", handleClose);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popoverRef.current]);
const mapChildrenWithProps = () => {
// Check if children is a ReactElement
if (React.isValidElement(children)) {
// Create a copy of the ReactElement and attach handleClose function to it
return cloneElement(children as ReactElement, {
handleClose,
});
}
// If not just return the children - means its a primitive type
else {
return children;
}
};
return (
<div
className={clsx([styles.popoverContainer, isDarkMode && styles.dark])}
ref={popoverRef}
>
{triggerWithHandlers}
<Dialog
className={clsx([
styles.popover,
styles[variant],
styles[position],
className,
])}
style={{ maxWidth }}
open={isVisible}
onClose={(e) => handleClose(e as any)} //have to do any because of type mismatch for events
onClick={(e) => e.stopPropagation()}
{...props}
>
{mapChildrenWithProps()}
</Dialog>
</div>
);
};

View File

@ -0,0 +1,54 @@
import { FC, ReactNode } from "react";
import { Button } from "../Button";
import styles from "./styles.module.css";
interface PopoverContentProps {
title?: string;
content: string;
confirmText?: ReactNode;
onConfirm?: () => void;
withCancel?: boolean;
handleClose?: () => void;
}
export const PopoverContent: FC<PopoverContentProps> = ({
title,
content,
confirmText = "Confirm",
onConfirm,
withCancel,
handleClose,
}) => (
<>
{title && <header className={styles.deletePopoverHeader}>{title}</header>}
<div className={styles.deletePopoverContent}>{content}</div>
<footer className={styles.deletePopoverFooter}>
{withCancel && (
<Button
size="x-small"
variant="outline"
color="secondary"
themed
data-cancel
>
Cancel
</Button>
)}
{onConfirm && (
<Button
onClick={() => {
if (handleClose) handleClose();
if (onConfirm) onConfirm();
}}
size="x-small"
variant="outline"
color="secondary"
themed
>
{confirmText}
</Button>
)}
</footer>
</>
);

View File

@ -0,0 +1,32 @@
import clsx from "clsx";
import { CharacterStatusSlug } from "@dndbeyond/character-rules-engine/es";
import styles from "./styles.module.css";
interface PremadeCharacterEditStatusProps {
characterStatus: CharacterStatusSlug | null;
isReadonly: boolean;
isBuilderView?: boolean;
}
export const PremadeCharacterEditStatus = ({
characterStatus,
isReadonly,
isBuilderView,
}: PremadeCharacterEditStatusProps) => {
return (
<>
{characterStatus === CharacterStatusSlug.PREMADE && !isReadonly && (
<div
className={clsx([
styles.editStateIndicator,
isBuilderView && styles.editStateIndicatorBuilder,
])}
>
Premade <strong>EDIT</strong> mode <strong>ENABLED</strong>
</div>
)}
</>
);
};

View File

@ -0,0 +1,56 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import { Tooltip } from "../Tooltip";
import styles from "./styles.module.css";
export interface ReferenceProps extends HTMLAttributes<HTMLDivElement> {
name: string | null;
tooltip?: string | null;
page?: number | null;
chapter?: number | null;
isDarkMode?: boolean;
}
/**
* This component acts as a reference to a specific page or chapter in a book
* where the information can be found.
*/
export const Reference: FC<ReferenceProps> = ({
chapter,
className,
isDarkMode,
name,
page,
tooltip,
...props
}) => {
const id = uuidv4();
const reference: string[] = [];
if (chapter) reference.push(`ch. ${chapter}`);
if (page) reference.push(`pg. ${page}`);
return (
<>
<p
className={clsx([styles.reference, className])}
data-tooltip-id={id}
data-tooltip-content={tooltip}
data-tooltip-delay-show={1500}
{...props}
>
<span className={styles.name}>{name}</span>
{reference.length > 0 && (
<span className={styles.ref}>, {reference.join(", ")}</span>
)}
</p>
<Tooltip
className={styles.tooltip}
id={id}
variant={isDarkMode ? "light" : "dark"}
/>
</>
);
};

View File

@ -0,0 +1,129 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useFiltersContext } from "~/contexts/Filters";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { SimpleSourceCategoryContract, Spell } from "~/types";
import { FilterGroup } from "../FilterGroup";
import { FilterButtonData } from "../FilterGroup/FilterGroup";
import styles from "./styles.module.css";
export interface SpellFilterProps extends HTMLAttributes<HTMLDivElement> {
spells: Array<Spell>;
filterQuery: string;
filterLevels: Array<number>;
onQueryChange: (value: string) => void;
onLevelFilterClick: (level: number) => void;
sourceCategories: Array<SimpleSourceCategoryContract>;
onSourceCategoryClick: (categoryId: number) => void;
filterSourceCategories: Array<number>;
themed?: boolean;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
}
export const SpellFilter: FC<SpellFilterProps> = ({
spells,
filterQuery,
filterLevels,
onQueryChange,
onLevelFilterClick,
sourceCategories,
onSourceCategoryClick,
filterSourceCategories,
themed,
buttonSize = "xx-small",
filterStyle,
className,
...props
}) => {
const { ruleData, spellUtils, ruleDataUtils, formatUtils } =
useCharacterEngine();
const {
showSpellLevels,
showSpellSourceCategories,
setShowSpellLevels,
setShowSpellSourceCategories,
} = useFiltersContext();
const [spellLevels, setSpellLevels] = useState<Array<number>>([]);
useEffect(() => {
const activeSpellLevelCounts: Array<number> = [];
const spellsLevels: Array<number> = [];
const maxSpellLevel = ruleDataUtils.getMaxSpellLevel(ruleData);
for (let i = 0; i <= maxSpellLevel; i++) {
activeSpellLevelCounts.push(0);
spellsLevels.push(i);
}
spells.forEach((spell) => {
activeSpellLevelCounts[spellUtils.getLevel(spell)] += 1;
});
setSpellLevels(
spellsLevels.filter((level) => activeSpellLevelCounts[level] !== 0)
);
}, [spells]);
const filterButtons: Array<FilterButtonData> = spellLevels.map((level) => {
let buttonLabel: React.ReactNode;
if (level === 0) {
buttonLabel = formatUtils.renderSpellLevelAbbreviation(0);
} else {
buttonLabel = (
<div className={styles.buttonText}>
<span>{level}</span>
<span
className={clsx([
styles.ordinal,
buttonSize === "xx-small" && styles.ordinalSmall,
])}
>
{formatUtils.getOrdinalSuffix(level)}
</span>
</div>
);
}
return { type: level, label: buttonLabel, className: styles.filterButton };
});
const sourcesData: Array<FilterButtonData> = sourceCategories.map(
(sourceCategory) => {
return {
label: sourceCategory.name,
type: sourceCategory.id,
className: styles.sourceCategoryButton,
sortOrder: sourceCategory.sortOrder,
};
}
);
return (
<FilterGroup
searchPlaceholder={"Enter Spell Name"}
filterQuery={filterQuery}
filterButtonData={filterButtons}
activeFilterButtonTypes={filterLevels}
onQueryChange={onQueryChange}
onSourceCategoryClick={onSourceCategoryClick}
activeFilterSourceCategories={filterSourceCategories}
sourceCategoryButtonData={sourcesData}
themed={themed}
onFilterButtonClick={onLevelFilterClick}
buttonGroupLabel={"Filter By Spell Level"}
buttonSize={buttonSize}
filterStyle={filterStyle}
shouldOpenSourceCategoryButtons={showSpellSourceCategories}
shouldOpenFilterButtons={showSpellLevels}
onFilterButtonsCollapse={() => setShowSpellLevels(!showSpellLevels)}
onSourceCategoriesCollapse={() =>
setShowSpellSourceCategories(!showSpellSourceCategories)
}
className={className}
{...props}
/>
);
};

View File

@ -0,0 +1,126 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
SpellUtils,
EntityUtils,
FormatUtils,
BaseSpell,
DataOriginRefData,
characterEnvSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { ConcentrationIcon, RitualIcon } from "../../tools/js/smartComponents/";
import { LegacyBadge } from "../LegacyBadge";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLSpanElement> {
onClick?: () => void;
spell: BaseSpell;
showSpellLevel?: boolean;
showIcons?: boolean;
showExpandedType?: boolean;
showLegacy?: boolean;
showLegacyBadge?: boolean;
dataOriginRefData?: DataOriginRefData | null;
}
export const SpellName: FC<Props> = ({
onClick,
spell,
showSpellLevel = true,
showExpandedType = false,
showIcons = true,
dataOriginRefData = null,
showLegacy = false,
showLegacyBadge = false,
className,
...props
}) => {
const [expandedInfoText, setExpandedInfoText] = useState("");
const location = useSelector(characterEnvSelectors.getContext);
const { isDarkMode } = useCharacterTheme();
const getIconTheme = () => {
if (location === "BUILDER") return "dark";
return isDarkMode ? "gray" : "dark";
};
const handleClick = useUnpropagatedClick(onClick);
useEffect(() => {
if (dataOriginRefData) {
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
if (expandedDataOriginRef === null) {
setExpandedInfoText("");
} else {
setExpandedInfoText(
EntityUtils.getDataOriginRefName(
expandedDataOriginRef,
dataOriginRefData
)
);
}
}
}, []);
return (
<>
<span
className={styles.spellName}
onClick={onClick ? handleClick : undefined}
{...props}
>
{showExpandedType && expandedInfoText !== "" && (
<Tooltip
title={expandedInfoText}
className={styles.expanded}
isDarkMode={isDarkMode}
>
+
</Tooltip>
)}
{SpellUtils.getName(spell)}
{SpellUtils.isCustomized(spell) && (
<Tooltip
title="Spell is Customized"
className={styles.customized}
isDarkMode={isDarkMode}
>
*
</Tooltip>
)}
{showIcons && SpellUtils.getConcentration(spell) && (
<ConcentrationIcon
className={styles.icon}
themeMode={getIconTheme()}
/>
)}
{showIcons && SpellUtils.isRitual(spell) && (
<RitualIcon
className={clsx([styles.icon])}
themeMode={getIconTheme()}
/>
)}
{showSpellLevel && (
<span className={styles.level}>
({FormatUtils.renderSpellLevelShortName(SpellUtils.getLevel(spell))}
)
</span>
)}
{showLegacy && SpellUtils.isLegacy(spell) && (
<span className={styles.legacy}> Legacy</span>
)}
</span>
{showLegacyBadge && SpellUtils.isLegacy(spell) && (
<LegacyBadge variant="margin-left" />
)}
</>
);
};

View File

@ -0,0 +1,26 @@
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface SummaryListProps extends HTMLAttributes<HTMLDivElement> {
title: string;
list: Array<string>;
}
export const SummaryList: FC<SummaryListProps> = ({
title,
list,
...props
}) => {
if (!list.length) {
return null;
}
return (
<div {...props}>
<p className={styles.title}>
{title}: <span className={styles.listItems}>{list.join(", ")}</span>
</p>
</div>
);
};

View File

@ -0,0 +1,11 @@
import { useSwipeable } from "react-swipeable";
export const Swipeable = ({
handlerFunctions,
deltaOverride = 75,
...props
}) => {
const handlers = useSwipeable({ ...handlerFunctions, delta: deltaOverride });
return <div className="swipeable" {...handlers} {...props}></div>;
};

View File

@ -0,0 +1,98 @@
import clsx from "clsx";
import { FC, Fragment, HTMLAttributes, ReactNode, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import styles from "./styles.module.css";
export interface TabFilterProps extends HTMLAttributes<HTMLDivElement> {
filters:
| {
label: ReactNode;
content: ReactNode;
badge?: ReactNode;
}[];
showAllTab?: boolean;
sharedChildren?: ReactNode;
}
export const TabFilter: FC<TabFilterProps> = ({
children,
className,
filters,
sharedChildren,
showAllTab = true,
...props
}) => {
const [activeFilter, setActiveFilter] = useState(0);
const { isDarkMode } = useCharacterTheme();
const activeIndex = showAllTab ? activeFilter - 1 : activeFilter;
const nonEmptyFilters = filters.filter((f) => f.label !== "");
const getDefaultLabel = (label: ReactNode) => {
if (typeof label === "string") return label;
return uuidv4();
};
const getAllContent = () => {
return (
nonEmptyFilters
// Remove optional tabs with duplicate content
.filter(
(f) =>
// Don't render tabs with icons such as ritual or concentration spells
typeof f.label === "string" &&
// Don't render tabs with the same content as the Attack or Limited Use tab
!getDefaultLabel(f.label).match(/(Attack|Limited Use)/)
)
// Return the content of the remaining tabs to be rendered
.map((f) => (
<Fragment key={getDefaultLabel(f.label)}>{f.content}</Fragment>
))
);
};
return (
<div
className={clsx([styles.tabFilter, isDarkMode && styles.dark, className])}
{...props}
>
<div className={styles.buttons} data-testid="tab-filters">
{showAllTab && (
<button
className={clsx([activeFilter === 0 && styles.active])}
onClick={() => setActiveFilter(0)}
data-testid="tab-filter-all"
>
All
</button>
)}
{nonEmptyFilters.map((filter, i) => {
const index = showAllTab ? i + 1 : i;
return (
<button
className={clsx([activeFilter === index && styles.active])}
onClick={() => setActiveFilter(index)}
data-testid={`tab-filter-${getDefaultLabel(filter.label)
.toLowerCase()
.replace(/\s/g, "-")}`}
key={`${index} + ${filter.label as string}`}
>
{filter.badge && (
<span className={styles.badge}>{filter.badge}</span>
)}
{filter.label}
</button>
);
})}
</div>
<div className={styles.content}>
{sharedChildren}
{!showAllTab || activeFilter !== 0
? nonEmptyFilters[activeIndex]?.content
: getAllContent()}
</div>
</div>
);
};

View File

@ -0,0 +1,107 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode, useState } from "react";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import { Popover } from "../Popover";
import styles from "./styles.module.css";
interface TabItem {
label: ReactNode;
content: ReactNode;
className?: string;
id: string;
}
interface TabListProps extends HTMLAttributes<HTMLElement> {
tabs: Array<TabItem | null>;
variant?: "default" | "collapse" | "toggle";
defaultActiveId?: string;
maxNavItemsShow?: number;
}
export const TabList: FC<TabListProps> = ({
tabs,
defaultActiveId,
maxNavItemsShow = 7,
variant = "default",
...props
}) => {
const [activeTab, setActiveTab] = useState(
defaultActiveId ? defaultActiveId : tabs[0]?.id
);
const [isTabVisible, setIsTabVisible] = useState(activeTab !== "");
const handleTabClick = (id: string) => {
if (id === activeTab && variant === "collapse") {
setIsTabVisible(!isTabVisible);
}
if (id === activeTab && variant === "toggle") {
setActiveTab("");
}
if (id !== activeTab) {
setActiveTab(id);
setIsTabVisible(true);
}
};
const tabsWithContent = tabs.filter((tab) => tab);
const visibleTabs = tabsWithContent.slice(0, maxNavItemsShow);
const hiddenTabs = tabsWithContent.slice(maxNavItemsShow);
const selectedTab = tabs.find((tab) => tab?.id === activeTab);
return (
<div {...props}>
<menu className={styles.tabs}>
{/* List of visible tabs */}
{visibleTabs.map((tab: TabItem) => (
<li key={tab.id}>
<button
className={styles.tabButton}
role="radio"
aria-checked={activeTab === tab.id}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
{variant !== "default" && (
<ChevronDown className={!isTabVisible ? styles.closed : ""} />
)}
</button>
</li>
))}
{/* Toggle and menu for hidden tabs */}
{hiddenTabs.length > 0 && (
<li>
<Popover
className={styles.morePopover}
variant="menu"
trigger={<button className={styles.tabButton}>More</button>}
>
<menu className={styles.moreMenu}>
{hiddenTabs.map((tab: TabItem) => (
<li key={tab.id}>
<button
className={styles.tabButton}
role="radio"
aria-checked={activeTab === tab.id}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
</li>
))}
</menu>
</Popover>
</li>
)}
</menu>
{/* Content of the active tab */}
<div className={clsx([styles.tabContent, selectedTab?.className])}>
{isTabVisible && activeTab !== "" && selectedTab?.content}
</div>
</div>
);
};

View File

@ -0,0 +1,30 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface TagGroupProps extends HTMLAttributes<HTMLDivElement> {
label: string;
tags: Array<string>;
}
export const TagGroup: FC<TagGroupProps> = ({
label,
tags,
className,
...props
}) => {
if (!tags.length) return null;
return (
<div className={clsx([styles.tagGroup, className])} {...props}>
<div className={styles.label}>{label}:</div>
<div className={styles.group}>
{tags.map((tag) => (
<div className={styles.tag} key={tag}>
{tag}
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,75 @@
import clsx from "clsx";
import {
forwardRef,
HTMLAttributes,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import styles from "./styles.module.css";
export interface TextareaProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
value?: string;
onInputBlur: (textValue: string) => void;
onInputKeyUp: () => void;
}
/**
* A component to be used as a text area with auto-resizing capabilities. This
* is used in the panes for notes and traits and needs to support text entry as
* well as programmatic updates.
*/
export const Textarea = forwardRef<HTMLDivElement, TextareaProps>(
(
{ className, value, onInputBlur, onInputKeyUp, placeholder, ...props },
forwardedRef
) => {
const [currentValue, setCurrentValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
// Combine the local ref with the forwarded ref
useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement);
const handleChange = (e) => {
const dataSet = e.target.parentNode.dataset;
dataSet.value = e.target.value;
setCurrentValue(e.target.value);
};
const handleBlur = (e) => onInputBlur(e.target.value);
const handleKeyUp = () => onInputKeyUp();
useEffect(() => {
if (ref.current) {
const wrapper = ref.current as HTMLDivElement;
const textarea = wrapper.children[0] as HTMLTextAreaElement;
// Set the data-value attribute to the value
wrapper.dataset.value = value;
// Set the textarea value to the value
textarea.value = value || "";
setCurrentValue(value);
}
}, [value]);
return (
<div
className={clsx([styles.textarea, className])}
ref={ref}
data-value={currentValue}
{...props}
>
<textarea
rows={1}
placeholder={placeholder}
onChange={handleChange}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
value={currentValue}
/>
</div>
);
}
);

View File

@ -0,0 +1,65 @@
import clsx from "clsx";
import { useState, type FC, type HTMLAttributes } from "react";
import styles from "./styles.module.css";
import { useSelector } from "react-redux";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
export interface ToggleProps
extends Omit<HTMLAttributes<HTMLButtonElement>, "onClick"> {
checked?: boolean;
color?: "primary" | "secondary" | "warning" | "error" | "info" | "success" | "themed";
onClick?: (isEnabled: boolean) => void;
onChangePromise?: (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
disabled?: boolean;
}
export const Toggle: FC<ToggleProps> = ({
checked,
className,
color = "primary",
onClick,
onChangePromise,
...props
}) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const [enabled, setEnabled] = useState(checked);
const handleClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (onChangePromise) {
onChangePromise(
!enabled,
() => {
setEnabled(!enabled);
},
() => {}
);
} else if (onClick) {
onClick(!enabled);
setEnabled(!enabled);
}
}
};
return (
<button
className={clsx([
styles.toggle,
styles[color],
checked && styles.checked,
className,
])}
onClick={handleClick}
aria-pressed={checked}
{...props}
/>
);
};

View File

@ -0,0 +1,23 @@
import clsx from "clsx";
import { FC } from "react";
import {
Tooltip as TTUITooltip,
TooltipProps as TTUITooltipProps,
} from "@dndbeyond/ttui/components/Tooltip";
import styles from "./styles.module.css";
interface TooltipProps extends TTUITooltipProps {}
/**
* This tooltip component is a wrapper around the TTUI Tooltip component to provide styles which match the existing tooltip styles. It should be used in all new components so we can get rid of the tippy.js implementation. The TTUI component uses [React Tooltip](https://react-tooltip.com/) under the hood, so you can find all options [here](https://react-tooltip.com/docs/options).
*/
export const Tooltip: FC<TooltipProps> = ({ className, ...props }) => (
<TTUITooltip
className={clsx([styles.tooltip, className])}
delayShow={300}
{...props}
/>
);

View File

@ -0,0 +1,208 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useMemo, useState } from "react";
import {
CharacterUtils,
FormatUtils,
HelperUtils,
HtmlSelectOption,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import XpBar from "~/tools/js/smartComponents/XpBar";
import { Select } from "~/tools/js/smartComponents/legacy";
import { Button } from "../Button";
import styles from "./styles.module.css";
const XP_CHANGE_TYPE = {
ADD: "ADD",
REMOVE: "REMOVE",
};
interface XpManagerProps extends HTMLAttributes<HTMLDivElement> {
handleXpUpdates: (newXpTotal: number) => void;
shouldReset: boolean;
}
export const XpManager: FC<XpManagerProps> = ({
handleXpUpdates,
shouldReset,
...props
}) => {
const { experienceInfo: xpInfo, ruleData } = useCharacterEngine();
const [xpNew, setXpNew] = useState<number | null>(null);
const [xpChange, setXpChange] = useState<number | null>(null);
const [levelChosen, setLevelChosen] = useState<number | null>(null);
const [changeType, setChangeType] = useState<string>(XP_CHANGE_TYPE.ADD);
const [newXpTotal, setNewXpTotal] = useState<number>(xpInfo.currentLevelXp);
useEffect(() => {
let xpDiff: number = xpChange ?? 0;
if (changeType === XP_CHANGE_TYPE.REMOVE) {
xpDiff *= -1;
}
let newXpTotal: number = xpInfo.currentLevelXp;
if (xpDiff) {
newXpTotal += xpDiff;
} else if (levelChosen !== null) {
newXpTotal = CharacterUtils.deriveCurrentLevelXp(levelChosen, ruleData);
} else if (xpNew !== null) {
newXpTotal = xpNew;
}
newXpTotal = Math.min(
Math.max(0, newXpTotal),
CharacterUtils.deriveMaxXp(ruleData)
);
setNewXpTotal((prev) => {
if (newXpTotal !== prev) {
handleXpUpdates(newXpTotal);
}
return newXpTotal;
});
}, [
xpChange,
changeType,
levelChosen,
xpNew,
xpInfo,
ruleData,
handleXpUpdates,
]);
useEffect(() => {
if (shouldReset) {
setXpNew(null);
setXpChange(null);
setLevelChosen(null);
setChangeType(XP_CHANGE_TYPE.ADD);
setNewXpTotal(xpInfo.currentLevelXp);
}
}, [shouldReset, xpInfo]);
const levelOptions = useMemo(() => {
let levelOptions: Array<HtmlSelectOption> = [];
for (let i = 1; i <= RuleDataUtils.getMaxCharacterLevel(ruleData); i++) {
levelOptions.push({
label: `${i}`,
value: i,
});
}
return levelOptions;
}, [ruleData]);
const displayCurrentLevelXp = useMemo(() => {
return xpInfo.currentLevel === ruleData.maxCharacterLevel
? CharacterUtils.deriveCurrentLevelXp(
ruleData.maxCharacterLevel - 1,
ruleData
)
: xpInfo.currentLevelXp;
}, [xpInfo, ruleData]);
const handleChooseLevel = (value: string): void => {
setXpNew(null);
setXpChange(null);
setLevelChosen(HelperUtils.parseInputInt(value));
};
const handleXpChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setXpNew(null);
setXpChange(HelperUtils.parseInputInt(evt.target.value));
setLevelChosen(null);
};
const handleXpSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setXpNew(HelperUtils.parseInputInt(evt.target.value));
setXpChange(null);
setLevelChosen(null);
};
return (
<div {...props}>
<h2 className={styles.levelTotal}>
Current XP Total:{" "}
{FormatUtils.renderLocaleNumber(xpInfo.currentLevelXp)} (Level{" "}
{xpInfo.currentLevel})
</h2>
<div className={styles.xpBar}>
<XpBar
xp={xpInfo.currentLevelXp}
showCurrentMarker={true}
ruleData={ruleData}
/>
</div>
<div className={styles.currentLevelAmounts}>
<div>{FormatUtils.renderLocaleNumber(displayCurrentLevelXp)}</div>
<div>{FormatUtils.renderLocaleNumber(xpInfo.nextLevelXp)}</div>
</div>
<div className={clsx([styles.controls, styles.withDivider])}>
<div className={styles.control}>
<label id="set-level" className={styles.label}>
Set Level
</label>
<Select
aria-labelledby="set-level"
options={levelOptions}
onChange={handleChooseLevel}
value={levelChosen}
placeholder={"--"}
/>
</div>
<div className={clsx([styles.control, styles.setXp])}>
<label id="set-xp" className={styles.label}>
Set XP
</label>
<input
aria-labelledby="set-xp"
type="number"
value={xpNew === null ? "" : xpNew}
onChange={handleXpSet}
/>
</div>
<div className={styles.adjustGroup}>
<div className={styles.adjustTabs}>
<Button
variant="text"
size="xx-small"
className={clsx([
styles.tab,
changeType === XP_CHANGE_TYPE.ADD && styles.active,
])}
onClick={() => setChangeType(XP_CHANGE_TYPE.ADD)}
>
Add Xp
</Button>
<Button
variant="text"
size="xx-small"
className={clsx([
styles.tab,
changeType === XP_CHANGE_TYPE.REMOVE && styles.active,
])}
onClick={() => setChangeType(XP_CHANGE_TYPE.REMOVE)}
>
Remove Xp
</Button>
</div>
<input
type="number"
value={xpChange === null ? "" : xpChange}
onChange={handleXpChange}
placeholder="Type XP Value"
/>
</div>
</div>
<h2 className={clsx([styles.levelTotal, styles.withDivider])}>
New XP Total: {FormatUtils.renderLocaleNumber(newXpTotal)} (Level{" "}
{CharacterUtils.deriveXpLevel(newXpTotal, ruleData)})
</h2>
</div>
);
};

191
ddb_main/config.ts Normal file
View File

@ -0,0 +1,191 @@
const { Constants } = require("@dndbeyond/character-rules-engine/es");
enum EnvTypeEnum {
PRODUCTION = "production",
STAGING = "staging",
TEST = "test",
DRAFT = "draft",
REVIEW = "review",
DEVELOPMENT = "development",
LOCAL_APP_SHELL = "local_app_shell",
}
const envConfigs = {
[EnvTypeEnum.PRODUCTION]: {
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: "https://www.dndbeyond.com",
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
"https://character-service.dndbeyond.com",
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
"https://gamedata-service.dndbeyond.com",
},
gameLogApiEndpoint: "https://www.dndbeyond.com",
ddbApiEndpoint: "https://api.dndbeyond.com",
diceAssetEndpoint: "https://www.dndbeyond.com/dice",
diceApiEndpoint: "https://dice-service.dndbeyond.com",
analyticTrackingId: "UA-26524418-48",
authEndpoint: "https://auth-service.dndbeyond.com/v1/cobalt-token",
env: EnvTypeEnum.PRODUCTION,
ddbMediaUrl: "https://www.dndbeyond.com",
},
[EnvTypeEnum.STAGING]: {
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: "https://stg.dndbeyond.com",
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
"https://character-service-stg.dndbeyond.com",
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
"https://gamedata-service-stg.dndbeyond.com",
},
gameLogApiEndpoint: "https://stg.dndbeyond.com",
ddbApiEndpoint: "https://test-api.dndbeyond.com",
diceAssetEndpoint: "https://stg.dndbeyond.com/dice",
diceApiEndpoint: "https://dice-service-stg.dndbeyond.com",
analyticTrackingId: "UA-26524418-48",
authEndpoint: "https://auth-service-stg.dndbeyond.com/v1/cobalt-token",
env: EnvTypeEnum.STAGING,
ddbMediaUrl: "https://www.dndbeyond.com",
},
[EnvTypeEnum.DRAFT]: {
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: "https://draft.dndbeyond.com",
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
"https://character-service-draft.dndbeyond.com",
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
"https://gamedata-service-draft.dndbeyond.com",
},
gameLogApiEndpoint: "https://stg.dndbeyond.com",
ddbApiEndpoint: "https://test-api.dndbeyond.com",
diceAssetEndpoint: "https://stg.dndbeyond.com/dice",
diceApiEndpoint: "https://dice-service-stg.dndbeyond.com",
analyticTrackingId: "UA-26524418-48",
authEndpoint: "https://auth-service-stg.dndbeyond.com/v1/cobalt-token",
env: EnvTypeEnum.DRAFT,
ddbMediaUrl: "https://www.dndbeyond.com",
},
[EnvTypeEnum.REVIEW]: {
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: "https://review.dndbeyond.com",
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
"https://character-service-review.dndbeyond.com",
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
"https://gamedata-service-review.dndbeyond.com",
},
gameLogApiEndpoint: "https://stg.dndbeyond.com",
ddbApiEndpoint: "https://test-api.dndbeyond.com",
diceAssetEndpoint: "https://stg.dndbeyond.com/dice",
diceApiEndpoint: "https://dice-service-stg.dndbeyond.com",
analyticTrackingId: "UA-26524418-48",
authEndpoint: "https://auth-service-stg.dndbeyond.com/v1/cobalt-token",
env: EnvTypeEnum.REVIEW,
ddbMediaUrl: "https://www.dndbeyond.com",
},
[EnvTypeEnum.DEVELOPMENT]: {
analyticTrackingId: "UA-26524418-48",
env: EnvTypeEnum.DEVELOPMENT,
diceAssetEndpoint: process.env.REACT_APP_DICE_ASSET_URL,
diceApiEndpoint: process.env.REACT_APP_DICE_API_URL,
gameLogApiEndpoint: process.env.REACT_APP_GAMELOG_API_URL,
ddbApiEndpoint: process.env.REACT_APP_DDB_API_URL,
authEndpoint: `${process.env.REACT_APP_DDB_AUTH_URL}`,
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: process.env.REACT_APP_DDB_BASE_URL,
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
process.env.REACT_APP_CHARACTER_SERVICE_URL,
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
process.env.REACT_APP_GAMEDATA_API_URL,
},
ddbMediaUrl: "https://www.dndbeyond.com",
},
[EnvTypeEnum.LOCAL_APP_SHELL]: {
apiInfo: {
[Constants.ApiTypeEnum.WEBSITE]: "http://dev.dndbeyond.com:8080",
[Constants.ApiTypeEnum.CHARACTER_SERVICE]:
"https://character-service-stg.dndbeyond.com",
[Constants.ApiTypeEnum.GAME_DATA_SERVICE]:
"https://gamedata-service-stg.dndbeyond.com",
},
gameLogApiEndpoint: "https://stg.dndbeyond.com",
ddbApiEndpoint: "https://test-api.dndbeyond.com",
diceAssetEndpoint: "https://stg.dndbeyond.com/dice",
diceApiEndpoint: "https://dice-service-stg.dndbeyond.com",
analyticTrackingId: "UA-26524418-48",
authEndpoint: "https://auth-service-stg.dndbeyond.com/v1/cobalt-token",
env: EnvTypeEnum.LOCAL_APP_SHELL,
ddbMediaUrl: "https://www.dndbeyond.com",
},
};
function getWebsite(env: EnvTypeEnum): string {
return envConfigs[env].apiInfo[Constants.ApiTypeEnum.WEBSITE];
}
function getConfig() {
let envKey: EnvTypeEnum = EnvTypeEnum.PRODUCTION;
switch (window.location.origin) {
case getWebsite(EnvTypeEnum.PRODUCTION):
envKey = EnvTypeEnum.PRODUCTION;
break;
case getWebsite(EnvTypeEnum.STAGING):
envKey = EnvTypeEnum.STAGING;
break;
case getWebsite(EnvTypeEnum.DRAFT):
envKey = EnvTypeEnum.DRAFT;
break;
case getWebsite(EnvTypeEnum.REVIEW):
envKey = EnvTypeEnum.REVIEW;
break;
case getWebsite(EnvTypeEnum.DEVELOPMENT):
envKey = EnvTypeEnum.DEVELOPMENT;
break;
case getWebsite(EnvTypeEnum.LOCAL_APP_SHELL):
envKey = EnvTypeEnum.LOCAL_APP_SHELL;
break;
default:
envKey = EnvTypeEnum.PRODUCTION;
break;
}
// this is the config for the current environment
let config: Record<string, any> = {
...envConfigs[envKey],
debug: process.env.NODE_ENV !== "production",
launchDarkylyClientId: process.env.REACT_APP_LAUNCH_DARKLY_CLIENT_ID,
production: process.env.NODE_ENV === "production",
version: process.env.REACT_APP_VERSION,
analyticTrackingId: process.env.REACT_APP_TRACKING_ID,
env: process.env.NODE_ENV,
characterServiceBaseUrl: `${
envConfigs[envKey].apiInfo[Constants.ApiTypeEnum.CHARACTER_SERVICE]
}/character/v5`,
userServiceBaseUrl: `${
envConfigs[envKey].apiInfo[Constants.ApiTypeEnum.CHARACTER_SERVICE]
}/user/v5`,
ddbBaseUrl: envConfigs[envKey].apiInfo[Constants.ApiTypeEnum.WEBSITE],
environment: envKey,
basePathname: "/characters",
};
return config;
}
// Export config items individually
export const {
analyticTrackingId,
env,
diceAssetEndpoint,
diceApiEndpoint,
gameLogApiEndpoint,
ddbApiEndpoint,
authEndpoint,
apiInfo,
ddbMediaUrl,
debug,
launchDarkylyClientId,
production,
version,
characterServiceBaseUrl,
userServiceBaseUrl,
ddbBaseUrl,
environment,
basePathname,
} = getConfig();
// Export the entire config object
export default getConfig();

315
ddb_main/constants.ts Normal file
View File

@ -0,0 +1,315 @@
import { Constants } from "@dndbeyond/character-rules-engine";
import config from "./config";
export const DDB_MEDIA_URL = config.ddbMediaUrl;
/* Naming convention for GA constants: Session_Category_Action */
export const SessionTrackingIds = {
DDB: config.production
? "UA-26524418-48" // DDB Proper Account
: "UA-117447463-5", // Encounter Builder Test Account
};
export const SessionNames = {
DDB: "DnDBeyond", // Session names cannot use spaces or special characters
};
export const SessionTrackingIdByName = {
[SessionNames.DDB]: SessionTrackingIds.DDB,
};
const CategoryPrefix = "Character Listing";
export const EventCategories = {
DDB_Character: `${CategoryPrefix} Character Card`,
DDB_ListingFilter: `${CategoryPrefix} Filter`,
DDB_ListingSort: `${CategoryPrefix} Sort`,
DDB_PlayerAppBanner: `${CategoryPrefix} Player App Banner`,
DDB_Unlock: `${CategoryPrefix} Unlock`,
};
export const EventActions = {
DDB_Character_Campaign: "Campaign",
DDB_Character_Copy: "Copy",
DDB_Character_Delete: "Delete",
DDB_Character_Edit: "Edit",
DDB_Character_LeaveCampaign: "Leave Campaign",
DDB_Character_View: "View",
DDB_ListingFilter_SearchChanged: "Search Changed",
DDB_ListingFilter_SearchCleared: "Search Cleared",
DDB_ListingSort_Changed: "Changed",
DDB_PlayerAppBanner_Dismissed: "Dismissed",
DDB_PlayerAppBanner_ClickedCta: "Clicked CTA",
DDB_Unlock_CharacterLocked: "Character Locked",
DDB_Unlock_CharacterUnlocked: "Character Unlocked",
DDB_Unlock_FinishUnlockingClicked: "Finish Unlocking Clicked",
DDB_Unlock_FinishUnlockingCancelled: "Finish Unlocking Cancelled",
DDB_Unlock_FinishUnlockingConfirmed: "Finish Unlocking Confirmed",
DDB_Unlock_SubscribeClicked: "Subscribe Clicked",
};
export const EventLabels = {
Cancelled: "Cancelled",
Clicked: "Clicked",
Confirmed: "Confirmed",
};
export const InputLimits = {
characterNameMaxLength: 128,
};
export const CharacterNameLimitMsg = `The max length for a name is ${InputLimits.characterNameMaxLength} characters.`;
export const SourceCategoryDescription = {
official:
"The sources below add additional character options beyond the 2024 Core Rules. You will only see character options from the Core Rules and content you own and have enabled here.",
homebrew:
"Character options designed by other players and uploaded to D&D BEYOND. Talk to your DM before including Homebrew content.",
partnered:
"Content developed by our partner publishers. You will only see character options for content you have purchased.",
};
export const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"];
export const FILE_SIZE_3MB = 3 * 1024 * 1024;
/**
* Export constants from the rules engine to reduce the number of places where
* they're being imported.
**/
export const ActionCustomizationAdjustmentTypes =
Constants.ACTION_CUSTOMIZATION_ADJUSTMENT_TYPES;
export const AllArmorList = Constants.ALL_ARMOR_LIST;
export const AbilityScoreStatTypeEnum = Constants.AbilityScoreStatTypeEnum;
export const AbilityScoreTypeEnum = Constants.AbilityScoreTypeEnum;
export const AbilitySkillEnum = Constants.AbilitySkillEnum;
export const AbilityStatEnum = Constants.AbilityStatEnum;
export const AbilityStatMentalList = Constants.ABILITY_STAT_MENTAL_LIST;
export const AbilityStatPhysicalList = Constants.ABILITY_STAT_PHYSICAL_LIST;
export const AccessTypeEnum = Constants.AccessTypeEnum;
export const ActionTypeEnum = Constants.ActionTypeEnum;
export const ActivatableTypeEnum = Constants.ActivatableTypeEnum;
export const ActivationTypeEnum = Constants.ActivationTypeEnum;
export const AdditionalTypeEnum = Constants.AdditionalTypeEnum;
export const AdjustmentConstraintTypeEnum =
Constants.AdjustmentConstraintTypeEnum;
export const AdjustmentDataTypeEnum = Constants.AdjustmentDataTypeEnum;
export const AdjustmentTypeEnum = Constants.AdjustmentTypeEnum;
export const ApiTypeEnum = Constants.ApiTypeEnum;
export const AppContextTypeEnum = Constants.AppContextTypeEnum;
export const ArmorClassExtraTypeEnum = Constants.ArmorClassExtraTypeEnum;
export const ArmorClassTypeEnum = Constants.ArmorClassTypeEnum;
export const ArmorTypeEnum = Constants.ArmorTypeEnum;
export const AttackSourceTypeEnum = Constants.AttackSourceTypeEnum;
export const AttackSubtypeEnum = Constants.AttackSubtypeEnum;
export const AttackTypeRangeEnum = Constants.AttackTypeRangeEnum;
export const BackgroundModifierTypeEnum = Constants.BackgroundModifierTypeEnum;
export const BuilderChoiceSubtypeEnum = Constants.BuilderChoiceSubtypeEnum;
export const BuilderChoiceTypeEnum = Constants.BuilderChoiceTypeEnum;
export const CharacterServiceVersionKey =
Constants.CHARACTER_SERVICE_VERSION_KEY;
export const CharacterServiceVersionKeyOverride =
Constants.CHARACTER_SERVICE_VERSION_KEY_OVERRIDE;
export const CreatureCustomizationAdjustmentTypes =
Constants.CREATURE_CUSTOMIZATION_ADJUSTMENT_TYPES;
export const CurrencyValue = Constants.CURRENCY_VALUE;
export const CustomItemDefinitionEntityTypeId =
Constants.CUSTOM_ITEM_DEFINITION_ENTITY_TYPE_ID;
export const CharacterColorEnum = Constants.CharacterColorEnum;
export const CharacterLoadingStatusEnum = Constants.CharacterLoadingStatusEnum;
export const CoinNamesEnum = Constants.CoinNamesEnum;
export const CoinTypeEnum = Constants.CoinTypeEnum;
export const ComponentAdjustmentEnum = Constants.ComponentAdjustmentEnum;
export const ComponentCostTypeEnum = Constants.ComponentCostTypeEnum;
export const ConditionIdEnum = Constants.ConditionIdEnum;
export const ConditionTypeEnum = Constants.ConditionTypeEnum;
export const ContainerTypeEnum = Constants.ContainerTypeEnum;
export const ContentSharingSettingEnum = Constants.ContentSharingSettingEnum;
export const CreatureGroupFlagEnum = Constants.CreatureGroupFlagEnum;
export const CreatureSizeEnum = Constants.CreatureSizeEnum;
export const CreatureSizeNameEnum = Constants.CreatureSizeNameEnum;
export const CustomProficiencyTypeEnum = Constants.CustomProficiencyTypeEnum;
export const DamageAdjustmentList = Constants.DAMAGE_ADJUSTMENT_LIST;
export const DbStringBookOfAncientSecrets =
Constants.DB_STRING_BOOK_OF_ANCIENT_SECRETS;
export const DbStringCarapace = Constants.DB_STRING_CARAPACE;
export const DbStringDedicatedWeapon = Constants.DB_STRING_DEDICATED_WEAPON;
export const DbStringEldritchAdept = Constants.DB_STRING_ELDRITCH_ADEPT;
export const DbStringGroupSidekick = Constants.DB_STRING_GROUP_SIDEKICK;
export const DbStringImprovedPactWeapon =
Constants.DB_STRING_IMPROVED_PACT_WEAPON;
export const DbStringInfuseItem = Constants.DB_STRING_INFUSE_ITEM;
export const DbStringIntegratedProtection =
Constants.DB_STRING_INTEGRATED_PROTECTION;
export const DbStringMartialArts = Constants.DB_STRING_MARTIAL_ARTS;
export const DbStringMediumArmorMaster =
Constants.DB_STRING_MEDIUM_ARMOR_MASTER;
export const DbStringPactMagic = Constants.DB_STRING_PACT_MAGIC;
export const DbStringRitualCasterBard = Constants.DB_STRING_RITUAL_CASTER_BARD;
export const DbStringRitualCasterCleric =
Constants.DB_STRING_RITUAL_CASTER_CLERIC;
export const DbStringRitualCasterDruid =
Constants.DB_STRING_RITUAL_CASTER_DRUID;
export const DbStringRitualCasterList = Constants.DB_STRING_RITUAL_CASTER_LIST;
export const DbStringRitualCasterSorcerer =
Constants.DB_STRING_RITUAL_CASTER_SORCERER;
export const DbStringRitualCasterWarlock =
Constants.DB_STRING_RITUAL_CASTER_WARLOCK;
export const DbStringRitualCasterWizard =
Constants.DB_STRING_RITUAL_CASTER_WIZARD;
export const DbStringSpellcasting = Constants.DB_STRING_SPELLCASTING;
export const DbStringSpellEldritchBlast =
Constants.DB_STRING_SPELL_ELDRITCH_BLAST;
export const DbStringTagSidekick = Constants.DB_STRING_TAG_SIDEKICK;
export const DbStringVerdan = Constants.DB_STRING_VERDAN;
export const DefaultFeatureFlagInfo = Constants.DEFAULT_FEATURE_FLAG_INFO;
export const DefinitionKeySeparator = Constants.DEFINITION_KEY_SEPARATOR;
export const DefinitionServiceVersions = Constants.DEFINITION_SERVICE_VERSIONS;
export const DiceRollKeyConceptSeparator =
Constants.DICE_ROLL_KEY_CONCEPT_SEPARATOR;
export const DiceRollKeyDataSeparator = Constants.DICE_ROLL_KEY_DATA_SEPARATOR;
export const DamageAdjustmentTypeEnum = Constants.DamageAdjustmentTypeEnum;
export const DataOriginDataInfoKeyEnum = Constants.DataOriginDataInfoKeyEnum;
export const DataOriginTypeEnum = Constants.DataOriginTypeEnum;
export const DeathCauseEnum = Constants.DeathCauseEnum;
export const DefenseAdjustmentTypeEnum = Constants.DefenseAdjustmentTypeEnum;
export const DefinitionPoolTypeInfoKeyEnum =
Constants.DefinitionPoolTypeInfoKeyEnum;
export const DefinitionTypeEnum = Constants.DefinitionTypeEnum;
export const DiceAdjustmentRollTypeEnum = Constants.DiceAdjustmentRollTypeEnum;
export const DiceAdjustmentTypeEnum = Constants.DiceAdjustmentTypeEnum;
export const DiceRollExcludeTypeEnum = Constants.DiceRollExcludeTypeEnum;
export const DisplayConfigurationTypeEnum =
Constants.DisplayConfigurationTypeEnum;
export const DisplayConfigurationValueEnum =
Constants.DisplayConfigurationValueEnum;
export const DisplayIntentionEnum = Constants.DisplayIntentionEnum;
export const DurationUnitEnum = Constants.DurationUnitEnum;
export const EntityLimitedUseScaleOperatorEnum =
Constants.EntityLimitedUseScaleOperatorEnum;
export const EntityTypeEnum = Constants.EntityTypeEnum;
export const ExtraGroupTypeEnum = Constants.ExtraGroupTypeEnum;
export const ExtraTypeEnum = Constants.ExtraTypeEnum;
export const FeatureFlagList = Constants.FEATURE_FLAG_LIST;
export const FeetInMiles = Constants.FEET_IN_MILES;
export const FutureItemDefinitionType = Constants.FUTURE_ITEM_DEFINITION_TYPE;
export const FeatureFlagEnum = Constants.FeatureFlagEnum;
export const FeatureTypeEnum = Constants.FeatureTypeEnum;
export const HackVehicleGroupId = Constants.HACK_VEHICLE_GROUP_ID;
export const HeavyArmorList = Constants.HEAVY_ARMOR_LIST;
export const ItemCustomizationAdjustmentTypes =
Constants.ITEM_CUSTOMIZATION_ADJUSTMENT_TYPES;
export const InfusionItemDataRuleTypeEnum =
Constants.InfusionItemDataRuleTypeEnum;
export const InfusionModifierDataTypeEnum =
Constants.InfusionModifierDataTypeEnum;
export const InfusionTypeEnum = Constants.InfusionTypeEnum;
export const ItemBaseTypeEnum = Constants.ItemBaseTypeEnum;
export const ItemBaseTypeIdEnum = Constants.ItemBaseTypeIdEnum;
export const ItemRarityNameEnum = Constants.ItemRarityNameEnum;
export const ItemTypeEnum = Constants.ItemTypeEnum;
export const LightArmorList = Constants.LIGHT_ARMOR_LIST;
export const LimitedUseResetTypeEnum = Constants.LimitedUseResetTypeEnum;
export const LimitedUseResetTypeNameEnum =
Constants.LimitedUseResetTypeNameEnum;
export const LogMessageType = Constants.LogMessageType;
export const MagicItemAttackWithStatList =
Constants.MAGIC_ITEM_ATTACK_WITH_STAT_LIST;
export const MagicItemEntityTypeId = Constants.MAGIC_ITEM_ENTITY_TYPE_ID;
export const MediumArmorList = Constants.MEDIUM_ARMOR_LIST;
export const ModifierBonusTypeEnum = Constants.ModifierBonusTypeEnum;
export const ModifierSubTypeEnum = Constants.ModifierSubTypeEnum;
export const ModifierTypeEnum = Constants.ModifierTypeEnum;
export const MovementTypeEnum = Constants.MovementTypeEnum;
export const MulticlassSpellSlotRoundingEnum =
Constants.MulticlassSpellSlotRoundingEnum;
export const NoteKeyEnum = Constants.NoteKeyEnum;
export const NoteTypeEnum = Constants.NoteTypeEnum;
export const NotificationTypeEnum = Constants.NotificationTypeEnum;
export const PoundsInTon = Constants.POUNDS_IN_TON;
export const PropertyList = Constants.PROPERTY_LIST;
export const PartyInventorySharingStateEnum =
Constants.PartyInventorySharingStateEnum;
export const PreferenceAbilityScoreDisplayTypeEnum =
Constants.PreferenceAbilityScoreDisplayTypeEnum;
export const PreferenceEncumbranceTypeEnum =
Constants.PreferenceEncumbranceTypeEnum;
export const PreferenceHitPointTypeEnum = Constants.PreferenceHitPointTypeEnum;
export const PreferencePrivacyTypeEnum = Constants.PreferencePrivacyTypeEnum;
export const PreferenceProgressionTypeEnum =
Constants.PreferenceProgressionTypeEnum;
export const PreferenceSharingTypeEnum = Constants.PreferenceSharingTypeEnum;
export const PrerequisiteSubTypeEnum = Constants.PrerequisiteSubTypeEnum;
export const PrerequisiteTypeEnum = Constants.PrerequisiteTypeEnum;
export const ProficiencyAdjustmentTypeEnum =
Constants.ProficiencyAdjustmentTypeEnum;
export const ProficiencyLevelEnum = Constants.ProficiencyLevelEnum;
export const ProficiencyRoundingEnum = Constants.ProficiencyRoundingEnum;
export const ProtectionAvailabilityStatusEnum =
Constants.ProtectionAvailabilityStatusEnum;
export const ProtectionSupplierTypeEnum = Constants.ProtectionSupplierTypeEnum;
export const RaceTypeEnum = Constants.RaceTypeEnum;
export const RitualCastingTypeEnum = Constants.RitualCastingTypeEnum;
export const RuleDataTypeEnum = Constants.RuleDataTypeEnum;
export const RuleKeyEnum = Constants.RuleKeyEnum;
export const ShieldsList = Constants.SHIELDS_LIST;
export const Signed32BitIntMaxValue = Constants.SIGNED_32BIT_INT_MAX_VALUE;
export const Signed32BitIntMinValue = Constants.SIGNED_32BIT_INT_MIN_VALUE;
export const SizeList = Constants.SIZE_LIST;
export const SpellCustomizationAdjustmentTypes =
Constants.SPELL_CUSTOMIZATION_ADJUSTMENT_TYPES;
export const StatAbilityCheckList = Constants.STAT_ABILITY_CHECK_LIST;
export const StatAbilityScoreList = Constants.STAT_ABILITY_SCORE_LIST;
export const StatSavingThrowList = Constants.STAT_SAVING_THROW_LIST;
export const SaveTypeEnum = Constants.SaveTypeEnum;
export const SenseTypeEnum = Constants.SenseTypeEnum;
export const SituationalBonusSavingThrowTypeEnum =
Constants.SituationalBonusSavingThrowTypeEnum;
export const SnippetAbilityKeyEnum = Constants.SnippetAbilityKeyEnum;
export const SnippetContentChunkTypeEnum =
Constants.SnippetContentChunkTypeEnum;
export const SnippetMathOperatorEnum = Constants.SnippetMathOperatorEnum;
export const SnippetPostProcessTypeEnum = Constants.SnippetPostProcessTypeEnum;
export const SnippetSymbolEnum = Constants.SnippetSymbolEnum;
export const SnippetTagDataTypeEnum = Constants.SnippetTagDataTypeEnum;
export const SnippetTagValueTypeEnum = Constants.SnippetTagValueTypeEnum;
export const SnippetValueModifierTypeEnum =
Constants.SnippetValueModifierTypeEnum;
export const SourceTypeEnum = Constants.SourceTypeEnum;
export const SpeedMovementKeyEnum = Constants.SpeedMovementKeyEnum;
export const SpellConditionTypeEnum = Constants.SpellConditionTypeEnum;
export const SpellDurationTypeEnum = Constants.SpellDurationTypeEnum;
export const SpellGroupEnum = Constants.SpellGroupEnum;
export const SpellPrepareTypeEnum = Constants.SpellPrepareTypeEnum;
export const SpellRangeTypeEnum = Constants.SpellRangeTypeEnum;
export const SpellRangeTypeNameEnum = Constants.SpellRangeTypeNameEnum;
export const SpellScaleTypeNameEnum = Constants.SpellScaleTypeNameEnum;
export const StartingEquipmentRuleTypeEnum =
Constants.StartingEquipmentRuleTypeEnum;
export const StartingEquipmentTypeEnum = Constants.StartingEquipmentTypeEnum;
export const StatBlockTypeEnum = Constants.StatBlockTypeEnum;
export const StealthCheckTypeEnum = Constants.StealthCheckTypeEnum;
export const TraitTypeEnum = Constants.TraitTypeEnum;
export const UsableDiceAdjustmentTypeEnum =
Constants.UsableDiceAdjustmentTypeEnum;
export const VehicleComponentGroupTypeEnum =
Constants.VehicleComponentGroupTypeEnum;
export const VehicleConfigurationDisplayTypeEnum =
Constants.VehicleConfigurationDisplayTypeEnum;
export const VehicleConfigurationKeyEnum =
Constants.VehicleConfigurationKeyEnum;
export const VehicleConfigurationPrimaryComponentManageTypeEnum =
Constants.VehicleConfigurationPrimaryComponentManageTypeEnum;
export const VehicleConfigurationSizeTypeEnum =
Constants.VehicleConfigurationSizeTypeEnum;
export const WeaponCategoryEnum = Constants.WeaponCategoryEnum;
export const WeaponPropertyEnum = Constants.WeaponPropertyEnum;
export const WeaponTypeEnum = Constants.WeaponTypeEnum;
export const WeightSpeedTypeEnum = Constants.WeightSpeedTypeEnum;
export const WeightTypeEnum = Constants.WeightTypeEnum;
export const DefaultCharacterName = Constants.DefaultCharacterName;

View File

@ -0,0 +1,21 @@
import { useContext } from "react";
import { createContext } from "react";
import useUser from "~/hooks/useUser";
import type { AppUserState } from "~/hooks/useUser";
export let AuthContext = createContext<AppUserState>(undefined);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const user = useUser();
if (user === undefined) {
return null;
}
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
return useContext(AuthContext);
};

View File

@ -0,0 +1,85 @@
import { useContext, useEffect } from "react";
import { createContext } from "react";
import { useSelector } from "react-redux";
import {
rulesEngineSelectors,
CharacterTheme,
} from "@dndbeyond/character-rules-engine";
export let CharacterThemeContext = createContext<CharacterTheme>({
isDefault: false,
themeColorId: 0,
name: "",
themeColor: "",
backgroundColor: "",
isDarkMode: false,
});
export const CharacterThemeProvider = ({ children }) => {
const {
isDefault,
themeColorId,
name,
themeColor,
backgroundColor,
isDarkMode,
} = useSelector(rulesEngineSelectors.getCharacterTheme);
const solidBackground =
backgroundColor.length > 7 ? backgroundColor.slice(0, -2) : backgroundColor;
const isThemeColorDark = () => {
// Remove #
const c = themeColor.substring(1);
// convert rrggbb to decimal
const rgb = parseInt(c, 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = (rgb >> 0) & 0xff;
// Get luma from rgb colors
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luma < 40;
};
useEffect(() => {
document.body.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
return (
<CharacterThemeContext.Provider
value={{
isDefault,
themeColorId,
name,
themeColor,
backgroundColor,
isDarkMode,
}}
>
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--theme-color: ${themeColor};
--theme-background: ${backgroundColor};
--theme-background-solid: ${solidBackground};
--theme-contrast: var(--ttui_grey-${isDarkMode ? 50 : 900});
--theme-transparent: color-mix(in srgb, var(--theme-color), transparent 60%);
/* Update the character muted color for readability on dark mode */
${
isDarkMode ? "--character-muted-color: var(--ttui_grey-400);" : ""
}
}`,
}}
/>
{children}
</CharacterThemeContext.Provider>
);
};
export const useCharacterTheme = () => {
return useContext(CharacterThemeContext);
};

View File

@ -0,0 +1,84 @@
import { FC, createContext, useContext, useEffect, useState } from "react";
import { toCamelCase } from "~/helpers/casing";
import { getFeatureFlagsFromCharacterService } from "~/helpers/characterServiceApi";
const featureFlagsToGet = [
"release-gate-ims",
"release-gate-gfs-blessings-ui",
"release-gate-character-sheet-tour",
"release-gate-premade-characters",
];
export const defaultFlags = {
imsFlag: false,
gfsBlessingsUiFlag: false,
characterSheetTourFlag: false,
premadeCharactersFlag: false,
};
export type FeatureFlags = typeof defaultFlags;
export interface FeatureFlagContextType extends FeatureFlags {
featureFlags: FeatureFlags;
}
export const FeatureFlagContext = createContext<FeatureFlagContextType>(null!);
interface ProviderProps {
defaultOverrides?: Partial<FeatureFlags>;
}
export const FeatureFlagProvider: FC<ProviderProps> = ({
children,
defaultOverrides,
}) => {
const [featureFlags, setFeatureFlags] = useState({
...defaultFlags,
...defaultOverrides,
});
const convertFlagsToKeys = (flags: Object) => {
return Object.keys(flags).reduce((acc, flag) => {
// Perform any manipulations to remove unwanted characters
const formattedFlag = flag
// Remove `character-app` from the flag name
.replace(/character-app\./i, "")
// Remove `release-gate` from the flag name
.replace(/release-gate\W/i, "")
// Remove `character-sheet` from flag name if not the tour flag
.replace(/character-sheet\W(?!tour)/i, "")
// Change `2d` to `tactical` since var can't start with number
.replace(/2d/i, "tactical");
const key = toCamelCase(`${formattedFlag}Flag`);
return { ...acc, [key]: flags[flag] };
}, {});
};
const getFeatureFlags = async () => {
try {
const req = await getFeatureFlagsFromCharacterService(featureFlagsToGet);
const res = await req.json();
const flags = convertFlagsToKeys(res.data) as FeatureFlags;
setFeatureFlags(flags);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
if (!defaultOverrides) getFeatureFlags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultOverrides]);
return (
<FeatureFlagContext.Provider value={{ ...featureFlags, featureFlags }}>
{children}
</FeatureFlagContext.Provider>
);
};
export const useFeatureFlags = () => {
return useContext(FeatureFlagContext);
};

View File

@ -0,0 +1,45 @@
import { createContext, FC, useContext, useState } from "react";
export interface FiltersContextType {
showItemTypes: boolean;
setShowItemTypes: (show: boolean) => void;
showItemSourceCategories: boolean;
setShowItemSourceCategories: (show: boolean) => void;
showSpellLevels: boolean;
setShowSpellLevels: (show: boolean) => void;
showSpellSourceCategories: boolean;
setShowSpellSourceCategories: (show: boolean) => void;
}
export const FiltersContext = createContext<FiltersContextType>(null!);
export const FiltersProvider: FC = ({ children }) => {
const [showItemTypes, setShowItemTypes] = useState(true);
const [showItemSourceCategories, setShowItemSourceCategories] =
useState(true);
const [showSpellLevels, setShowSpellLevels] = useState(true);
const [showSpellSourceCategories, setShowSpellSourceCategories] =
useState(true);
return (
<FiltersContext.Provider
value={{
showItemTypes,
setShowItemTypes,
showItemSourceCategories,
setShowItemSourceCategories,
showSpellLevels,
setShowSpellLevels,
showSpellSourceCategories,
setShowSpellSourceCategories,
}}
>
{children}
</FiltersContext.Provider>
);
};
export const useFiltersContext = () => {
return useContext(FiltersContext);
};

View File

@ -0,0 +1,36 @@
import { createContext, useContext, useState } from "react";
import { Helmet } from "react-helmet";
interface HeadContextProps {
title: string;
setTitle: (title: string) => void;
}
export const HeadContext = createContext<HeadContextProps>(null!);
export const HeadContextProvider = ({ children }) => {
const [title, setTitle] = useState("Character App");
const setNewTitle = (title) => {
setTitle(`${title} - D&D Beyond`);
};
return (
<HeadContext.Provider value={{ title, setTitle: setNewTitle }}>
<Helmet>
<title>{title}</title>
</Helmet>
{children}
</HeadContext.Provider>
);
};
export const useHeadContext = () => {
const context = useContext(HeadContext);
if (!context) {
throw new Error("useHead must be used within a HeadProvider");
}
return context;
};

View File

@ -0,0 +1,174 @@
import { isEqual } from "lodash";
import { createContext, FC, useContext, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
PaneComponentEnum,
PaneComponentInfo,
PaneIdentifiers,
SidebarAlignmentEnum,
SidebarPlacementEnum,
} from "~/subApps/sheet/components/Sidebar/types";
import { SIDEBAR_FIXED_POSITION_START_WIDTH } from "~/tools/js/CharacterSheet/config";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
export interface SidebarInfo {
isVisible: boolean;
setIsVisible: (isVisible: boolean) => void;
isLocked: boolean;
setIsLocked: (isLocked: boolean) => void;
placement: SidebarPlacementEnum;
setPlacement: (placement: SidebarPlacementEnum) => void;
alignment: SidebarAlignmentEnum;
setAlignment: (alignment: SidebarAlignmentEnum) => void;
width: number;
}
export interface PaneInfo {
activePane: PaneComponentInfo | null;
showControls: boolean;
isAtStart: boolean;
isAtEnd: boolean;
paneHistoryStart: (
componentType: PaneComponentEnum,
componentIdentifiers?: PaneIdentifiers | null
) => void;
paneHistoryPush: (
componentType: PaneComponentEnum,
componentIdentifiers?: PaneIdentifiers | null
) => void;
paneHistoryPrevious: () => void;
paneHistoryNext: () => void;
}
export interface SidebarContextType {
sidebar: SidebarInfo;
pane: PaneInfo;
}
export const SidebarContext = createContext<SidebarContextType>(null!);
export const SidebarProvider: FC = ({ children }) => {
const isMobile = useSelector(appEnvSelectors.getIsMobile);
const sheetDimensions = useSelector(appEnvSelectors.getDimensions);
const [isVisible, setIsVisible] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [placement, setPlacement] = useState(SidebarPlacementEnum.OVERLAY);
const [alignment, setAlignment] = useState(SidebarAlignmentEnum.RIGHT);
const [paneIdx, setPaneIdx] = useState<number>(0);
const [paneHistory, setPaneHistory] = useState<Array<PaneComponentInfo>>([]);
const [activePane, setActivePane] = useState<PaneComponentInfo | null>(null);
const width: number = 340;
//Create a new pane history
const paneHistoryStart = (
type: PaneComponentEnum,
identifiers: PaneIdentifiers | null = null
) => {
setIsVisible(true);
setPaneIdx(0);
setPaneHistory([
{
type,
identifiers,
},
]);
};
//Add a new pane to the history
const paneHistoryPush = (
type: PaneComponentEnum,
identifiers: PaneIdentifiers | null = null
) => {
if (paneIdx === null) {
paneHistoryStart(type, identifiers);
} else {
// get current active pane (last in history)
const activeHistoryElement = {
identifiers: activePane?.identifiers,
type: activePane?.type,
};
// create a new node for the history
const newHistoryElement = { identifiers, type };
// compare the current active pane with the new one (last element in history with new one)
if (!isEqual(activeHistoryElement, newHistoryElement)) {
// Check if new history element is different than current - otherwise skip to avoid duplicates
let newHistory = [
...paneHistory.slice(0, paneIdx + 1),
newHistoryElement,
];
setPaneHistory(newHistory);
setPaneIdx(newHistory.length - 1);
}
}
setIsVisible(true);
};
//Go back to the previous pane in the history
const paneHistoryPrevious = () => {
setPaneIdx(Math.max(0, paneIdx - 1));
};
//Go to the next pane in the history
const paneHistoryNext = () => {
setPaneIdx(Math.min(paneHistory.length - 1, paneIdx + 1));
};
useEffect(() => {
//If the window less than the fixed position start width (1600px), set the placement to overlay
if (
isMobile ||
sheetDimensions.window.width < SIDEBAR_FIXED_POSITION_START_WIDTH
) {
setPlacement(SidebarPlacementEnum.OVERLAY);
}
//set Right alignment if mobile
setAlignment(isMobile ? SidebarAlignmentEnum.RIGHT : alignment);
}, [isMobile, sheetDimensions]);
useEffect(() => {
const activePane = paneHistory[paneIdx];
setActivePane(activePane);
}, [paneIdx, paneHistory]);
return (
<SidebarContext.Provider
value={{
sidebar: {
isVisible,
setIsVisible,
isLocked,
setIsLocked,
placement,
setPlacement,
alignment,
setAlignment,
width,
},
pane: {
activePane,
showControls: paneHistory.length > 1,
isAtStart: paneIdx === 0,
isAtEnd: paneIdx === paneHistory.length - 1,
paneHistoryStart,
paneHistoryPush,
paneHistoryPrevious,
paneHistoryNext,
},
}}
>
{children}
</SidebarContext.Provider>
);
};
export const useSidebar = () => {
return useContext(SidebarContext);
};

View File

@ -0,0 +1,88 @@
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import { ThemeProvider } from "@mui/material/styles";
import { useState, createContext, useEffect, useMemo } from "react";
import { prefixer } from "stylis";
import { getTheme } from "~/theme";
type PaletteOpt = "light" | "dark";
interface Manager {
lightOrDark: PaletteOpt;
primary?: string;
}
interface ThemeManagerContext {
manager: Manager;
setManager: (manager: Manager) => void;
}
export const ThemeManager = createContext<ThemeManagerContext>(null!);
export const ThemeManagerProvider = ({
children,
manager: propsManager,
}: {
children: React.ReactNode;
manager?: Manager;
}) => {
const [manager, setManager] = useState<Manager>(
propsManager || {
lightOrDark: "light",
}
);
useEffect(() => {
if (propsManager) {
setManager(propsManager);
}
}, [propsManager]);
// HACK
/**
* We are doing some hacky things to
* win is styling conflicts this can
* go away once we no longer need
* waterdeep styles
*/
function hack__createExtraScopePlugin(...extra) {
const scopes = extra.map((scope) => `${scope.trim()} `);
return (element) => {
if (element.type !== "rule") {
return;
}
if (element.root?.type === "@keyframes") {
return;
}
element.props = element.props
.map((prop) => scopes.map((scope) => scope + prop))
.reduce((scopesArray, scope) => scopesArray.concat(scope), []);
};
}
const hack__cache = useMemo(
() =>
createCache({
key: "ddb-character-app",
container: document.body,
prepend: true,
stylisPlugins: [
hack__createExtraScopePlugin("body"),
// has to be included manually when customizing `stylisPlugins` if you want to have vendor prefixes added automatically
prefixer,
],
}),
[]
);
/**
* End the hack
*/
const theme = getTheme(manager.lightOrDark, manager.primary);
return (
<ThemeManager.Provider value={{ manager, setManager }}>
<CacheProvider value={hack__cache}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</CacheProvider>
</ThemeManager.Provider>
);
};

View File

@ -0,0 +1,50 @@
import { Dispatch } from "react";
import {
Action,
ActionUtils,
characterActions,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
export const handleActionUseSet = (
action: Action,
uses: number,
dispatch: Dispatch<any>
): void => {
const id = ActionUtils.getId(action);
const entityTypeId = ActionUtils.getEntityTypeId(action);
const dataOriginType = ActionUtils.getDataOriginType(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(id, entityTypeId, uses, dataOriginType)
);
}
};
export const handleSpellUseSet = (
spell: Spell,
uses: number,
dispatch: Dispatch<any>
): void => {
const mappingId = SpellUtils.getMappingId(spell);
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
if (
mappingId !== null &&
mappingEntityTypeId !== null &&
dataOriginType !== null
) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
};

View File

@ -0,0 +1,343 @@
// GA Docs for using multiple accounts on the same page
// https://developers.google.com/analytics/devguides/collection/analyticsjs/creating-trackers#working_with_multiple_trackers
import debounce from "debounce";
import config from "../../config";
import {
SessionTrackingIdByName,
SessionNames,
EventCategories,
EventActions,
EventLabels,
} from "../../constants";
import {
getSearchableTerms,
getSearchableTermsAnalyticsLabel,
} from "../../state/selectors/characterUtils";
import { CharacterData, LogEventOptions } from "../../types";
let { debug } = config;
const tryGa = (...params) => {
if (typeof window.ga === "function") {
window.ga(...params);
return true;
}
return false;
};
// eslint-disable-next-line max-params
const logGa = (sessionName: string, eventName: string, ...rest: any) =>
tryGa(`${sessionName}.send`, eventName, ...rest);
export const logEvent = (
sessionName: string,
{ category, action, label, value }: LogEventOptions
) => {
const success = logGa(sessionName, "event", category, action, label, value);
if (success && debug) {
// eslint-disable-next-line no-console
console.log(
"Logged analytics event:",
"\nsession:",
sessionName,
"\ncategory:",
category,
"\naction:",
action,
"\nlabel:",
label,
"\nvalue:",
value
);
}
};
// eslint-disable-next-line max-params
export const logPageView = (sessionName, pageName, ...rest) => {
tryGa(`${sessionName}.set`, "page", pageName, ...rest);
const success = tryGa(`${sessionName}.send`, "pageview");
if (success && debug) {
// eslint-disable-next-line no-console
console.log(
"Logged page view:",
"\nsession:",
sessionName,
"\npage:",
pageName,
"\nrest:",
...rest
);
}
};
export const initAnalytics = (sessionName: string, forceDebug?: boolean) => {
/* eslint-disable @typescript-eslint/no-unused-expressions, no-sequences */
(function (
i: Window,
s: Document,
o: string,
g: string,
r: string,
a?: HTMLScriptElement,
m?: Element
) {
i["GoogleAnalyticsObject"] = r;
(i[r] =
i[r] ||
function () {
(i[r].q = i[r].q || []).push(arguments);
}),
(i[r].l = 1 * (new Date() as any));
(a = s.createElement(o) as HTMLScriptElement),
(m = s.getElementsByTagName(o)[0]);
a.async = true;
a.src = g;
m.parentNode?.insertBefore(a, m);
})(
window,
document,
"script",
"https://www.google-analytics.com/analytics.js",
"ga"
);
/* eslint-enable */
tryGa("create", SessionTrackingIdByName[sessionName], "auto", sessionName);
if (forceDebug) {
debug = forceDebug;
}
};
/**
* Converts an array to a string that can be used for an event label
* @param {array} array The array to convert
* @param {object} valueToLabelMap An optional value to label mapping object
* @returns {string} A string representation of the array
*/
export const arrayToLabel = (array, valueToLabelMap = {}) =>
array
.sort()
.map((value) => valueToLabelMap[value] || value)
.join(", ");
/**
* Reduces an array of filter options ({ label, value }) to a lookup object of value to label
* @param {array} filterOptions The list of options to reduce
* @returns {object} A lookup object with filter values as the keys and filter labels as the values
*/
export const filterOptionsToLookup = (filterOptions = []) =>
filterOptions.reduce((lookup, { label, value }) => {
lookup[value] = label;
return lookup;
}, {});
export const logCharacterCampaignClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Campaign,
label: EventLabels.Clicked,
});
};
export const logCharacterCopyCancelled = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Copy,
label: EventLabels.Cancelled,
});
};
export const logCharacterCopyClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Copy,
label: EventLabels.Clicked,
});
};
export const logCharacterCopyConfirmed = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Copy,
label: EventLabels.Confirmed,
});
};
export const logCharacterDeleteCancelled = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Delete,
label: EventLabels.Cancelled,
});
};
export const logCharacterDeleteClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Delete,
label: EventLabels.Clicked,
});
};
export const logCharacterDeleteConfirmed = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Delete,
label: EventLabels.Confirmed,
});
};
export const logCharacterEditClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_Edit,
label: EventLabels.Clicked,
});
};
export const logCharacterLeaveCampaignCancelled = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_LeaveCampaign,
label: EventLabels.Cancelled,
});
};
export const logCharacterLeaveCampaignClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_LeaveCampaign,
label: EventLabels.Clicked,
});
};
export const logCharacterLeaveCampaignConfirmed = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_LeaveCampaign,
label: EventLabels.Confirmed,
});
};
export const logCharacterViewClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Character,
action: EventActions.DDB_Character_View,
label: EventLabels.Clicked,
});
};
export const logListingSearchChanged = debounce(
(characters: Array<CharacterData>, matchesSearch: () => any) => {
const matchedLabels = characters
.map(
(character) =>
getSearchableTermsAnalyticsLabel(character)[
getSearchableTerms(character).findIndex(matchesSearch)
]
)
.filter(Boolean)
.filter(
(character, index, characters) =>
characters.indexOf(character) === index
);
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_ListingFilter,
action: EventActions.DDB_ListingFilter_SearchChanged,
label: arrayToLabel(matchedLabels) || "no match",
});
},
500
);
export const logListingSearchCleared = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_ListingFilter,
action: EventActions.DDB_ListingFilter_SearchCleared,
});
};
export const logListingSortChanged = (label) => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_ListingSort,
action: EventActions.DDB_ListingSort_Changed,
label,
});
};
export const logPlayerAppBannerDismissed = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_PlayerAppBanner,
action: EventActions.DDB_PlayerAppBanner_Dismissed,
});
};
export const logPlayerAppBannerClickedCta = (label) => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_PlayerAppBanner,
action: EventActions.DDB_PlayerAppBanner_ClickedCta,
label,
});
};
export const logUnlockCharacterLocked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_CharacterLocked,
});
};
export const logUnlockCharacterUnlocked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_CharacterUnlocked,
});
};
export const logUnlockFinishUnlockingClicked = (
unlockedCharacterCount,
maxSlots
) => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_FinishUnlockingClicked,
label: `Unlocked Characters: ${unlockedCharacterCount} / ${maxSlots}`,
});
};
export const logUnlockFinishUnlockingCancelled = (
unlockedCharacterCount,
maxSlots
) => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_FinishUnlockingCancelled,
label: `Unlocked Characters: ${unlockedCharacterCount} / ${maxSlots}`,
});
};
export const logUnlockFinishUnlockingConfirmed = (
unlockedCharacterCount,
maxSlots
) => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_FinishUnlockingConfirmed,
label: `Unlocked Characters: ${unlockedCharacterCount} / ${maxSlots}`,
});
};
export const logUnlockSubscribeClicked = () => {
logEvent(SessionNames.DDB, {
category: EventCategories.DDB_Unlock,
action: EventActions.DDB_Unlock_SubscribeClicked,
});
};

View File

@ -0,0 +1,11 @@
export const toCamelCase = (str: string) =>
str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index === 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\W/g, "");
export const toPascalCase = (str: string) =>
str
.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase())
.replace(/\W/g, "");

View File

@ -0,0 +1,189 @@
import { CharacterData, UserPreferences } from "~/types";
import config, {
characterServiceBaseUrl,
ddbBaseUrl,
userServiceBaseUrl,
} from "../config";
import { getId, getCampaignId } from "../state/selectors/characterUtils";
import { applyQueriesClientSide, queryListToMap } from "./queryUtils";
import { summon } from "./summon";
import { getUserId } from "./userApi";
export const getCharacters = async () => {
try {
const userId = await getUserId();
const req = await summon(
`${characterServiceBaseUrl}/characters/list?userId=${userId}`
);
return req.json();
} catch {
throw new Error("Non-OK response from GET characters");
}
};
export const getItems = async ({ queries }) => {
const characters = await getCharacters();
applyQueriesClientSide(queryListToMap(queries));
return new Promise((resolve) =>
resolve({
json: () => new Promise((resolve2) => resolve2(characters)),
})
);
};
export const copyCharacter = (character: CharacterData) => {
try {
const characterId = getId(character);
return summon(`${characterServiceBaseUrl}/character/copy`, {
method: "POST",
body: JSON.stringify({ characterId }),
});
} catch (err) {
throw err;
}
};
export const claimCharacter = (characterId: number, isAssigned: boolean) => {
try {
return summon(`${characterServiceBaseUrl}/premade/claim`, {
method: "POST",
body: JSON.stringify({ characterId, isAssigned }),
});
} catch (err) {
throw err;
}
};
export const getCharacterSlots = async () => {
try {
const req = await summon(`${config.userServiceBaseUrl}/slot-limit`);
const res = await req.json();
return res.data;
} catch (err) {
throw err;
}
};
export const deleteCharacter = (character: CharacterData) => {
try {
const characterId = getId(character);
return summon(`${characterServiceBaseUrl}/character`, {
method: "DELETE",
body: JSON.stringify({ characterId }),
});
} catch (err) {
throw err;
}
};
export const leaveCampaign = (character: CharacterData) => {
try {
const campaignId = getCampaignId(character);
const characterId = getId(character);
return summon(
`${ddbBaseUrl}/api/campaign/${campaignId}/character/${characterId}`,
{
method: "DELETE",
}
);
} catch (err) {
throw err;
}
};
export const joinCampaign = (campaignJoinCode: string, characterId: number) => {
try {
return summon(`${ddbBaseUrl}/api/campaign/join/${campaignJoinCode}`, {
method: "POST",
body: JSON.stringify({ characterId }),
});
} catch (err) {
throw err;
}
};
export const unlockCharacters = (characterIds: Array<number>) => {
try {
return summon(`${userServiceBaseUrl}/unlock`, {
method: "POST",
body: JSON.stringify({ characterIds }),
});
} catch (err) {
throw err;
}
};
export const activateCharacter = (character: CharacterData) => {
try {
const characterId = getId(character);
return summon(
`${characterServiceBaseUrl}/characters/${characterId}/status/activate`,
{
method: "PUT",
}
);
} catch (err) {
throw err;
}
};
export const getFeatureFlagsFromCharacterService = (flags: Array<string>) => {
try {
return summon(`${characterServiceBaseUrl}/featureflag`, {
method: "POST",
body: JSON.stringify({ flags }),
});
} catch (err) {
throw err;
}
};
/**
* Get all sources that the user is able to use.
*/
export const getAllEntitledSources = (campaignId?: number) => {
try {
return summon(
`${userServiceBaseUrl}/entitled-sources${
campaignId ? `?campaignId=${campaignId}` : ""
}`
);
} catch (err) {
throw err;
}
};
export const getUserPreferences = () => {
try {
return summon(`${config.userServiceBaseUrl}/preferences`);
} catch (err) {
throw err;
}
};
export const updateUserPreferences = (preferences: UserPreferences) => {
try {
return summon(`${config.userServiceBaseUrl}/preferences`, {
method: "POST",
body: JSON.stringify(preferences),
});
} catch (err) {
throw err;
}
};
export const getRulesData = () => {
try {
return summon(`${characterServiceBaseUrl}/rule-data`, {
method: "GET",
});
} catch (err) {
throw err;
}
};

View File

@ -0,0 +1,52 @@
import {
MovementTypeEnum,
PreferenceAbilityScoreDisplayTypeEnum as AbilityScoreDisplayType,
PreferenceEncumbranceTypeEnum,
PreferenceHitPointTypeEnum,
PreferencePrivacyTypeEnum,
PreferenceProgressionTypeEnum,
PreferenceSharingTypeEnum,
SenseTypeEnum,
} from "~/constants";
import { FeatureFlags } from "~/contexts/FeatureFlag";
/**
* This is a copy of the `generateCharacterPreferences` function from the rules
* engine package. This is necessary because we have restructured the
* featureFlagContext, so the `featureFlagInfo` variable is no longer
* available.
**/
export const generateCharacterPreferences = (featureFlags: FeatureFlags) => {
// Default values
const abilityScoreDisplayType = AbilityScoreDisplayType.MODIFIERS_TOP;
const encumbranceType = PreferenceEncumbranceTypeEnum.ENCUMBRANCE;
const hitPointType = PreferenceHitPointTypeEnum.FIXED;
const primaryMovement = MovementTypeEnum.WALK;
const primarySense = SenseTypeEnum.PASSIVE_PERCEPTION;
const progressionType = PreferenceProgressionTypeEnum.MILESTONE;
const sharingType = PreferenceSharingTypeEnum.LIMITED;
const privacyType = PreferencePrivacyTypeEnum.CAMPAIGN_ONLY;
return {
abilityScoreDisplayType,
enableContainerCurrency: false,
enableDarkMode: false,
enableOptionalClassFeatures: false,
enableOptionalOrigins: false,
encumbranceType,
enforceFeatRules: true,
enforceMulticlassRules: true,
hitPointType,
ignoreCoinWeight: true,
primaryMovement,
primarySense,
privacyType,
progressionType,
sharingType,
showCompanions: false,
showScaledSpells: true,
showUnarmedStrike: true,
showWildShape: false,
useHomebrewContent: true,
};
};

View File

@ -0,0 +1,33 @@
export const tryGet = (key: string) => {
try {
return window.localStorage.getItem(key);
} catch (exception) {
// If 3rd party cookies are turned off even though window.localStorage
// is accessible or local storage is full this will error
// https://github.com/Modernizr/Modernizr/blob/master/feature-detects/storage/localstorage.js
return null;
}
};
export const trySet = (key: string, value: string) => {
try {
window.localStorage.setItem(key, value);
return true;
} catch (exception) {
// Safari can throw an exception when calling localStorage.setItem in a private browsing tab.
// https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem#Exceptions
return false;
}
};
export const tryRemove = (key: string) => {
try {
window.localStorage.removeItem(key);
return true;
} catch (exception) {
// Get and trySet can throw exceptions, so this probably can too...
return false;
}
};

View File

@ -0,0 +1,13 @@
import { ReportHandler } from "web-vitals";
export const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};

View File

@ -0,0 +1,141 @@
import { SortOrderEnum, SortTypeEnum, CharacterData } from "../types";
// Creates a sorting predicate to sort for some property
export const byProp =
(propSelector: Function) =>
(a: CharacterData, b: CharacterData): number => {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
const aProp = propSelector(a);
const bProp = propSelector(b);
if (aProp < bProp) {
return -1;
}
if (aProp > bProp) {
return 1;
}
return 0;
};
// Creates a value to be used for sorting
export const createSortValue = (
sortBy: SortTypeEnum,
sortOrder: SortOrderEnum
): string => {
return `${sortBy}-${sortOrder}`;
};
/**
* Sorts an array of objects by a given key. If a drilled-down key is needed,
* simply provide the path to the key as a string (ex. "key.subkey") and the
* function will handle the rest.
**/
export const orderBy = (
arr: Array<any>,
key?: string,
sort: "asc" | "desc" = "asc"
) => {
const getKey = (obj: any) => {
if (key) {
if (key.includes(".")) {
const arr = key.split(".");
return arr.reduce((acc, curr) => {
return acc[curr];
}, obj);
} else {
return obj[key];
}
} else {
return;
}
};
const handleSort = (valA: object, valB: object) => {
// If a key is provided, sort by that
if (key) {
// Get values to compare
const a = getKey(valA);
const b = getKey(valB);
// If the values are strings, compare them alphabetically
if (a < b) return sort === "asc" ? -1 : 1;
if (b < a) return sort === "asc" ? 1 : -1;
}
// Otherwise, sort by the object itself
return 0;
};
// Return a new array with the sorted values
return arr.concat().sort(handleSort);
};
/**
* Separates a single array of objects into two separate arrays based on
* whether or not they include a query or not. If a key is provided, the
* function will look for the query by that key. If the key is nested, it will
* handle that as well. The function will return both the items which match and
* the ones that don't as separate arrays to be used as needed.
**/
export const sortByMatch = (
arr: Array<any>,
query: string,
key: string = "name",
exact: boolean = false
) => {
// If a key is provided, drill down until that key is found
const getKey = (obj: any) => {
if (key) {
if (key.includes(".")) {
const arr = key.split(".");
return arr.reduce((acc, curr) => {
return acc[curr];
}, obj);
} else {
return obj[key];
}
} else {
return;
}
};
// Create empty arrays to store the objects
let arrA: Array<any> = [];
let arrB: Array<any> = [];
// Loop through the provided array and separate the objects
arr.forEach((item) => {
if (exact) {
if (getKey(item) === query) {
arrA.push(item);
} else {
arrB.push(item);
}
} else {
if (getKey(item).includes(query)) {
arrA.push(item);
} else {
arrB.push(item);
}
}
});
// Return a new array with the sorted values
return [arrA, arrB];
};
export const sortObjectByKeys = (val: object, dir: "asc" | "desc" = "asc") => {
const sortFn = (a, b) => {
const numA = parseInt(a);
const numB = parseInt(b);
return dir === "asc" ? numA - numB : numB - numA;
};
const sorted = Object.keys(val)
.sort(sortFn)
.reduce((obj, key) => {
obj[key] = val[key];
return obj;
}, {});
return sorted;
};

View File

@ -0,0 +1,39 @@
import AuthUtils from "@dndbeyond/authentication-lib-js";
import { getStt } from "./tokenUtils";
export const getAuthHeaders = AuthUtils.makeGetAuthorizationHeaders({
madeGetShortTermToken: getStt,
});
/**
* A wrapper around fetch that adds the Authorization header and anything else
* that may need to be included in every request. If withCookies is true, the
* request will add the credentials: "include" option. Otherwise, this function
* should work exactly like the native fetch function.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
*/
export const summon = async (
input: RequestInfo | URL,
init?: RequestInit,
withCookies?: boolean
) => {
// Get the Authorization headers from the auth token
const authHeaders = await getAuthHeaders();
// If withCookies is true, set the credentials to include
const credentials = withCookies && { credentials: "include" };
// If there is a body, set the Content-Type to application/json
const contentType = init?.body && { "Content-Type": "application/json" };
return window.fetch(input, {
...(credentials as { credentials: RequestCredentials } | undefined),
...init,
headers: {
...authHeaders,
...init?.headers,
...contentType,
Accept: "application/json",
},
});
};

View File

@ -0,0 +1,15 @@
import jwtDecode from "jwt-decode";
import { AuthUtils } from "@dndbeyond/authentication-lib-js";
import { authEndpoint as authUrl } from "../config";
export const getStt = AuthUtils.makeGetShortTermToken({
authUrl,
throwOnHttpStatusError: false,
});
export const getDecodedStt = async () => {
const stt = await getStt();
return stt ? jwtDecode(stt) : null;
};

View File

@ -0,0 +1,36 @@
import { UserUtils } from "@dndbeyond/authentication-lib-js";
import { getDecodedStt } from "./tokenUtils";
export const getUserToken = async () => {
const stt = await getDecodedStt();
return stt;
};
export const getUser = async () => {
const token = await getUserToken();
return {
...UserUtils.jwtToUser(token),
displayName: token.displayName,
};
};
export const getUserDisplayName = async () => {
const user = await getUser();
return user.displayName;
};
export const getUserId = async () => {
const user = await getUser();
return user.id;
};
export const getSubscriptionTier = async () => {
const user = await getUser();
return user.subscriptionTier;
};
export const getRoles = async () => {
const user = await getUser();
return user.roles;
};

View File

@ -0,0 +1,11 @@
/**
* https://github.com/Microsoft/TypeScript/issues/16069#issuecomment-369374214
* Used to get around array filter function that errors because it doesn't recognize the filter
* is removing nulls
* @param input
*/
export const isNotNullOrUndefined = <T extends Object>(
input: null | undefined | T
): input is T => {
return input !== null;
};

View File

@ -0,0 +1,25 @@
import { useContext, useEffect, useState } from "react";
import {
AbilityManager,
FeaturesManager,
} from "@dndbeyond/character-rules-engine/es";
import { AttributesManagerContext } from "~/tools/js/Shared/managers/AttributesManagerContext";
export function useAbilities() {
const { attributesManager } = useContext(AttributesManagerContext);
const [abilities, setAbilities] = useState<Array<AbilityManager>>([]);
useEffect(() => {
async function onUpdate() {
const abilities = await attributesManager.getAbilities();
setAbilities(abilities);
}
return FeaturesManager.subscribeToUpdates({ onUpdate });
}, [attributesManager, setAbilities]);
return abilities;
}

View File

@ -0,0 +1,48 @@
import { useCallback, useState } from "react";
const identity = (x) => x;
/**
* Hook that encapsulates loading/error state setting when calling an API promise
* Usage:
* const [
* doThing, // callback to invoke to initiate the API call
* isDoingThing, // boolean indicating if the API call is in progress
* errorDoingThing, // error object indicating if there was an error caught (whatever param is passed to the .catch() callback)
* ] = useApiCall(MyApiUtils.doThing);
* @param {function} apiFunc The API function to use for setting loading/error state - Must return a promise!
* @param {function} [onSuccess] Optional callback invoked if the apiFunc promise succeeds
* @returns {[function, boolean, any]} An array containing a callback to initiate the API call, a isLoading boolean, and an error object
*/
const useApiCall = (apiFunc, onSuccess = identity) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const makeCall = useCallback(
(...args) => {
setIsLoading(true);
setError(null);
apiFunc(...args)
.then(onSuccess)
.then(() => {
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
setError(err);
});
},
[apiFunc, onSuccess]
);
return [
makeCall as (...args: any[]) => void,
isLoading as boolean,
error as any,
];
};
export default useApiCall;

View File

@ -0,0 +1,428 @@
import { useSelector } from "react-redux";
import {
AbilityUtils as abilityUtils,
AccessUtils as accessUtils,
ActionUtils as actionUtils,
ActivationUtils as activationUtils,
ApiAdapterUtils as apiAdapterUtils,
BackgroundUtils as backgroundUtils,
CampaignUtils as campaignUtils,
CampaignSettingUtils as campaignSettingUtils,
CharacterUtils as characterUtils,
ChoiceUtils as choiceUtils,
ClassFeatureUtils as classFeatureUtils,
ClassUtils as classUtils,
ConditionUtils as conditionUtils,
ConditionLevelUtils as conditionLevelUtils,
ContainerUtils as containerUtils,
CoreUtils as coreUtils,
CreatureUtils as creatureUtils,
CreatureRuleUtils as creatureRuleUtils,
DataOriginUtils as dataOriginUtils,
DecorationUtils as decorationUtils,
DefinitionUtils as definitionUtils,
DefinitionPoolUtils as definitionPoolUtils,
DiceUtils as diceUtils,
DurationUtils as durationUtils,
EntityUtils as entityUtils,
ExtraUtils as extraUtils,
FeatUtils as featUtils,
FeatureFlagInfoUtils as featureFlagInfoUtils,
FeatureUtils as featureUtils,
FormatUtils as formatUtils,
HelperUtils as helperUtils,
InfusionChoiceUtils as infusionChoiceUtils,
InfusionUtils as infusionUtils,
ItemUtils as itemUtils,
KnownInfusionUtils as knownInfusionUtils,
LimitedUseUtils as limitedUseUtils,
ModifierUtils as modifierUtils,
NoteUtils as noteUtils,
OptionalClassFeatureUtils as optionalClassFeatureUtils,
OptionalOriginUtils as optionalOriginUtils,
OptionUtils as optionUtils,
OrganizationUtils as organizationUtils,
PdfUtils as pdfUtils,
PrerequisiteUtils as prerequisiteUtils,
RaceUtils as raceUtils,
RacialTraitUtils as racialTraitUtils,
RuleDataUtils as ruleDataUtils,
RuleDataPoolUtils as ruleDataPoolUtils,
SkillUtils as skillUtils,
SnippetUtils as snippetUtils,
SourceUtils as sourceUtils,
SpellUtils as spellUtils,
StartingEquipmentUtils as startingEquipmentUtils,
ValueUtils as valueUtils,
VehicleUtils as vehicleUtils,
VehicleComponentUtils as vehicleComponentUtils,
apiCreatorSelectors as api,
rulesEngineSelectors as s,
FeatList,
characterSelectors as char,
characterActions,
serviceDataSelectors as sds,
serviceDataActions,
} from "@dndbeyond/character-rules-engine";
/**
* A hook that provides access to the character rules engine state without
* having to import rules engine all over the place in your components. This
* reduces touchpoints to the rules engine and makes it easier to refactor the
* rules engine in the future.
*/
export const useCharacterEngine = () => ({
// Character Actions
characterActions,
serviceDataActions,
// Character Selectors that aren't passed thru to Rules Engine Selectors
characterStatusSlug: useSelector(char.getStatusSlug),
// Rules Engine Selector Values
abilities: useSelector(s.getAbilities),
abilityLookup: useSelector(s.getAbilityLookup),
abilityKey: useSelector(s.getAbilityKeyLookup),
actionLookup: useSelector(s.getActionLookup),
acAdjustments: useSelector(s.getAcAdjustments),
acSuppliers: useSelector(s.getArmorClassSuppliers),
acTotal: useSelector(s.getAcTotal),
actions: useSelector(s.getActions),
activatables: useSelector(s.getActivatables),
activeCharacterSpells: useSelector(s.getActiveCharacterSpells),
activeClassSpells: useSelector(s.getActiveClassSpells),
activeConditions: useSelector(s.getActiveConditions),
activeGroupedImmunities: useSelector(s.getActiveGroupedImmunities),
activeGroupedResistances: useSelector(s.getActiveGroupedResistances),
activeGroupedVulnerabilities: useSelector(s.getActiveGroupedVulnerabilities),
activeImmunities: useSelector(s.getActiveImmunities),
activeResistances: useSelector(s.getActiveResistances),
activeVulnerabilities: useSelector(s.getActiveVulnerabilities),
activeSource: useSelector(s.getActiveSourceLookup),
activeSourceCategories: useSelector(s.getActiveSourceCategories),
activeSources: useSelector(s.getActiveSources),
activeSpellAttackList: useSelector(s.getActiveSpellAttackList),
advantageSavingThrowModifiers: useSelector(
s.getAdvantageSavingThrowModifiers
),
alignment: useSelector(s.getAlignment),
allInventoryItems: useSelector(s.getAllInventoryItems),
armorSpeedAdjustmentAmount: useSelector(s.getArmorSpeedAdjustmentAmount),
attacks: useSelector(s.getAttacks),
attacksPerActionInfo: useSelector(s.getAttacksPerActionInfo),
attunedItems: useSelector(s.getAttunedItems),
attunedItemCountMax: useSelector(s.getAttunedItemCountMax),
attunedSlots: useSelector(s.getAttunedSlots),
availableInfusionChoices: useSelector(s.getAvailableInfusionChoices),
availablePactMagicSlotLevels: useSelector(s.getAvailablePactMagicSlotLevels),
availableSpellSlotLevels: useSelector(s.getAvailableSpellSlotLevels),
backgroundChoice: useSelector(s.getBackgroundChoiceLookup),
backgroundInfo: useSelector(s.getBackgroundInfo),
backgroundModifier: useSelector(s.getBackgroundModifierLookup),
backgroundModifiers: useSelector(s.getBackgroundModifiers),
baseClass: useSelector(s.getBaseClassLookup),
baseFeat: useSelector(s.getBaseFeatLookup),
baseFeats: useSelector(s.getBaseFeats),
bonusSavingThrowModifiers: useSelector(s.getBonusSavingThrowModifiers),
campaign: useSelector(s.getCampaign),
campaignSettings: useSelector(sds.getCampaignSettings),
carryingCapacity: useSelector(s.getCarryCapacity),
castableSpellSlotLevels: useSelector(s.getCastableSpellSlotLevels),
castablePactMagicSlotLevels: useSelector(s.getCastablePactMagicSlotLevels),
characterConfig: useSelector(s.getCharacterConfiguration),
characterGender: useSelector(s.getGender),
characterId: useSelector(s.getId),
characterInventoryContainers: useSelector(s.getCharacterInventoryContainers),
characterName: useSelector(s.getName),
characterNotes: useSelector(s.getCharacterNotes),
characterSpells: useSelector(s.getCharacterSpells),
characterTheme: useSelector(s.getCharacterTheme),
characterTraits: useSelector(s.getCharacterTraits),
choiceInfo: useSelector(s.getChoiceInfo),
classAction: useSelector(s.getClassActionLookup),
classAlwaysKnownSpells: useSelector(sds.getClassAlwaysKnownSpells),
classAlwaysPreparedSpells: useSelector(sds.getClassAlwaysPreparedSpells),
classChoice: useSelector(s.getClassChoiceLookup),
classCreatureRules: useSelector(s.getClassCreatureRules),
classFeature: useSelector(s.getClassFeatureLookup),
classMappingId: useSelector(s.getClassMappingIdLookupByActiveId),
classModifier: useSelector(s.getClassModifierLookup),
classOption: useSelector(s.getClassOptionLookup),
classSpell: useSelector(s.getClassSpellLookup),
classSpells: useSelector(s.getClassSpells),
classSpellInfo: useSelector(s.getClassSpellInfoLookup),
classSpellLists: useSelector(s.getClassSpellLists),
classSpellListSpells: useSelector(s.getClassSpellListSpellsLookup),
classes: useSelector(s.getClasses),
classesModifiers: useSelector(s.getClassesModifiers),
combinedMaxSpellSlotLevel: useSelector(s.getCombinedMaxSpellSlotLevel),
combinedSpellSlots: useSelector(s.getCombinedSpellSlots),
conModifier: useSelector(s.getConModifier),
conditionImmunityData: useSelector(s.getConditionImmunityData),
conditionModifier: useSelector(s.getConditionModifierLookup),
conditionModifiers: useSelector(s.getConditionModifiers),
container: useSelector(s.getContainerLookup),
containerCoinWeight: useSelector(s.getCointainerCoinWeight),
containerItemWeight: useSelector(s.getContainerItemWeight),
containerLookup: useSelector(s.getContainerLookup),
creature: useSelector(s.getCreatureLookup),
creatureGroupRules: useSelector(s.getCreatureGroupRulesLookup),
creatureInfusion: useSelector(s.getCreatureInfusionLookup),
creatureOwnerData: useSelector(s.getCreatureOwnerData),
creatureRules: useSelector(s.getCreatureRules),
creatures: useSelector(s.getCreatures),
currentCarriedWeightSpeed: useSelector(s.getCurrentCarriedWeightSpeed),
currentCarriedWeightType: useSelector(s.getCurrentCarriedWeightType),
currentLevel: useSelector(s.getCurrentLevel),
currentXp: useSelector(s.getCurrentXp),
currentLevelRacialTraits: useSelector(s.getCurrentLevelRacialTraits),
customActions: useSelector(s.getCustomActions),
customConditionAdjustments: useSelector(s.getCustomConditionAdjustments),
customDamageAdjustments: useSelector(s.getCustomDamageAdjustments),
customItems: useSelector(s.getCustomItems),
customImmunityDamageAdjustments: useSelector(
s.getCustomImmunityDamageAdjustments
),
customResistanceDamageAdjustments: useSelector(
s.getCustomResistanceDamageAdjustments
),
customVulnerabilityDamageAdjustments: useSelector(
s.getCustomVulnerabilityDamageAdjustments
),
customSkills: useSelector(s.getCustomSkills),
customSkillProficiencies: useSelector(s.getCustomSkillProficiencies),
damageImmunityData: useSelector(s.getDamageImmunityData),
deathSaveInfo: useSelector(s.getDeathSaveInfo),
deathCause: useSelector(s.getDeathCause),
decorationInfo: useSelector(s.getDecorationInfo),
dedicatedWeaponEnabled: useSelector(s.getDedicatedWeaponEnabled),
definitionPool: useSelector(sds.getDefinitionPool),
dexModifier: useSelector(s.getDexModifier),
disadvantageSavingThrowModifiers: useSelector(
s.getDisadvantageSavingThrowModifiers
),
encumberedWeight: useSelector(s.getEncumberedWeight),
entityRestrictionData: useSelector(s.getEntityRestrictionData),
entityValueLookup: useSelector(s.getCharacterValueLookupByEntity),
equippedItems: useSelector(s.getEquippedItems),
experienceInfo: useSelector(s.getExperienceInfo),
expertiseModifiers: useSelector(s.getExpertiseModifiers),
extras: useSelector(s.getExtras),
featLookup: useSelector(s.getFeatLookup),
feats: useSelector(s.getFeats),
featAction: useSelector(s.getFeatActionLookup),
featCreatureRules: useSelector(s.getFeatCreatureRules),
featModifier: useSelector(s.getFeatModifierLookup),
featModifiers: useSelector(s.getFeatModifiers),
featOption: useSelector(s.getFeatOptionLookup),
featChoice: useSelector(s.getFeatChoiceLookup),
gearWeaponItems: useSelector(s.getGearWeaponItems),
globalBackgroundSpellListIds: useSelector(s.getGlobalBackgroundSpellListIds),
globalRaceSpellListIds: useSelector(s.getGlobalRaceSpellListIds),
globalSpellListIds: useSelector(s.getGlobalSpellListIds),
hasInitiativeAdvantage: useSelector(s.getHasInitiativeAdvantage),
hasMaxAttunedItems: useSelector(s.hasMaxAttunedItems),
hasSpells: useSelector(s.hasSpells),
heavilyEncumberedWeight: useSelector(s.getHeavilyEncumberedWeight),
hexWeaponEnabled: useSelector(s.getHexWeaponEnabled),
highestEquippedArmorAC: useSelector(s.getHighestAcEquippedArmor),
highestEquippedShieldAC: useSelector(s.getHighestAcEquippedShield),
hpInfo: useSelector(s.getHitPointInfo),
improvedPactWeaponEnabled: useSelector(s.getImprovedPactWeaponEnabled),
infusions: useSelector(s.getInfusions),
infusionChoices: useSelector(s.getInfusionChoices),
infusionChoiceLookup: useSelector(s.getInfusionChoiceLookup),
infusionChoiceInfusion: useSelector(s.getInfusionChoiceInfusionLookup),
infusionMappings: useSelector(sds.getInfusionsMappings),
innateNaturalActions: useSelector(s.getInnateNaturalActions),
inventory: useSelector(s.getInventory),
inventoryContainers: useSelector(s.getInventoryContainers),
inventoryLookup: useSelector(s.getInventoryLookup),
inventoryInfusion: useSelector(s.getInventoryInfusionLookup),
isSheetReady: useSelector(s.isCharacterSheetReady),
isDead: useSelector(s.isDead),
isMulticlassCharacter: useSelector(s.isMulticlassCharacter),
itemAttacks: useSelector(s.getItemAttacks),
itemModifier: useSelector(s.getItemModifierLookup),
itemSpell: useSelector(s.getItemSpellLookup),
kenseiModifiers: useSelector(s.getKenseiModifiers),
knownInfusion: useSelector(s.getKnownInfusionLookup),
knownInfusionByChoice: useSelector(s.getKnownInfusionLookupByChoiceKey),
knownInfusions: useSelector(s.getKnownInfusions),
knownInfusionMappings: useSelector(sds.getKnownInfusionsMappings),
knownReplicatedItems: useSelector(s.getKnownReplicatedItems),
languages: useSelector(s.getLanguages),
languageModifiers: useSelector(s.getLanguageModifiers),
levelSpells: useSelector(s.getLevelSpells),
lifestyle: useSelector(s.getLifestyle),
loadSpecies: useSelector(api.makeLoadAvailableRaces),
maxPactMagicSlotLevel: useSelector(s.getMaxPactMagicSlotLevel),
maxSpellSlotLevel: useSelector(s.getMaxSpellSlotLevel),
miscModifiers: useSelector(s.getMiscModifiers),
modifierData: useSelector(s.getModifierData),
optionalClassFeature: useSelector(s.getOptionalClassFeatureLookup),
optionalClassFeatures: useSelector(s.getOptionalClassFeatures),
optionalOrigin: useSelector(s.getOptionalOriginLookup),
optionalOrigins: useSelector(s.getOptionalOrigins),
originRef: useSelector(s.getDataOriginRefData),
originRefBackground: useSelector(s.getDataOriginRefBackgroundData),
originRefClass: useSelector(s.getDataOriginRefClassData),
originRefCondition: useSelector(s.getDataOriginRefConditionData),
originRefFeat: useSelector(s.getDataOriginRefFeatData),
originRefFeatList: useSelector(s.getDataOriginRefFeatListData),
originRefItem: useSelector(s.getDataOriginRefItemData),
originRefRace: useSelector(s.getDataOriginRefRaceData),
originRefVehicle: useSelector(s.getDataOriginRefVehicleData),
overallSpellInfo: useSelector(s.getOverallSpellInfo),
overridePassiveInsight: useSelector(s.getOverridePassiveInsight),
overridePassiveInvestigation: useSelector(s.getOverridePassiveInvestigation),
overridePassivePerception: useSelector(s.getOverridePassivePerception),
pactMagicConditiones: useSelector(s.getPactMagicClasses),
pactMagicSlots: useSelector(s.getPactMagicSlots),
pactWeaponEnabled: useSelector(s.getPactWeaponEnabled),
partyInfo: useSelector(sds.getPartyInfo),
partyInventory: useSelector(s.getPartyInventory),
partyInventoryContainers: useSelector(s.getPartyInventoryContainers),
partyInventoryItem: useSelector(s.getPartyInventoryLookup),
passiveInsight: useSelector(s.getPassiveInsight),
passiveInvestigation: useSelector(s.getPassiveInvestigation),
passivePerception: useSelector(s.getPassivePerception),
pdfBucket1: useSelector(s.getPdfDataBucket1),
pdfBucket2: useSelector(s.getPdfDataBucket2),
pdfBucket3: useSelector(s.getPdfDataBucket3),
pdfBucket4: useSelector(s.getPdfDataBucket4),
pdfBucket5: useSelector(s.getPdfDataBucket5),
pdfBucket6: useSelector(s.getPdfDataBucket6),
pdfExportData: useSelector(s.getPdfExportData),
playerId: useSelector(s.getUserId),
playerName: useSelector(s.getUsername),
preferences: useSelector(s.getCharacterPreferences),
prerequisiteData: useSelector(s.getPrerequisiteData),
processedInitiative: useSelector(s.getProcessedInitiative),
proficiency: useSelector(s.getProficiencyLookup),
proficiencyBonus: useSelector(s.getProficiencyBonus),
proficiencyGroups: useSelector(s.getProficiencyGroups),
proficiencyModifiers: useSelector(s.getProficiencyModifiers),
protectionSuppliers: useSelector(s.getProtectionSuppliers),
pushDragLiftWeight: useSelector(s.getPushDragLiftWeight),
race: useSelector(s.getRace),
raceAction: useSelector(s.getRaceActionLookup),
raceChoice: useSelector(s.getRaceChoiceLookup),
raceCreatureRules: useSelector(s.getRaceCreatureRules),
raceModifier: useSelector(s.getRaceModifierLookup),
raceModifiers: useSelector(s.getRaceModifiers),
raceOption: useSelector(s.getRaceOptionLookup),
raceSpell: useSelector(s.getRaceSpellLookup),
requiredCharacterServiceParams: useSelector(
s.getRequiredCharacterServiceParams
),
requiredGameDataServiceParams: useSelector(
s.getRequiredGameDataServiceParams
),
resistanceData: useSelector(s.getResistanceData),
restrictedBonusSavingThrowModifiers: useSelector(
s.getRestrictedBonusSavingThrowModifiers
),
ritualSpells: useSelector(s.getRitualSpells),
ruleData: useSelector(s.getRuleData),
ruleDataPool: useSelector(sds.getRuleDataPool),
savingThrowDiceAdjustments: useSelector(s.getSavingThrowDiceAdjustments),
senseInfo: useSelector(s.getSenseInfo),
situationalBonusSavingThrowsLookup: useSelector(
s.getSituationalBonusSavingThrowsLookup
),
situationalBonusSavingThrows: useSelector(s.getSituationalBonusSavingThrows),
size: useSelector(s.getSize),
skill: useSelector(s.getSkillLookup),
skills: useSelector(s.getSkills),
snippetData: useSelector(s.getSnippetData),
socialImageData: useSelector(s.getSocialImageData),
specialWeaponPropertiesEnabled: useSelector(
s.hack__getSpecialWeaponPropertiesEnabled
),
speeds: useSelector(s.getSpeeds),
spellClasses: useSelector(s.getSpellClasses),
spellcasterInfo: useSelector(s.getSpellCasterInfo),
spellcastingClasses: useSelector(s.getSpellcastingClasses),
spellListDataOrigin: useSelector(s.getSpellListDataOriginLookup),
spellSlots: useSelector(s.getSpellSlots),
startingClass: useSelector(s.getStartingClass),
strScore: useSelector(s.getStrScore),
totalCarriedWeight: useSelector(s.getTotalCarriedWeight),
totalClassLevel: useSelector(s.getTotalClassLevel),
uniqueProficiencyModifiers: useSelector(s.getUniqueProficiencyModifiers),
validEquipmentModifiers: useSelector(s.getValidEquipmentModifiers),
validGlobalModifiers: useSelector(s.getValidGlobalModifiers),
validImmunityModifiers: useSelector(s.getValidImmunityModifiers),
validResistanceModifiers: useSelector(s.getValidResistanceModifiers),
validVulnerabilityModifiers: useSelector(s.getValidVulnerabilityModifiers),
valueLookup: useSelector(s.getCharacterValueLookup),
vehicle: useSelector(s.getVehicleLookup),
vehicles: useSelector(s.getVehicles),
vehicleComponent: useSelector(s.getVehicleComponentLookup),
vehicleComponentMappings: useSelector(sds.getVehicleComponentMappings),
vehicleMappings: useSelector(sds.getVehicleMappings),
vulnerabilityData: useSelector(s.getVulnerabilityData),
weaponSpellDamageGroups: useSelector(s.getWeaponSpellDamageGroups),
weightSpeeds: useSelector(s.getWeightSpeeds),
// Utility Functions
abilityUtils,
accessUtils,
actionUtils,
activationUtils,
apiAdapterUtils,
backgroundUtils,
campaignUtils,
campaignSettingUtils,
characterUtils,
choiceUtils,
classFeatureUtils,
classUtils,
conditionUtils,
conditionLevelUtils,
containerUtils,
coreUtils,
creatureUtils,
creatureRuleUtils,
dataOriginUtils,
decorationUtils,
definitionUtils,
definitionPoolUtils,
diceUtils,
durationUtils,
entityUtils,
extraUtils,
featUtils,
featureFlagInfoUtils,
featureUtils,
formatUtils,
helperUtils,
infusionChoiceUtils,
infusionUtils,
itemUtils,
knownInfusionUtils,
limitedUseUtils,
modifierUtils,
noteUtils,
optionalClassFeatureUtils,
optionalOriginUtils,
optionUtils,
organizationUtils,
pdfUtils,
prerequisiteUtils,
raceUtils,
racialTraitUtils,
ruleDataUtils,
ruleDataPoolUtils,
skillUtils,
snippetUtils,
sourceUtils,
spellUtils,
startingEquipmentUtils,
valueUtils,
vehicleUtils,
vehicleComponentUtils,
// Misc
FeatList,
});

View File

@ -0,0 +1,77 @@
import { useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { characterSelectors } from "@dndbeyond/character-rules-engine/es";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import {
claimCharacter as claimCharacterApi,
joinCampaign as joinCampaignApi,
} from "../helpers/characterServiceApi";
interface UseClaimCharacterProps {
campaignJoinCode: string | null;
isAssigned: boolean;
}
export const useClaimCharacter = ({
campaignJoinCode,
isAssigned,
}: UseClaimCharacterProps) => {
const [isFinished, setIsFinished] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [newCharacterId, setNewCharacterId] = useState<number | null>(null);
const [campaignId, setCampaignId] = useState<number | null>(null);
const characterId = useSelector(characterSelectors.getId);
const dispatch = useDispatch();
const claimCharacter = useCallback(async () => {
try {
setIsLoading(true);
let clonedId;
const claimResponse = await claimCharacterApi(characterId, isAssigned);
if (claimResponse.ok) {
const data = await claimResponse.json();
clonedId = data.id;
setNewCharacterId(clonedId);
} else {
throw new Error(`There was an error claiming character ${characterId}`);
}
if (campaignJoinCode) {
const joinResponse = await joinCampaignApi(campaignJoinCode, clonedId);
if (joinResponse.ok) {
const data = await joinResponse.json();
setCampaignId(data.campaignId);
} else {
throw new Error(
`There was an error adding character ${clonedId} to campaign join code ${campaignJoinCode}`
);
}
}
} catch (error) {
console.error(error);
dispatch(
toastMessageActions.toastError("Error", "An unexpected error occurred.")
);
} finally {
setIsLoading(false);
setIsFinished(true);
}
}, [campaignJoinCode, characterId, dispatch, isAssigned]);
return [
claimCharacter,
isLoading,
isFinished,
newCharacterId,
campaignId,
] as const;
};

View File

@ -0,0 +1,33 @@
import { HTMLAttributes, useState } from "react";
import styles from '../../styles/errors.module.css';
export interface ErrorHandlerOptions {
initialState: boolean;
errMsg: string;
errorMessageAttributes?: HTMLAttributes<HTMLDivElement>;
};
export interface ErrorHandler {
showError: boolean;
setShowError: (value: boolean) => void;
ErrorMessage: () => JSX.Element;
};
export const useErrorHandling = (
initialState: boolean,
errMsg: string,
errorMessageAttributes: HTMLAttributes<HTMLDivElement> | null = null
): ErrorHandler => {
const [showError, setShowError] = useState(initialState);
const ErrorMessage = (): JSX.Element => (
<div className={styles.inputError} {...errorMessageAttributes}>
{errMsg}
</div>
);
return {
showError,
setShowError,
ErrorMessage,
};
};

View File

@ -0,0 +1,49 @@
import { HTMLAttributes } from "react";
import { useErrorHandling } from "./useErrorHandling";
export interface MaxLengthErrorHandler {
handleMaxLengthErrorMsg: (value: string) => void;
hideError: () => void;
MaxLengthErrorMessage: () => JSX.Element;
};
export const useMaxLengthErrorHandling = (
initialState: boolean,
maxLength: number | null,
errMsg: string = "",
errorMessageAttributes?: HTMLAttributes<HTMLDivElement>
): MaxLengthErrorHandler => {
errMsg ||= `The max length is ${maxLength} characters.`;
const {
showError,
setShowError,
ErrorMessage,
} = useErrorHandling(initialState, errMsg, errorMessageAttributes);
const handleMaxLengthErrorMsg = (value: string): void => {
if (!maxLength) {
// Skip if maxLength is 0 or not set.
return;
}
const isTooLong = (value?.length ?? 0) >= (maxLength ?? 0);
if (isTooLong !== showError) {
setShowError(isTooLong);
}
};
const hideError = () => {
setShowError(false);
}
const MaxLengthErrorMessage = (): JSX.Element => showError && errMsg
? (<ErrorMessage />)
: <></>;
return {
handleMaxLengthErrorMsg,
MaxLengthErrorMessage,
hideError
};
}

View File

@ -0,0 +1,26 @@
import { useContext, useEffect, useState } from "react";
import {
ExtraManager,
FeaturesManager,
} from "@dndbeyond/character-rules-engine/es";
import { ExtrasManagerContext } from "~/tools/js/Shared/managers/ExtrasManagerContext";
//TODO: need to look at subscribeToUpdates for how many times its being called on state updates
export function useExtras() {
const { extrasManager } = useContext(ExtrasManagerContext);
const [extras, setExtras] = useState<Array<ExtraManager>>(
extrasManager.getCharacterExtraManagers()
);
useEffect(() => {
const onUpdate = () => {
setExtras(extrasManager.getCharacterExtraManagers());
};
return FeaturesManager.subscribeToUpdates({ onUpdate });
}, [extrasManager]);
return extras;
}

View File

@ -0,0 +1,59 @@
import {
useState,
useEffect,
useCallback,
Dispatch,
SetStateAction,
} from "react";
import { tryGet, tryRemove, trySet } from "../helpers/localStorageUtils";
//got most of the typing for this at https://usehooks-ts.com/react-hook/use-local-storage
type SetValue<T> = Dispatch<SetStateAction<T>>;
type RemoveValue<T> = Dispatch<SetStateAction<T>>;
function useLocalStorage<T>(
key: string,
defaultValue: T
): [T, SetValue<T>, RemoveValue<T>] {
const getValue = useCallback(() => {
const savedValue = tryGet(key);
return savedValue === null
? (defaultValue as T)
: (parseJSON(savedValue) as T);
}, [key, defaultValue]);
const [value, setValue] = useState(getValue());
useEffect(() => {
setValue(getValue());
}, [getValue]);
const set = useCallback(
(newValue) => {
setValue(newValue);
trySet(key, newValue);
},
[key, setValue]
);
const remove = () => {
setValue(defaultValue as T);
tryRemove(key);
};
return [value, set, remove];
}
export default useLocalStorage;
// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: any): T | undefined {
try {
return value === "undefined" ? undefined : JSON.parse(value ?? "");
} catch (error) {
console.log("parsing error on", { value });
return value;
}
}

View File

@ -0,0 +1,58 @@
import { useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
ApiAdapterUtils,
ApiRequests,
characterActions,
rulesEngineSelectors,
syncTransactionActions,
} from "@dndbeyond/character-rules-engine";
import { toastMessageActions } from "~/tools/js/Shared/actions";
export interface ExportPdfData {
exportPdf: () => void;
pdfUrl: string | null;
isLoading: boolean;
isFinished: boolean;
}
export const usePdfExport = (): ExportPdfData => {
const [isFinished, setIsFinished] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const pdfData = useSelector(rulesEngineSelectors.getPdfExportData);
const dispatch = useDispatch();
const exportPdf = useCallback(async () => {
try {
setIsLoading(true);
let syncActivationId: string = "PDF-SYNC";
dispatch(syncTransactionActions.activate(syncActivationId));
//This will update the character's spell data before export is created
//(in case a user is in the builder and has not updated their class spells)
dispatch(characterActions.loadLazyCharacterData());
const response = await ApiRequests.postCharacterPdf({
exportData: JSON.stringify(pdfData),
});
const pdfUrl = ApiAdapterUtils.getResponseData(response);
if (pdfUrl) {
setPdfUrl(pdfUrl);
dispatch(syncTransactionActions.deactivate());
}
} catch (error) {
dispatch(
toastMessageActions.toastError("Error", "An unexpected error occurred.")
);
} finally {
setIsLoading(false);
setIsFinished(true);
}
}, []);
return { exportPdf, pdfUrl, isLoading, isFinished };
};

View File

@ -0,0 +1,62 @@
import { useSelector } from "react-redux";
import { useSidebar } from "~/contexts/Sidebar";
import {
SidebarAlignmentEnum,
SidebarPlacementEnum,
SidebarPositionInfo,
} from "~/subApps/sheet/components/Sidebar/types";
import { SheetPositioningInfo } from "~/tools/js/CharacterSheet/typings";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
export const usePositioning = () => {
const {
sidebar: { isVisible, placement, alignment, width },
} = useSidebar();
const getSheetPositioning = (): SheetPositioningInfo => {
let offset: number = 0;
if (!isVisible) {
return { offset, left: offset };
}
if (placement === SidebarPlacementEnum.FIXED) {
offset = width / 2;
}
return {
offset,
left: alignment === SidebarAlignmentEnum.LEFT ? offset : -1 * offset,
};
};
const getSidebarPositioning = (): SidebarPositionInfo => {
const sheetDimensions = useSelector(appEnvSelectors.getDimensions);
const sheetPosition = getSheetPositioning();
const { sheet, window } = sheetDimensions;
let posX: number = 0;
let sidebarGutter: number = 15;
let screenGutterSize: number = (window.width - sheet.width) / 2;
if (placement === SidebarPlacementEnum.FIXED) {
posX = screenGutterSize + sheetPosition.offset - sidebarGutter - width;
} else {
screenGutterSize -= sidebarGutter;
let overflowOffset: number =
width > screenGutterSize ? width - screenGutterSize : 0;
posX = screenGutterSize - width + overflowOffset;
}
return {
left: alignment === SidebarAlignmentEnum.LEFT ? posX : "auto",
right: alignment === SidebarAlignmentEnum.RIGHT ? posX : "auto",
};
};
return {
getSheetPositioning,
getSidebarPositioning,
};
};

View File

@ -0,0 +1,104 @@
import { useSelector } from "react-redux";
import {
RuleDataUtils as ruleDataUtils,
ruleDataSelectors as s,
} from "@dndbeyond/character-rules-engine";
/**
* A hook that provides access to the rule data state without having to
* import rules engine all over the place in your components. This reduces
* touchpoints to the rules engine and makes it easier to refactor the rules
* engine in the future.
*/
export const useRuleData = () => ({
// Export all data coming from the RuleData selectors
allData: useSelector(s.getAllData),
abilityScoreDisplayTypes: useSelector(s.getAbilityScoreDisplayTypes),
abilitySkills: useSelector(s.getAbilitySkills),
activationTypes: useSelector(s.getActivationTypes),
additionalLevelTypes: useSelector(s.getAdditionalLevelTypes),
adjustmentDataTypes: useSelector(s.getAdjustmentDataTypes),
adjustmentTypes: useSelector(s.getAdjustmentTypes),
alignments: useSelector(s.getAlignments),
aoeTypes: useSelector(s.getAoeTypes),
armor: useSelector(s.getArmor),
basicActions: useSelector(s.getBasicActions),
baseWeaponReach: useSelector(s.getBaseWeaponReach),
basicMaxStatScore: useSelector(s.getBasicMaxStatScore),
challengeRatings: useSelector(s.getChallengeRatings),
conditionTypes: useSelector(s.getConditionTypes),
conditions: useSelector(s.getConditions),
creatureGroupCategories: useSelector(s.getCreatureGroupCategories),
creatureGroupFlags: useSelector(s.getCreatureGroupFlags),
creatureGroups: useSelector(s.getCreatureGroups),
creatureSizes: useSelector(s.getCreatureSizes),
currencyData: useSelector(s.getCurrencyData),
damageAdjustments: useSelector(s.getDamageAdjustments),
damageTypes: useSelector(s.getDamageTypes),
defaultArmorImageUrl: useSelector(s.getDefaultArmorImageUrl),
defaultAttunedItemCountMax: useSelector(s.getDefaultAttunedItemCountMax),
defaultGearImageUrl: useSelector(s.getDefaultGearImageUrl),
defaultRacePortraitUrl: useSelector(s.getDefaultRacePortraitUrl),
defaultWeaponImageUrl: useSelector(s.getDefaultWeaponImageUrl),
diceValues: useSelector(s.getDiceValues),
environments: useSelector(s.getEnvironments),
initiativeScore: useSelector(s.getInitiativeScore),
languageTypeId: useSelector(s.getLanguageTypeId),
languages: useSelector(s.getLanguages),
levelExperiencePoints: useSelector(s.getLevelExperiencePoints),
levelProficiencyBonuses: useSelector(s.getLevelProficiencyBonuses),
lifestyles: useSelector(s.getLifestyles),
limitedUseResetTypes: useSelector(s.getLimitedUseResetTypes),
longRestMinHitDiceUsedRecovered: useSelector(
s.getLongRestMinimumHitDiceUsedRecovered
),
maxAttunedItemCountMax: useSelector(s.getMaxAttunedItemCountMax),
maxCharacterLevel: useSelector(s.getMaxCharacterLevel),
maxDeathsavesFail: useSelector(s.getMaxDeathsavesFail),
maxDeathsavesSuccess: useSelector(s.getMaxDeathsavesSuccess),
maxSpellLevel: useSelector(s.getMaxSpellLevel),
maxStatScore: useSelector(s.getMaxStatScore),
minAttunedItemCountMax: useSelector(s.getMinAttunedItemCountMax),
minStatScore: useSelector(s.getMinStatScore),
minimumHpTotal: useSelector(s.getMinimumHpTotal),
minimumLimitedUseMaxUse: useSelector(s.getMinimumLimitedUseMaxUse),
monsterSubTypes: useSelector(s.getMonsterSubTypes),
monsterTypes: useSelector(s.getMonsterTypes),
movements: useSelector(s.getMovements),
multiClassSpellSlots: useSelector(s.getMultiClassSpellSlots),
naturalActions: useSelector(s.getNaturalActions),
noArmorAcAmount: useSelector(s.getNoArmorAcAmount),
pactMagicMultiClassSpellSlots: useSelector(
s.getPactMagicMultiClassSpellSlots
),
privacyTypes: useSelector(s.getPrivacyTypes),
proficiencyGroups: useSelector(s.getProficiencyGroups),
raceGroups: useSelector(s.getRaceGroups),
rangeTypes: useSelector(s.getRangeTypes),
ritualCastingTimeMinuteAddition: useSelector(
s.getRitualCastingTimeMinuteAddition
),
rules: useSelector(s.getRules),
senses: useSelector(s.getSenses),
sharingTypes: useSelector(s.getSharingTypes),
sourceCategories: useSelector(s.getSourceCategories),
sources: useSelector(s.getSources),
spellComponents: useSelector(s.getSpellComponents),
spellConditionTypes: useSelector(s.getSpellConditionTypes),
spellRangeTypes: useSelector(s.getSpellRangeTypes),
statModifiers: useSelector(s.getStatModifiers),
stats: useSelector(s.getStats),
stealthCheckTypes: useSelector(s.getStealthCheckTypes),
stringMartialArts: useSelector(s.getStringMartialArts),
stringPactMagic: useSelector(s.getStringPactMagic),
stringSpellCasting: useSelector(s.getStringSpellCasting),
stringSpellEldritchBlast: useSelector(s.getStringSpellEldritchBlast),
weapons: useSelector(s.getWeapons),
weaponCategories: useSelector(s.getWeaponCategories),
weaponProperties: useSelector(s.getWeaponProperties),
weaponPropertyReachDistance: useSelector(s.getWeaponPropertyReachDistance),
// Export all utils from RuleDataUtils
ruleDataUtils,
});

179
ddb_main/hooks/useSource.ts Normal file
View File

@ -0,0 +1,179 @@
import { orderBy } from "lodash";
import {
BuilderChoiceOptionContract,
EntityRestrictionData,
HelperUtils,
HtmlSelectOptionGroup,
RuleDataUtils,
SimpleSourcedDefinitionContract,
SourceMappingContract,
SourceUtils,
} from "@dndbeyond/character-rules-engine";
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
import { useCharacterEngine } from "./useCharacterEngine";
import { useRuleData } from "./useRuleData";
interface SourceCategoryGroup<T> {
name: string;
id: number;
items: Array<T>;
sortOrder: number | undefined;
}
type SourceCategoryKey =
| "avatarUrl"
| "description"
| "id"
| "isEnabledByDefault"
| "isHideable"
| "isPartneredContent"
| "isToggleable"
| "name";
export const useSource = () => {
const {
ruleData,
helperUtils: { lookupDataOrFallback },
ruleDataUtils: { getSourceCategoryLookup },
} = useCharacterEngine();
const {
ruleDataUtils: { getSourceDataLookup },
} = useRuleData();
const allSources = getSourceDataLookup(ruleData);
const allSourceCategories = getSourceCategoryLookup(ruleData);
// Returns the source ID from a given source
const getSourceId = (source: SourceMappingContract) => source?.sourceId;
const getSource = (sourceId: number | string) =>
lookupDataOrFallback(allSources, sourceId);
const getSourceName = (sourceId: number | string) =>
lookupDataOrFallback(allSources, sourceId)?.name;
const getSourceDescription = (sourceId: number | string) =>
lookupDataOrFallback(allSources, sourceId)?.description;
// Source Category direct access with category id
const getSourceCategory = (sourceCategoryId: number) =>
lookupDataOrFallback(allSourceCategories, sourceCategoryId);
const getSourceCategoryDescription = (sourceCategoryId: number) =>
getSourceCategory(sourceCategoryId)?.description;
// Takes an array of any entity that contains a sources key and returns an array of sorted SourceCategoryGroup representing each Source Category with the category data and an array of the entities that belong to that category.
const getSourceCategoryGroups = <
T extends {
sources: SourceMappingContract[] | null;
}
>(
items: Array<T>
): Array<SourceCategoryGroup<T>> => {
let groups: Array<SourceCategoryGroup<T>> = [];
items.forEach((item) => {
const sourceContracts = item.sources ?? [];
//get the source data for each source contract
const sources = sourceContracts
.map((source) =>
HelperUtils.lookupDataOrFallback(
RuleDataUtils.getSourceDataLookup(ruleData),
source.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
//get the source category name for the first source contract or default to Homebrew
const sourceCategoryName =
sources.length > 0 &&
sources[0].sourceCategory &&
sources[0].sourceCategory.name
? sources[0].sourceCategory.name
: "Homebrew";
// Search the availableGroupedOptions array for the source category
const index = groups.findIndex(
(element) => element.name === sourceCategoryName
);
// If the source category wasn't found, add it as a new option group.
if (index < 0) {
groups.push({
name: sourceCategoryName,
id: sources[0]?.sourceCategory?.id || 0,
sortOrder: sources[0]?.sourceCategory?.sortOrder,
items: [item],
});
// Otherwise, add the option to the existing source category's array of options.
} else {
groups[index].items.push(item);
}
});
return orderBy(groups, "sortOrder");
};
// Takes an array of any entity's DefinitionContract and returns an array of sorted HtmlSelectOptionGroup representing each Source Category and a sorted array of options.
const getGroupedOptionsBySourceCategory = <
T extends {
sources: SourceMappingContract[] | null;
name: string | null;
id: number;
description?: string | null;
}
>(
items: Array<T>,
optionValue?: number | null,
entityRestrictionData?: EntityRestrictionData,
labelFallback?: string
): HtmlSelectOptionGroup[] => {
return SourceUtils.getGroupedOptionsBySourceCategory(
items,
ruleData,
optionValue,
entityRestrictionData,
labelFallback
);
};
//Given an array of choiceOptionContracts, return an array of SimpleSourcedDefinitionContracts (these are used to create grouped options in a select dropdown)
const getSimpleSourcedDefinitionContracts = (
choiceOptionContracts: Array<BuilderChoiceOptionContract>
): Array<SimpleSourcedDefinitionContract> => {
return SourceUtils.getSimpleSourcedDefinitionContracts(
choiceOptionContracts
);
};
// Compares a source's category to a given match and returns a boolean value
const matchSourceCategory = (
sourceId: number,
match: string,
key: SourceCategoryKey = "name"
) => {
if (!sourceId) return false;
const source = lookupDataOrFallback(allSources, sourceId);
if (!source) return false;
if (source.sourceCategory?.[key] === match) return true;
};
return {
allSources,
getSource,
getSourceId,
getSourceCategory,
getSourceCategoryDescription,
getSourceDescription,
getSourceName,
matchSourceCategory,
getGroupedOptionsBySourceCategory,
getSimpleSourcedDefinitionContracts,
getSourceCategoryGroups,
};
};

View File

@ -0,0 +1,26 @@
import { useState, useEffect } from "react";
import { getSubscriptionTier } from "../helpers/userApi";
export const FREE_TIER = "free";
export const HERO_TIER = "hero";
export const MASTER_TIER = "master";
export const LEGENDARY_TIER = "legendary";
const useSubscriptionTier = (defaultSubscription?: string) => {
const [subscriptionTier, setSubscriptionTier] = useState<string | undefined>(
defaultSubscription
);
useEffect(() => {
getSubscriptionTier().then((tier) => {
if (tier) {
setSubscriptionTier(tier as unknown as string);
}
});
}, []);
return (subscriptionTier || FREE_TIER).toLowerCase();
};
export default useSubscriptionTier;

View File

@ -0,0 +1,21 @@
import { MouseEvent, useCallback } from "react";
/**
* Prevents click events from propagating to parent elements.
* @param onClick The function to call when the element is clicked.
* @returns A function that will prevent the click event from propagating.
*/
export const useUnpropagatedClick = (
onClick?: (event: MouseEvent, ...args: unknown[]) => void
) => {
const handleClick = useCallback(
(event: MouseEvent, ...args: unknown[]) => {
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
onClick?.(event, ...args);
},
[onClick]
);
return handleClick;
};

42
ddb_main/hooks/useUser.ts Normal file
View File

@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import type { User } from "@dndbeyond/authentication-lib-js";
import { getUser } from "../helpers/userApi";
export interface AppUser extends User {
displayName: string;
id: string;
name: string;
roles: Array<string>;
subscription: string;
subscriptionTier: string;
}
/**
* AppUserState has three states:
* - undefined: We currently don't know if the user is authenticated or not. ie loading state
* - null: user is not authenticated
* - User: user is authenticated and is a registered user
*/
const useUserInitialState = undefined;
type UseUserUnauthenticated = null;
export type AppUserState =
| AppUser
| UseUserUnauthenticated
| typeof useUserInitialState;
const useUser = () => {
const [user, setUser] = useState<AppUserState>(useUserInitialState);
useEffect(() => {
getUser()
.then(setUser)
.catch(() => {
setUser(null);
});
}, []);
return user;
};
export default useUser;

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from "react";
import { getUserId } from "../helpers/userApi";
const useUserId = (defaultUserId?: number) => {
const [userId, setUserId] = useState<number | undefined>(defaultUserId);
useEffect(() => {
getUserId().then((user) => {
setUserId(user as unknown as number);
});
}, []);
return userId;
};
export default useUserId;

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
import { getUserDisplayName } from "../helpers/userApi";
const useUserName = () => {
const [userName, setUserName] = useState<string | null>("");
useEffect(() => {
getUserDisplayName()
.then(setUserName)
.catch(() => {
setUserName(null);
});
}, []);
return userName;
};
export default useUserName;

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react";
import { getRoles } from "../helpers/userApi";
const useUserRoles = () => {
const [userRoles, setUserRoles] = useState<string[]>([]);
useEffect(() => {
getRoles()
.then(setUserRoles)
.catch(() => {
setUserRoles([]);
});
}, []);
return userRoles;
};
export default useUserRoles;

69
ddb_main/index.tsx Normal file
View File

@ -0,0 +1,69 @@
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter } from "react-router-dom";
import { FontProvider } from "@dndbeyond/ftui-components";
import "@dndbeyond/ttui/styles/palette.css";
import wayOfLight from "@dndbeyond/ttui/themes/wayOfLight";
import { Layout } from "./components/Layout";
import { AuthProvider } from "./contexts/Authentication";
import { FeatureFlagProvider } from "./contexts/FeatureFlag";
import { FiltersProvider } from "./contexts/Filters";
import { HeadContextProvider } from "./contexts/Head";
import { ThemeManagerProvider } from "./contexts/ThemeManager";
import { reportWebVitals } from "./helpers/reportWebVitals";
import { Routes } from "./routes";
import "./styles/global.css";
import "./styles/index.scss";
import "./styles/reset.css";
import "./styles/variables.css";
import "./styles/waterdeep-stat-blocks.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 60 * 1000 } },
});
const fonts = [
{
family: "Tiamat Condensed SC",
type: "woff2",
url: "https://www.dndbeyond.com/fonts/tiamatcondensedsc-regular-webfont.woff2",
},
{
family: "MrsEavesSmallCaps",
type: "truetype",
url: "https://www.dndbeyond.com/content/skins/waterdeep/fonts/MrsEavesSmallCaps.ttf",
},
];
const App = () => (
<>
<style>{wayOfLight}</style>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<ThemeManagerProvider manager={{ lightOrDark: "light" }}>
<FontProvider fonts={fonts} />
<HeadContextProvider>
<AuthProvider>
<FeatureFlagProvider>
<FiltersProvider>
<Layout>
<Routes />
</Layout>
</FiltersProvider>
</FeatureFlagProvider>
</AuthProvider>
</HeadContextProvider>
</ThemeManagerProvider>
</QueryClientProvider>
</BrowserRouter>
</>
);
ReactDOM.render(<App />, document.getElementById("character-tools-target"));
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

13
ddb_main/node_modules/@babel/polyfill/lib/index.js generated vendored Normal file
View File

@ -0,0 +1,13 @@
"use strict";
require("./noConflict");
var _global = _interopRequireDefault(require("core-js/library/fn/global"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
if (_global["default"]._babelPolyfill && typeof console !== "undefined" && console.warn) {
console.warn("@babel/polyfill is loaded more than once on this page. This is probably not desirable/intended " + "and may have consequences if different versions of the polyfills are applied sequentially. " + "If you do need to load the polyfill more than once, use @babel/polyfill/noConflict " + "instead to bypass the warning.");
}
_global["default"]._babelPolyfill = true;

View File

@ -0,0 +1,29 @@
"use strict";
require("core-js/es6");
require("core-js/fn/array/includes");
require("core-js/fn/array/flat-map");
require("core-js/fn/string/pad-start");
require("core-js/fn/string/pad-end");
require("core-js/fn/string/trim-start");
require("core-js/fn/string/trim-end");
require("core-js/fn/symbol/async-iterator");
require("core-js/fn/object/get-own-property-descriptors");
require("core-js/fn/object/values");
require("core-js/fn/object/entries");
require("core-js/fn/promise/finally");
require("core-js/web");
require("regenerator-runtime/runtime");

View File

@ -0,0 +1,139 @@
require('../modules/es6.symbol');
require('../modules/es6.object.create');
require('../modules/es6.object.define-property');
require('../modules/es6.object.define-properties');
require('../modules/es6.object.get-own-property-descriptor');
require('../modules/es6.object.get-prototype-of');
require('../modules/es6.object.keys');
require('../modules/es6.object.get-own-property-names');
require('../modules/es6.object.freeze');
require('../modules/es6.object.seal');
require('../modules/es6.object.prevent-extensions');
require('../modules/es6.object.is-frozen');
require('../modules/es6.object.is-sealed');
require('../modules/es6.object.is-extensible');
require('../modules/es6.object.assign');
require('../modules/es6.object.is');
require('../modules/es6.object.set-prototype-of');
require('../modules/es6.object.to-string');
require('../modules/es6.function.bind');
require('../modules/es6.function.name');
require('../modules/es6.function.has-instance');
require('../modules/es6.parse-int');
require('../modules/es6.parse-float');
require('../modules/es6.number.constructor');
require('../modules/es6.number.to-fixed');
require('../modules/es6.number.to-precision');
require('../modules/es6.number.epsilon');
require('../modules/es6.number.is-finite');
require('../modules/es6.number.is-integer');
require('../modules/es6.number.is-nan');
require('../modules/es6.number.is-safe-integer');
require('../modules/es6.number.max-safe-integer');
require('../modules/es6.number.min-safe-integer');
require('../modules/es6.number.parse-float');
require('../modules/es6.number.parse-int');
require('../modules/es6.math.acosh');
require('../modules/es6.math.asinh');
require('../modules/es6.math.atanh');
require('../modules/es6.math.cbrt');
require('../modules/es6.math.clz32');
require('../modules/es6.math.cosh');
require('../modules/es6.math.expm1');
require('../modules/es6.math.fround');
require('../modules/es6.math.hypot');
require('../modules/es6.math.imul');
require('../modules/es6.math.log10');
require('../modules/es6.math.log1p');
require('../modules/es6.math.log2');
require('../modules/es6.math.sign');
require('../modules/es6.math.sinh');
require('../modules/es6.math.tanh');
require('../modules/es6.math.trunc');
require('../modules/es6.string.from-code-point');
require('../modules/es6.string.raw');
require('../modules/es6.string.trim');
require('../modules/es6.string.iterator');
require('../modules/es6.string.code-point-at');
require('../modules/es6.string.ends-with');
require('../modules/es6.string.includes');
require('../modules/es6.string.repeat');
require('../modules/es6.string.starts-with');
require('../modules/es6.string.anchor');
require('../modules/es6.string.big');
require('../modules/es6.string.blink');
require('../modules/es6.string.bold');
require('../modules/es6.string.fixed');
require('../modules/es6.string.fontcolor');
require('../modules/es6.string.fontsize');
require('../modules/es6.string.italics');
require('../modules/es6.string.link');
require('../modules/es6.string.small');
require('../modules/es6.string.strike');
require('../modules/es6.string.sub');
require('../modules/es6.string.sup');
require('../modules/es6.date.now');
require('../modules/es6.date.to-json');
require('../modules/es6.date.to-iso-string');
require('../modules/es6.date.to-string');
require('../modules/es6.date.to-primitive');
require('../modules/es6.array.is-array');
require('../modules/es6.array.from');
require('../modules/es6.array.of');
require('../modules/es6.array.join');
require('../modules/es6.array.slice');
require('../modules/es6.array.sort');
require('../modules/es6.array.for-each');
require('../modules/es6.array.map');
require('../modules/es6.array.filter');
require('../modules/es6.array.some');
require('../modules/es6.array.every');
require('../modules/es6.array.reduce');
require('../modules/es6.array.reduce-right');
require('../modules/es6.array.index-of');
require('../modules/es6.array.last-index-of');
require('../modules/es6.array.copy-within');
require('../modules/es6.array.fill');
require('../modules/es6.array.find');
require('../modules/es6.array.find-index');
require('../modules/es6.array.species');
require('../modules/es6.array.iterator');
require('../modules/es6.regexp.constructor');
require('../modules/es6.regexp.exec');
require('../modules/es6.regexp.to-string');
require('../modules/es6.regexp.flags');
require('../modules/es6.regexp.match');
require('../modules/es6.regexp.replace');
require('../modules/es6.regexp.search');
require('../modules/es6.regexp.split');
require('../modules/es6.promise');
require('../modules/es6.map');
require('../modules/es6.set');
require('../modules/es6.weak-map');
require('../modules/es6.weak-set');
require('../modules/es6.typed.array-buffer');
require('../modules/es6.typed.data-view');
require('../modules/es6.typed.int8-array');
require('../modules/es6.typed.uint8-array');
require('../modules/es6.typed.uint8-clamped-array');
require('../modules/es6.typed.int16-array');
require('../modules/es6.typed.uint16-array');
require('../modules/es6.typed.int32-array');
require('../modules/es6.typed.uint32-array');
require('../modules/es6.typed.float32-array');
require('../modules/es6.typed.float64-array');
require('../modules/es6.reflect.apply');
require('../modules/es6.reflect.construct');
require('../modules/es6.reflect.define-property');
require('../modules/es6.reflect.delete-property');
require('../modules/es6.reflect.enumerate');
require('../modules/es6.reflect.get');
require('../modules/es6.reflect.get-own-property-descriptor');
require('../modules/es6.reflect.get-prototype-of');
require('../modules/es6.reflect.has');
require('../modules/es6.reflect.is-extensible');
require('../modules/es6.reflect.own-keys');
require('../modules/es6.reflect.prevent-extensions');
require('../modules/es6.reflect.set');
require('../modules/es6.reflect.set-prototype-of');
module.exports = require('../modules/_core');

View File

@ -0,0 +1,2 @@
require('../../modules/es7.array.flat-map');
module.exports = require('../../modules/_core').Array.flatMap;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.array.includes');
module.exports = require('../../modules/_core').Array.includes;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.object.entries');
module.exports = require('../../modules/_core').Object.entries;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.object.get-own-property-descriptors');
module.exports = require('../../modules/_core').Object.getOwnPropertyDescriptors;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.object.values');
module.exports = require('../../modules/_core').Object.values;

View File

@ -0,0 +1,4 @@
'use strict';
require('../../modules/es6.promise');
require('../../modules/es7.promise.finally');
module.exports = require('../../modules/_core').Promise['finally'];

View File

@ -0,0 +1,2 @@
require('../../modules/es7.string.pad-end');
module.exports = require('../../modules/_core').String.padEnd;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.string.pad-start');
module.exports = require('../../modules/_core').String.padStart;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.string.trim-right');
module.exports = require('../../modules/_core').String.trimRight;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.string.trim-left');
module.exports = require('../../modules/_core').String.trimLeft;

View File

@ -0,0 +1,2 @@
require('../../modules/es7.symbol.async-iterator');
module.exports = require('../../modules/_wks-ext').f('asyncIterator');

View File

@ -0,0 +1,2 @@
require('../modules/es7.global');
module.exports = require('../modules/_core').global;

View File

@ -0,0 +1,4 @@
module.exports = function (it) {
if (typeof it != 'function') throw TypeError(it + ' is not a function!');
return it;
};

View File

@ -0,0 +1,5 @@
var isObject = require('./_is-object');
module.exports = function (it) {
if (!isObject(it)) throw TypeError(it + ' is not an object!');
return it;
};

View File

@ -0,0 +1,2 @@
var core = module.exports = { version: '2.6.12' };
if (typeof __e == 'number') __e = core; // eslint-disable-line no-undef

View File

@ -0,0 +1,20 @@
// optional / simple context binding
var aFunction = require('./_a-function');
module.exports = function (fn, that, length) {
aFunction(fn);
if (that === undefined) return fn;
switch (length) {
case 1: return function (a) {
return fn.call(that, a);
};
case 2: return function (a, b) {
return fn.call(that, a, b);
};
case 3: return function (a, b, c) {
return fn.call(that, a, b, c);
};
}
return function (/* ...args */) {
return fn.apply(that, arguments);
};
};

View File

@ -0,0 +1,4 @@
// Thank's IE8 for his funny defineProperty
module.exports = !require('./_fails')(function () {
return Object.defineProperty({}, 'a', { get: function () { return 7; } }).a != 7;
});

View File

@ -0,0 +1,7 @@
var isObject = require('./_is-object');
var document = require('./_global').document;
// typeof document.createElement is 'object' in old IE
var is = isObject(document) && isObject(document.createElement);
module.exports = function (it) {
return is ? document.createElement(it) : {};
};

View File

@ -0,0 +1,62 @@
var global = require('./_global');
var core = require('./_core');
var ctx = require('./_ctx');
var hide = require('./_hide');
var has = require('./_has');
var PROTOTYPE = 'prototype';
var $export = function (type, name, source) {
var IS_FORCED = type & $export.F;
var IS_GLOBAL = type & $export.G;
var IS_STATIC = type & $export.S;
var IS_PROTO = type & $export.P;
var IS_BIND = type & $export.B;
var IS_WRAP = type & $export.W;
var exports = IS_GLOBAL ? core : core[name] || (core[name] = {});
var expProto = exports[PROTOTYPE];
var target = IS_GLOBAL ? global : IS_STATIC ? global[name] : (global[name] || {})[PROTOTYPE];
var key, own, out;
if (IS_GLOBAL) source = name;
for (key in source) {
// contains in native
own = !IS_FORCED && target && target[key] !== undefined;
if (own && has(exports, key)) continue;
// export native or passed
out = own ? target[key] : source[key];
// prevent global pollution for namespaces
exports[key] = IS_GLOBAL && typeof target[key] != 'function' ? source[key]
// bind timers to global for call from export context
: IS_BIND && own ? ctx(out, global)
// wrap global constructors for prevent change them in library
: IS_WRAP && target[key] == out ? (function (C) {
var F = function (a, b, c) {
if (this instanceof C) {
switch (arguments.length) {
case 0: return new C();
case 1: return new C(a);
case 2: return new C(a, b);
} return new C(a, b, c);
} return C.apply(this, arguments);
};
F[PROTOTYPE] = C[PROTOTYPE];
return F;
// make static versions for prototype methods
})(out) : IS_PROTO && typeof out == 'function' ? ctx(Function.call, out) : out;
// export proto methods to core.%CONSTRUCTOR%.methods.%NAME%
if (IS_PROTO) {
(exports.virtual || (exports.virtual = {}))[key] = out;
// export proto methods to core.%CONSTRUCTOR%.prototype.%NAME%
if (type & $export.R && expProto && !expProto[key]) hide(expProto, key, out);
}
}
};
// type bitmap
$export.F = 1; // forced
$export.G = 2; // global
$export.S = 4; // static
$export.P = 8; // proto
$export.B = 16; // bind
$export.W = 32; // wrap
$export.U = 64; // safe
$export.R = 128; // real proto method for `library`
module.exports = $export;

View File

@ -0,0 +1,7 @@
module.exports = function (exec) {
try {
return !!exec();
} catch (e) {
return true;
}
};

Some files were not shown because too many files have changed in this diff Show More