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:
@@ -0,0 +1,2 @@
|
||||
export const CHARACTER_INIT = "sheet.CHARACTER_INIT";
|
||||
export const CHARACTER_RECEIVE_FAIL = "sheet.CHARACTER_RECEIVE_FAIL";
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
CharacterInitAction,
|
||||
CharacterReceiveFailAction,
|
||||
sheetActionTypes,
|
||||
} from "../sheet";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function characterInit(): CharacterInitAction {
|
||||
return {
|
||||
type: sheetActionTypes.CHARACTER_INIT,
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param error
|
||||
*/
|
||||
export function characterReceiveFail(error: Error): CharacterReceiveFailAction {
|
||||
return {
|
||||
type: sheetActionTypes.CHARACTER_RECEIVE_FAIL,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const sheetActionTypes = actionTypes;
|
||||
export const sheetActions = actions;
|
||||
export const sheetTypings = typings;
|
||||
|
||||
export * from "./typings";
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from "react";
|
||||
|
||||
import { Snippet } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
ActionUtils,
|
||||
ActivationContract,
|
||||
ActivationUtils,
|
||||
BaseInventoryContract,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
ClassFeatureContract,
|
||||
InfusionUtils,
|
||||
ItemUtils,
|
||||
LevelScaleContract,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
ClassFeatureUtils,
|
||||
InventoryLookup,
|
||||
HelperUtils,
|
||||
Hack__BaseCharClass,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
|
||||
import { FeatureSnippetLimitedUse } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
action: Action;
|
||||
activation: ActivationContract;
|
||||
showActivationInfo: boolean;
|
||||
|
||||
onActionClick?: (action: Action) => void;
|
||||
onActionUseSet?: (action: Action, used: number) => void;
|
||||
|
||||
abilityLookup: AbilityLookup;
|
||||
inventoryLookup: InventoryLookup;
|
||||
ruleData: RuleData;
|
||||
snippetData: SnippetData;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ActionSnippet extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
showActivationInfo: false,
|
||||
};
|
||||
|
||||
handleActionUseSet = (action: Action, uses: number): void => {
|
||||
const { onActionUseSet } = this.props;
|
||||
|
||||
if (onActionUseSet) {
|
||||
onActionUseSet(action, uses);
|
||||
}
|
||||
};
|
||||
|
||||
handleActionClick = (evt: React.MouseEvent): void => {
|
||||
const { action, onActionClick } = this.props;
|
||||
|
||||
if (onActionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onActionClick(action);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
action,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
snippetData,
|
||||
isInteractive,
|
||||
showActivationInfo,
|
||||
inventoryLookup,
|
||||
activation,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
let name: React.ReactNode = ActionUtils.getName(action);
|
||||
let dataOrigin = ActionUtils.getDataOrigin(action);
|
||||
let dataOriginType = ActionUtils.getDataOriginType(action);
|
||||
|
||||
let classLevel: number | null = null;
|
||||
let scaleValue: LevelScaleContract | null = null;
|
||||
|
||||
switch (dataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
|
||||
classLevel = ClassUtils.getLevel(
|
||||
dataOrigin.parent as Hack__BaseCharClass
|
||||
);
|
||||
scaleValue = ClassFeatureUtils.getLevelScale(
|
||||
dataOrigin.primary as ClassFeatureContract
|
||||
);
|
||||
break;
|
||||
case Constants.DataOriginTypeEnum.RACE:
|
||||
//scaleValue = RacialTraitUtils.getLevelScale(dataOrigin.primary); //TODO this doesn't exist.. is it still needed?
|
||||
break;
|
||||
case Constants.DataOriginTypeEnum.ITEM: {
|
||||
if (!name) {
|
||||
const itemContract = dataOrigin.primary as BaseInventoryContract;
|
||||
const itemMappingId = ItemUtils.getMappingId(itemContract);
|
||||
const item = HelperUtils.lookupDataOrFallback(
|
||||
inventoryLookup,
|
||||
itemMappingId
|
||||
);
|
||||
|
||||
if (item !== null) {
|
||||
const infusion = ItemUtils.getInfusion(item);
|
||||
name = (
|
||||
<React.Fragment>
|
||||
<ItemName item={item} />
|
||||
{infusion !== null && (
|
||||
<span className="ct-feature-snippet__heading-extra">
|
||||
(Infusion: {InfusionUtils.getName(infusion)})
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let activationDisplay: React.ReactNode;
|
||||
if (showActivationInfo && activation) {
|
||||
activationDisplay = (
|
||||
<span
|
||||
className={`ct-feature-snippet__heading-activation ${
|
||||
theme.isDarkMode
|
||||
? "ct-feature-snippet__heading-activation--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
({ActivationUtils.renderActivation(activation, ruleData)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-feature-snippet" onClick={this.handleActionClick}>
|
||||
<div
|
||||
className={`ct-feature-snippet__heading ${
|
||||
theme.isDarkMode ? "ct-feature-snippet__heading--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{name} {activationDisplay}
|
||||
</div>
|
||||
<div className="ct-feature-snippet__content">
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
theme={theme}
|
||||
classLevel={classLevel}
|
||||
levelScale={scaleValue}
|
||||
limitedUse={limitedUse}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
>
|
||||
{ActionUtils.getSnippet(action)}
|
||||
</Snippet>
|
||||
</div>
|
||||
<FeatureSnippetLimitedUse
|
||||
component={action}
|
||||
theme={theme}
|
||||
limitedUse={limitedUse}
|
||||
onUseSet={this.handleActionUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ActionSnippet from "./ActionSnippet";
|
||||
|
||||
export default ActionSnippet;
|
||||
export { ActionSnippet };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CharacterTheme } from "@dndbeyond/character-rules-engine";
|
||||
import { HTMLAttributes } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
||||
interface ActionListSectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
headingText: string;
|
||||
theme: CharacterTheme;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export const ActionListSection = ({
|
||||
// Component Props
|
||||
theme,
|
||||
headingText,
|
||||
testId = "",
|
||||
// From HTMLAttributes
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ActionListSectionProps) => {
|
||||
const headingTestId = `actions-list-${testId}-heading`;
|
||||
const listTestId = `actions-list-${testId}-list`;
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.section, className])} {...props}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.sectionHeading,
|
||||
theme?.isDarkMode && styles.darkMode,
|
||||
])}
|
||||
data-testid={headingTestId}
|
||||
>
|
||||
{headingText}
|
||||
</div>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.list,
|
||||
theme?.isDarkMode && styles.darkMode,
|
||||
])}
|
||||
data-testid={listTestId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React, { HTMLAttributes, useMemo } from "react";
|
||||
import { AttackTable } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
Activatable,
|
||||
Attack,
|
||||
BasicActionContract,
|
||||
Constants,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellUtils,
|
||||
WeaponSpellDamageGroup,
|
||||
InventoryLookup,
|
||||
DataOriginRefData,
|
||||
HelperUtils,
|
||||
ActionUtils,
|
||||
BaseSpell,
|
||||
CharacterTheme,
|
||||
Action,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { IRollContext } from "@dndbeyond/dice";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import ActionSnippet from "../ActionSnippet";
|
||||
import BasicActions from "../BasicActions";
|
||||
import { FeatureSnippetSpells } from "../FeatureSnippet";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
import { ActionListSection } from "./ActionListSection";
|
||||
|
||||
|
||||
interface ActionsListProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading: React.ReactNode;
|
||||
theme: CharacterTheme;
|
||||
showNotes: boolean;
|
||||
diceEnabled?: boolean;
|
||||
isInteractive: boolean;
|
||||
showActivationInfo?: boolean;
|
||||
|
||||
actions?: Array<Activatable>;
|
||||
basicActions?: Array<BasicActionContract>;
|
||||
ritualSpells?: Array<Spell>;
|
||||
attacks?: Array<Attack>;
|
||||
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
|
||||
ruleData: RuleData;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
rollContext: IRollContext;
|
||||
inventoryLookup: InventoryLookup;
|
||||
snippetData: SnippetData;
|
||||
|
||||
// ActionHandlers
|
||||
onAttackClick: (attack: Attack) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onBasicActionClick: (basicAction: BasicActionContract) => void;
|
||||
onActionUseSet: (action: Action, used: number) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, used: number) => void;
|
||||
};
|
||||
|
||||
export const ActionsList = ({
|
||||
heading,
|
||||
theme,
|
||||
showNotes,
|
||||
isInteractive,
|
||||
diceEnabled = false,
|
||||
showActivationInfo = false,
|
||||
|
||||
actions = [],
|
||||
basicActions = [],
|
||||
ritualSpells = [],
|
||||
attacks = [],
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
spellCasterInfo,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
inventoryLookup,
|
||||
snippetData,
|
||||
// From ActionHandlers
|
||||
onAttackClick,
|
||||
onBasicActionClick,
|
||||
onActionClick,
|
||||
onActionUseSet,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
// From HTMLAttributes
|
||||
className,
|
||||
...props
|
||||
}: ActionsListProps) => {
|
||||
const { actionLookup } = useCharacterEngine();
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => !!basicActions.length
|
||||
|| !!actions.length
|
||||
|| !!ritualSpells.length
|
||||
|| !!attacks.length,
|
||||
[basicActions, actions, ritualSpells, attacks]
|
||||
);
|
||||
|
||||
const attackTableProps = {
|
||||
attacks,
|
||||
weaponSpellDamageGroups,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
spellCasterInfo,
|
||||
onAttackClick,
|
||||
showNotes,
|
||||
diceEnabled,
|
||||
theme,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
};
|
||||
|
||||
const spellSnippetProps = {
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
ruleData,
|
||||
theme,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
};
|
||||
|
||||
const spells = useMemo(() => {
|
||||
let spells: Array<BaseSpell> = [];
|
||||
|
||||
actions.forEach((action) => {
|
||||
switch (action.type) {
|
||||
case Constants.ActivatableTypeEnum.CLASS_SPELL:
|
||||
case Constants.ActivatableTypeEnum.CHARACTER_SPELL:
|
||||
spells.push(action.entity);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return spells;
|
||||
}, [actions]);
|
||||
|
||||
const orderedRitualSpells = useMemo(
|
||||
() => orderBy(
|
||||
ritualSpells,
|
||||
(spell) => SpellUtils.getName(spell)
|
||||
),
|
||||
[ritualSpells]
|
||||
);
|
||||
|
||||
const features = useMemo(() => actions.filter(
|
||||
a => a.type === Constants.ActivatableTypeEnum.ACTION)
|
||||
.map(feature => {
|
||||
const action = HelperUtils.lookupDataOrFallback(
|
||||
actionLookup,
|
||||
ActionUtils.getUniqueKey(feature.entity)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.activatable}
|
||||
key={feature.key}
|
||||
data-testid="actions-list-activatable-feature"
|
||||
>
|
||||
<ActionSnippet
|
||||
theme={theme}
|
||||
action={action ?? feature.entity}
|
||||
onActionClick={onActionClick}
|
||||
onActionUseSet={onActionUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
inventoryLookup={inventoryLookup}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
isInteractive={isInteractive}
|
||||
showActivationInfo={showActivationInfo}
|
||||
activation={feature.activation}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
[
|
||||
actions,
|
||||
actionLookup,
|
||||
theme,
|
||||
onActionClick,
|
||||
onActionUseSet,
|
||||
abilityLookup,
|
||||
inventoryLookup,
|
||||
ruleData,
|
||||
snippetData,
|
||||
isInteractive,
|
||||
showActivationInfo,
|
||||
proficiencyBonus,
|
||||
]);
|
||||
|
||||
return isVisible ? (
|
||||
<div className={clsx([styles.actionsList, className])} {...props}>
|
||||
<div
|
||||
className={clsx([styles.heading, theme.isDarkMode && styles.darkMode])}
|
||||
data-testid="actions-list-header"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
<div>
|
||||
{!!attacks.length && <AttackTable className={styles.attackTable} {...attackTableProps} />}
|
||||
|
||||
{!!basicActions.length && (
|
||||
<ActionListSection headingText="Actions in Combat" testId="basic" theme={theme}>
|
||||
<BasicActions
|
||||
onActionClick={onBasicActionClick}
|
||||
basicActions={basicActions}
|
||||
theme={theme}
|
||||
/>
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{!!spells.length && (
|
||||
<ActionListSection headingText="Spells" theme={theme}>
|
||||
<FeatureSnippetSpells layoutType={"compact"} spells={spells} {...spellSnippetProps} />
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{!!ritualSpells.length && (
|
||||
<ActionListSection headingText="Ritual Spells" theme={theme}>
|
||||
<FeatureSnippetSpells layoutType={"compact"} spells={orderedRitualSpells} {...spellSnippetProps} />
|
||||
</ActionListSection>
|
||||
)}
|
||||
|
||||
{features}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
Background,
|
||||
BackgroundUtils,
|
||||
CharacterFeaturesManager,
|
||||
CharacterTheme,
|
||||
DataOriginRefData,
|
||||
FeatManager,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { DataOriginTypeEnum } from "~/constants";
|
||||
|
||||
import { FeatFeatureSnippet } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
background: Background | null;
|
||||
onClick: () => void;
|
||||
onFeatClick?: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager;
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick?: (action: Action) => void;
|
||||
onSpellClick?: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class BackgroundDetail extends React.PureComponent<Props> {
|
||||
handleBackgroundClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
renderDefault = (): React.ReactNode => {
|
||||
return (
|
||||
<div className="ct-background__default">
|
||||
Your character's background feature will display in this section once
|
||||
chosen.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { background, featuresManager, onFeatClick, ...restProps } =
|
||||
this.props;
|
||||
|
||||
if (!background) {
|
||||
return this.renderDefault();
|
||||
}
|
||||
|
||||
const name = BackgroundUtils.getName(background);
|
||||
const featureName = BackgroundUtils.getFeatureName(background);
|
||||
const featureDescription =
|
||||
BackgroundUtils.getFeatureDescription(background);
|
||||
|
||||
const feats = featuresManager.getDataOriginOnlyFeatsByParent(
|
||||
DataOriginTypeEnum.BACKGROUND,
|
||||
`${BackgroundUtils.getId(background)}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-background" onClick={this.handleBackgroundClick}>
|
||||
<div className="ct-background__name">{name}</div>
|
||||
<>
|
||||
{featureDescription && (
|
||||
<div className="ct-background__feature">
|
||||
<div className="ct-background__feature-name">
|
||||
{featureName ? `Feature: ${featureName}` : "Feature"}
|
||||
</div>
|
||||
<HtmlContent
|
||||
className="ct-background__feature-description"
|
||||
html={featureDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{feats.map((feat) => (
|
||||
<FeatFeatureSnippet key={feat.getId()} feat={feat} {...restProps} />
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import BackgroundDetail from "./BackgroundDetail";
|
||||
|
||||
export default BackgroundDetail;
|
||||
export { BackgroundDetail };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
BasicActionContract,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
basicActions: Array<BasicActionContract>;
|
||||
onActionClick?: (basicAction: BasicActionContract) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class BasicActions extends React.PureComponent<Props> {
|
||||
handleActionClick = (
|
||||
basicAction: BasicActionContract,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
const { onActionClick } = this.props;
|
||||
|
||||
if (onActionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onActionClick(basicAction);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { basicActions, theme } = this.props;
|
||||
|
||||
if (!basicActions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let orderedActions = orderBy(
|
||||
basicActions,
|
||||
[(basicAction) => basicAction.name],
|
||||
["asc"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-basic-actions ${
|
||||
theme.isDarkMode ? "ct-basic-actions--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{orderedActions.map((basicAction, idx) => (
|
||||
<span
|
||||
className="ct-basic-actions__action"
|
||||
key={basicAction.id}
|
||||
onClick={this.handleActionClick.bind(this, basicAction)}
|
||||
>
|
||||
{basicAction.name}
|
||||
{idx + 1 < orderedActions.length && (
|
||||
<span className="ct-basic-actions__action-sep">,</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import BasicActions from "./BasicActions";
|
||||
|
||||
export default BasicActions;
|
||||
export { BasicActions };
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
FeatureManager,
|
||||
FeaturesManager,
|
||||
rulesEngineSelectors,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
import { Snippet } from "~/tools/js/smartComponents";
|
||||
|
||||
import { CharacterFeaturesManagerContext } from "../../../Shared/managers/CharacterFeaturesManagerContext";
|
||||
|
||||
interface Props {
|
||||
// onActionUseSet: (action: Action, uses: number) => void;
|
||||
// onActionClick: (action: Action) => void;
|
||||
// onSpellClick: (spell: Spell) => void;
|
||||
// onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
// onFeatureClick: (feat: FeatManager) => void;
|
||||
// snippetData: SnippetData;
|
||||
// ruleData: RuleData;
|
||||
// abilityLookup: AbilityLookup;
|
||||
// dataOriginRefData: DataOriginRefData;
|
||||
// isReadonly: boolean;
|
||||
// proficiencyBonus: number;
|
||||
// theme: CharacterTheme;
|
||||
}
|
||||
export const BlessingsDetail: React.FC<Props> = () => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
|
||||
const [blessings, setBlessings] = useState<Array<FeatureManager>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function onUpdate() {
|
||||
const blessings = await characterFeaturesManager.getBlessings();
|
||||
setBlessings(blessings);
|
||||
}
|
||||
return FeaturesManager.subscribeToUpdates({ onUpdate });
|
||||
}, [setBlessings]);
|
||||
|
||||
const snippetData = useSelector(rulesEngineSelectors.getSnippetData);
|
||||
// const ruleData = useSelector(rulesEngineSelectors.getRuleData);
|
||||
// const abilityLookup = useSelector(rulesEngineSelectors.getAbilityLookup);
|
||||
// const dataOriginRefData = useSelector(rulesEngineSelectors.getDataOriginRefData);
|
||||
// const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
const proficiencyBonus = useSelector(
|
||||
rulesEngineSelectors.getProficiencyBonus
|
||||
);
|
||||
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
|
||||
|
||||
return (
|
||||
<div className="ct-blessings-detail">
|
||||
{blessings.length ? (
|
||||
<React.Fragment>
|
||||
{blessings.map((blessing) => (
|
||||
<div
|
||||
onClick={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
paneHistoryStart(
|
||||
PaneComponentEnum.BLESSING_DETAIL,
|
||||
PaneIdentifierUtils.generateBlessing(blessing.getId())
|
||||
);
|
||||
}}
|
||||
className={"ct-feature-snippet"}
|
||||
key={blessing.getId()}
|
||||
>
|
||||
<div
|
||||
className={`ct-feature-snippet__heading ${
|
||||
theme?.isDarkMode
|
||||
? "ct-feature-snippet__heading--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{blessing.getName()}
|
||||
</div>
|
||||
<div className="ct-feature-snippet__content">
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
>
|
||||
{blessing.getDescription()}
|
||||
</Snippet>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div
|
||||
className={`ct-blessings-detail__default ${
|
||||
theme.isDarkMode ? "ct-blessings-detail__default--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<p>There is nothing here.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BlessingsDetail;
|
||||
@@ -0,0 +1,4 @@
|
||||
import BlessingsDetail from "./BlessingsDetail";
|
||||
|
||||
export default BlessingsDetail;
|
||||
export { BlessingsDetail };
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardActionArea from "@mui/material/CardActionArea";
|
||||
import CardActions from "@mui/material/CardActions";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
|
||||
import { useAuth } from "~/contexts/Authentication";
|
||||
import { useClaimCharacter } from "~/hooks/useClaimCharacter";
|
||||
import backgroundImage from "~/images/claim.png";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import {
|
||||
DESKTOP_COMPONENT_START_WIDTH,
|
||||
TABLET_COMPONENT_START_WIDTH,
|
||||
} from "../../config";
|
||||
import ClaimConfirmationDialog from "../WatchTourDialog/ClaimConfirmationDialog";
|
||||
|
||||
const renderButtonContent = (signedIn: boolean) => (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", marginLeft: "10px" }}>
|
||||
{!signedIn && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginRight: "5px",
|
||||
textTransform: "none",
|
||||
fontWeight: 400,
|
||||
fontSize: "14px",
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
Create Account to
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textTransform: "none",
|
||||
fontWeight: 700,
|
||||
fontSize: "14px",
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
Claim Character
|
||||
</Typography>
|
||||
<ChevronRightIcon />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// z-index of the sidebar controls
|
||||
const zIndex = 60002;
|
||||
|
||||
const createAccountLink = `/create-account?returnUrl=${window.location.pathname}`;
|
||||
|
||||
const ClaimPremadeButton: React.FC = () => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const campaignJoinCode = params.get("campaignJoinCode");
|
||||
const isAssigned = params.get("isAssigned") === "false" ? false : true;
|
||||
|
||||
const [isMaxCharacterMessageOpen, setIsMaxCharacterMessageOpen] =
|
||||
useState(false);
|
||||
|
||||
const envDimensions = useSelector(appEnvSelectors.getDimensions);
|
||||
|
||||
const { characterSlotLimit, activeCharacterCount } = useSelector(
|
||||
appEnvSelectors.getCharacterSlots
|
||||
);
|
||||
// Character slot limit is null for admin accounts
|
||||
const hasOpenSlot =
|
||||
characterSlotLimit === null || activeCharacterCount < characterSlotLimit;
|
||||
|
||||
const [
|
||||
claimCharacter,
|
||||
isClaimingCharacter,
|
||||
isFinishedClaimingCharacter,
|
||||
newCharacterId,
|
||||
campaignId,
|
||||
] = useClaimCharacter({
|
||||
campaignJoinCode,
|
||||
isAssigned,
|
||||
});
|
||||
|
||||
const user = useAuth();
|
||||
const signedIn = !!user;
|
||||
|
||||
const handleClaimButtonClick = () => {
|
||||
hasOpenSlot ? claimCharacter() : setIsMaxCharacterMessageOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{envDimensions.window.width >= TABLET_COMPONENT_START_WIDTH ? (
|
||||
<Card
|
||||
className="claimPremade-button"
|
||||
hidden={isFinishedClaimingCharacter && !!newCharacterId}
|
||||
sx={{
|
||||
zIndex,
|
||||
position: "fixed",
|
||||
right: 30,
|
||||
bottom:
|
||||
envDimensions.window.width >= DESKTOP_COMPONENT_START_WIDTH
|
||||
? 30
|
||||
: 70,
|
||||
width: 300,
|
||||
background: "#551710",
|
||||
borderRadius: "8px",
|
||||
overflow: "visible",
|
||||
boxShadow:
|
||||
"0px 57px 23px rgba(0, 0, 0, 0.01), 0px 32px 19px rgba(0, 0, 0, 0.05), 0px 14px 14px rgba(0, 0, 0, 0.08), 0px 4px 8px rgba(0, 0, 0, 0.09), 0px 0px 0px rgba(0, 0, 0, 0.09)",
|
||||
"&:hover": {
|
||||
"& .claimPremade-buttonContent": {
|
||||
background: "#F5F3EE",
|
||||
boxShadow: "0px -4px 12px rgba(0, 0, 0, 0.25)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
sx={{ textAlign: "center" }}
|
||||
{...(!signedIn && { href: createAccountLink })}
|
||||
onClick={signedIn ? handleClaimButtonClick : undefined}
|
||||
disabled={isClaimingCharacter}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={backgroundImage}
|
||||
sx={{ position: "relative", top: -20, width: 244, height: 144 }}
|
||||
/>
|
||||
<CardActions
|
||||
className="claimPremade-buttonContent"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
height: 48,
|
||||
color: "#000000",
|
||||
background: "#FFFFFF",
|
||||
|
||||
borderRadius: "0 0 8px 8px",
|
||||
}}
|
||||
>
|
||||
{renderButtonContent(signedIn)}
|
||||
</CardActions>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
className="claimPremade-button"
|
||||
{...(!signedIn && { href: createAccountLink })}
|
||||
sx={{
|
||||
display:
|
||||
isFinishedClaimingCharacter && !!newCharacterId
|
||||
? "none"
|
||||
: "inline-flex",
|
||||
zIndex,
|
||||
position: "fixed",
|
||||
left: "50%",
|
||||
bottom: 70,
|
||||
width: 300,
|
||||
height: "50px",
|
||||
marginLeft: "-150px",
|
||||
color: "#000000",
|
||||
backgroundColor: "#FFFFFF",
|
||||
boxShadow:
|
||||
"0px 57px 23px rgba(0, 0, 0, 0.01), 0px 32px 19px rgba(0, 0, 0, 0.05), 0px 14px 14px rgba(0, 0, 0, 0.08), 0px 4px 8px rgba(0, 0, 0, 0.09), 0px 0px 0px rgba(0, 0, 0, 0.09)",
|
||||
"&:hover": {
|
||||
backgroundColor: "#F5F3EE",
|
||||
boxShadow:
|
||||
"0px 57px 23px rgba(0, 0, 0, 0.01), 0px 32px 19px rgba(0, 0, 0, 0.05), 0px 14px 14px rgba(0, 0, 0, 0.08), 0px 4px 8px rgba(0, 0, 0, 0.09), 0px 0px 0px rgba(0, 0, 0, 0.09)",
|
||||
},
|
||||
"&.Mui-disabled": {
|
||||
backgroundColor: "#FFFFFF",
|
||||
},
|
||||
}}
|
||||
onClick={signedIn ? handleClaimButtonClick : undefined}
|
||||
disabled={isClaimingCharacter}
|
||||
>
|
||||
{renderButtonContent(signedIn)}
|
||||
</Button>
|
||||
)}
|
||||
<MaxCharactersDialog
|
||||
open={isMaxCharacterMessageOpen}
|
||||
onClose={() => setIsMaxCharacterMessageOpen(false)}
|
||||
useMyCharactersLink
|
||||
/>
|
||||
{isFinishedClaimingCharacter && !!newCharacterId && (
|
||||
<ClaimConfirmationDialog
|
||||
characterId={newCharacterId}
|
||||
campaignId={campaignId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaimPremadeButton;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClaimPremadeButton from "./ClaimPremadeButton";
|
||||
|
||||
export default ClaimPremadeButton;
|
||||
export { ClaimPremadeButton as ClaimPremadeDialog };
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
CharClass,
|
||||
CharacterFeaturesManager,
|
||||
CharacterTheme,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
DataOriginRefData,
|
||||
Feat,
|
||||
FeatManager,
|
||||
InfusionChoice,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ClassFeatureSnippet } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
charClass: CharClass;
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
onFeatureClick: (feature: ClassFeature, charClass: CharClass) => void;
|
||||
onInfusionChoiceClick: (infusionChoice: InfusionChoice) => void;
|
||||
feats: Array<Feat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
onFeatClick: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager
|
||||
}
|
||||
export default class ClassDetail extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { charClass, ...restProps } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-class-detail">
|
||||
<div className="ct-class-detail__name">
|
||||
{ClassUtils.getName(charClass)} Features
|
||||
</div>
|
||||
<div className="ct-class-detail__features">
|
||||
{ClassUtils.getUniqueClassFeatures(charClass).map((feature) => (
|
||||
<ClassFeatureSnippet
|
||||
{...restProps}
|
||||
key={ClassFeatureUtils.getId(feature)}
|
||||
feature={feature}
|
||||
charClass={charClass}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassDetail from "./ClassDetail";
|
||||
|
||||
export default ClassDetail;
|
||||
export { ClassDetail };
|
||||
@@ -0,0 +1,93 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
CharacterFeaturesManager,
|
||||
CharacterTheme,
|
||||
CharClass,
|
||||
ClassFeature,
|
||||
ClassUtils,
|
||||
DataOriginRefData,
|
||||
Feat,
|
||||
FeatManager,
|
||||
InfusionChoice,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ClassDetail from "../ClassDetail";
|
||||
|
||||
interface Props {
|
||||
classes: Array<CharClass>;
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
onFeatureClick: (feature: ClassFeature, charClass: CharClass) => void;
|
||||
onInfusionChoiceClick: (infusionChoice: InfusionChoice) => void;
|
||||
feats: Array<Feat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
onFeatClick: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager
|
||||
}
|
||||
const ClassesDetail: React.FC<Props> = ({
|
||||
classes,
|
||||
isReadonly,
|
||||
snippetData,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
onFeatureClick,
|
||||
feats,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
onActionUseSet,
|
||||
onInfusionChoiceClick,
|
||||
onFeatClick,
|
||||
featuresManager
|
||||
}) => {
|
||||
let orderedClasses = orderBy(classes, (charClass) =>
|
||||
ClassUtils.getName(charClass)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-classes-detail">
|
||||
{orderedClasses.map((charClass) => (
|
||||
<ClassDetail
|
||||
key={ClassUtils.getId(charClass)}
|
||||
charClass={charClass}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
onSpellClick={onSpellClick}
|
||||
onActionClick={onActionClick}
|
||||
feats={feats}
|
||||
snippetData={snippetData}
|
||||
onInfusionChoiceClick={onInfusionChoiceClick}
|
||||
isReadonly={isReadonly}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onFeatClick={onFeatClick}
|
||||
featuresManager={featuresManager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClassesDetail;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassesDetail from "./ClassesDetail";
|
||||
|
||||
export default ClassesDetail;
|
||||
export { ClassesDetail };
|
||||
@@ -0,0 +1,570 @@
|
||||
import {
|
||||
spring,
|
||||
TransitionMotion,
|
||||
TransitionStyle,
|
||||
} from "@serprex/react-motion";
|
||||
import { uniqueId, throttle } from "lodash";
|
||||
import React from "react";
|
||||
import { DOWN, SwipeEventData, UP } from "react-swipeable";
|
||||
|
||||
import { Swipeable } from "~/components/Swipeable";
|
||||
|
||||
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
|
||||
import { ComponentCarouselItem } from "./ComponentCarouselItem";
|
||||
|
||||
interface CarouselWindowItem {
|
||||
offset: number;
|
||||
idx: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeItemKey: string;
|
||||
onItemChange?: (newKey: string | null, oldKey: string | null) => void;
|
||||
overscanAmount: number;
|
||||
changingWaitTime: number;
|
||||
changeStartDelta: number;
|
||||
resizeThrottleTime: number;
|
||||
resizeBlockWaitTime: number;
|
||||
envDimensions: AppEnvDimensionsState;
|
||||
children:
|
||||
| Array<React.ReactElement<ComponentCarouselItem>>
|
||||
| React.ReactElement<ComponentCarouselItem>;
|
||||
}
|
||||
|
||||
interface WindowsItemState {
|
||||
windowItems: Array<CarouselWindowItem>;
|
||||
currentIdx: number;
|
||||
swipedAmount: number;
|
||||
}
|
||||
interface State extends WindowsItemState {
|
||||
activeIdx: number;
|
||||
placeholderWidth: number;
|
||||
filteredChildren: Array<ComponentCarouselItem>;
|
||||
childByIdxLookup: Record<number, ComponentCarouselItem>;
|
||||
childrenCount: number;
|
||||
isChanging: boolean;
|
||||
isResizeBlocked: boolean;
|
||||
}
|
||||
export default class ComponentCarousel extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
overscanAmount: 2,
|
||||
changingWaitTime: 550,
|
||||
changeStartDelta: 75,
|
||||
resizeThrottleTime: 300,
|
||||
resizeBlockWaitTime: 2000,
|
||||
};
|
||||
|
||||
placeholderRef = React.createRef<HTMLDivElement>();
|
||||
changingRafId: number | null = null;
|
||||
changingStartTime: number | null = null;
|
||||
resizeBlockRafId: number | null = null;
|
||||
resizeBlockStartTime: number | null = null;
|
||||
orientationHandler: () => void;
|
||||
resizeHandler: () => void;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeIdx: 0,
|
||||
currentIdx: 0,
|
||||
swipedAmount: 0,
|
||||
placeholderWidth: 0,
|
||||
childByIdxLookup: {},
|
||||
childrenCount: 0,
|
||||
windowItems: [],
|
||||
filteredChildren: [],
|
||||
isChanging: false,
|
||||
isResizeBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State): State {
|
||||
const { children, activeItemKey } = props;
|
||||
|
||||
let activeIdx: number = 0;
|
||||
|
||||
//TODO need to refactor to better handle this and not require any
|
||||
let filteredChildren: Array<any> = [];
|
||||
if (children) {
|
||||
filteredChildren = React.Children.toArray(children).filter(
|
||||
(child: any) => !!child && child.props.isEnabled
|
||||
);
|
||||
}
|
||||
React.Children.forEach(filteredChildren, (child, idx) => {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { itemKey, isEnabled } = child.props;
|
||||
|
||||
if (isEnabled && itemKey === activeItemKey) {
|
||||
activeIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
let childByIdxLookup: Record<number, ComponentCarouselItem> =
|
||||
filteredChildren.reduce(
|
||||
(acc, child, idx) => ({
|
||||
...acc,
|
||||
[idx]: child,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
let newState: State = {
|
||||
...state,
|
||||
activeIdx,
|
||||
currentIdx: activeIdx,
|
||||
filteredChildren,
|
||||
childrenCount: filteredChildren.length,
|
||||
childByIdxLookup,
|
||||
};
|
||||
|
||||
let windowItemsState: WindowsItemState = state.isChanging
|
||||
? state
|
||||
: ComponentCarousel.deriveWindowItemsState(props, newState);
|
||||
|
||||
return {
|
||||
...newState,
|
||||
...windowItemsState,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initResizeHandlers();
|
||||
this.updatePlaceholderDimensions();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroyResizeHandlers();
|
||||
this.stopChangeCheckLoop();
|
||||
this.stopResizeBlockCheckLoop();
|
||||
}
|
||||
|
||||
destroyResizeHandlers = (): void => {
|
||||
window.removeEventListener("resize", this.resizeHandler);
|
||||
window.removeEventListener("orientationchange", this.orientationHandler);
|
||||
};
|
||||
|
||||
initResizeHandlers = (): void => {
|
||||
const { resizeThrottleTime, envDimensions } = this.props;
|
||||
|
||||
this.resizeHandler = throttle(() => {
|
||||
if (!this.state.isResizeBlocked) {
|
||||
this.updatePlaceholderDimensions();
|
||||
}
|
||||
}, resizeThrottleTime);
|
||||
|
||||
this.orientationHandler = throttle(() => {
|
||||
let oldSize: number = envDimensions ? envDimensions.window.width : 0;
|
||||
let count: number = 0;
|
||||
let orientationIntervalId: number | undefined;
|
||||
orientationIntervalId = window.setInterval(() => {
|
||||
let newEnvWidth = envDimensions ? envDimensions.window.width : oldSize;
|
||||
if (oldSize !== newEnvWidth || count > 15) {
|
||||
window.clearInterval(orientationIntervalId);
|
||||
return;
|
||||
}
|
||||
if (oldSize !== window.innerWidth) {
|
||||
this.updatePlaceholderDimensions();
|
||||
}
|
||||
count++;
|
||||
}, 100);
|
||||
}, resizeThrottleTime);
|
||||
|
||||
window.addEventListener("resize", this.resizeHandler);
|
||||
window.addEventListener("orientationchange", this.orientationHandler);
|
||||
};
|
||||
|
||||
updatePlaceholderDimensions = (): void => {
|
||||
if (this.placeholderRef.current) {
|
||||
let placeholderWidth = this.placeholderRef.current.offsetWidth;
|
||||
this.setState(
|
||||
{
|
||||
placeholderWidth,
|
||||
},
|
||||
this.updateWindowItems
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
getItemKeyByIdx = (itemIdx: number): string | null => {
|
||||
const { filteredChildren } = this.state;
|
||||
|
||||
let itemKey: string | null = null;
|
||||
|
||||
React.Children.forEach(filteredChildren, (child, idx) => {
|
||||
if (itemIdx === idx) {
|
||||
itemKey = child.props.itemKey;
|
||||
}
|
||||
});
|
||||
|
||||
return itemKey;
|
||||
};
|
||||
|
||||
static getSwipedItemPosIdx = (
|
||||
activeIdx: number,
|
||||
placeholderWidth: number,
|
||||
swipedAmount: number
|
||||
): number => {
|
||||
if (placeholderWidth === 0) {
|
||||
return 0;
|
||||
}
|
||||
let swipedSlideCount = Math.round(swipedAmount / placeholderWidth);
|
||||
return activeIdx - swipedSlideCount; // subtract because of swipe direction makes it negative going right
|
||||
};
|
||||
|
||||
// Need to be able to get what array index to use based on a position index that is both positive, zero, and negative
|
||||
// example: [a, b, c, d, e, f, g, h] (length = 8)
|
||||
// position index: -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10
|
||||
// array index: 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2
|
||||
//
|
||||
// position index "-9" gives letter "h"
|
||||
static getArrayIdxByPositionIdx = (
|
||||
posIndex: number,
|
||||
total: number
|
||||
): number => {
|
||||
if (posIndex >= 0) {
|
||||
return posIndex % total;
|
||||
} else {
|
||||
const indexMod = Math.abs(posIndex) % total;
|
||||
return (indexMod === 0 ? 0 : 1) * (total - indexMod);
|
||||
}
|
||||
};
|
||||
|
||||
runChangeCheck = (callback: () => void): void => {
|
||||
const { changingWaitTime } = this.props;
|
||||
|
||||
if (
|
||||
this.changingStartTime !== null &&
|
||||
Number(new Date()) - this.changingStartTime > changingWaitTime
|
||||
) {
|
||||
callback();
|
||||
} else {
|
||||
this.changingRafId = window.requestAnimationFrame(
|
||||
this.runChangeCheck.bind(this, callback)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
startChangeCheckLoop = (callback: () => void): void => {
|
||||
this.changingStartTime = Number(new Date());
|
||||
this.stopChangeCheckLoop();
|
||||
this.changingRafId = window.requestAnimationFrame(
|
||||
this.runChangeCheck.bind(this, callback)
|
||||
);
|
||||
};
|
||||
|
||||
stopChangeCheckLoop = (): void => {
|
||||
if (this.changingRafId !== null) {
|
||||
window.cancelAnimationFrame(this.changingRafId);
|
||||
}
|
||||
};
|
||||
|
||||
startResizeBlockCheckLoop = (callback: () => void): void => {
|
||||
this.resizeBlockStartTime = Number(new Date());
|
||||
this.stopResizeBlockCheckLoop();
|
||||
this.resizeBlockRafId = window.requestAnimationFrame(
|
||||
this.runResizeBlockCheck.bind(this, callback)
|
||||
);
|
||||
};
|
||||
|
||||
stopResizeBlockCheckLoop = (): void => {
|
||||
if (this.resizeBlockRafId !== null) {
|
||||
window.cancelAnimationFrame(this.resizeBlockRafId);
|
||||
}
|
||||
};
|
||||
|
||||
runResizeBlockCheck = (callback: () => void): void => {
|
||||
const { resizeBlockWaitTime } = this.props;
|
||||
|
||||
if (
|
||||
this.resizeBlockStartTime !== null &&
|
||||
Number(new Date()) - this.resizeBlockStartTime > resizeBlockWaitTime
|
||||
) {
|
||||
callback();
|
||||
} else {
|
||||
this.resizeBlockRafId = window.requestAnimationFrame(
|
||||
this.runResizeBlockCheck.bind(this, callback)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static deriveWindowItemsState = (
|
||||
props: Props,
|
||||
state: State
|
||||
): WindowsItemState => {
|
||||
const { overscanAmount } = props;
|
||||
const {
|
||||
windowItems,
|
||||
placeholderWidth,
|
||||
swipedAmount,
|
||||
childrenCount,
|
||||
currentIdx,
|
||||
} = state;
|
||||
|
||||
let currentPosIdx: number = ComponentCarousel.getSwipedItemPosIdx(
|
||||
currentIdx,
|
||||
placeholderWidth,
|
||||
swipedAmount
|
||||
);
|
||||
let startIdx: number = currentPosIdx - overscanAmount;
|
||||
let endIdx: number = currentPosIdx + overscanAmount;
|
||||
|
||||
let newWindowItems: Array<CarouselWindowItem> = [];
|
||||
if (childrenCount > 0) {
|
||||
let offset: number = -1 * overscanAmount;
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
let itemIdx = ComponentCarousel.getArrayIdxByPositionIdx(
|
||||
i,
|
||||
childrenCount
|
||||
);
|
||||
let existingItem = windowItems.find(
|
||||
(windowItem) => windowItem.idx === itemIdx
|
||||
);
|
||||
|
||||
newWindowItems.push({
|
||||
offset,
|
||||
idx: itemIdx,
|
||||
key: existingItem ? existingItem.key : uniqueId(),
|
||||
});
|
||||
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
windowItems: newWindowItems,
|
||||
currentIdx: currentPosIdx,
|
||||
swipedAmount: 0,
|
||||
};
|
||||
};
|
||||
|
||||
updateWindowItems = (startDelayedChange: boolean = false): void => {
|
||||
const { childrenCount, activeIdx } = this.state;
|
||||
|
||||
let windowItemsState = ComponentCarousel.deriveWindowItemsState(
|
||||
this.props,
|
||||
this.state
|
||||
);
|
||||
|
||||
this.setState(windowItemsState);
|
||||
|
||||
if (startDelayedChange) {
|
||||
this.startChangeCheckLoop(() => {
|
||||
let newActiveIdx = ComponentCarousel.getArrayIdxByPositionIdx(
|
||||
windowItemsState.currentIdx,
|
||||
childrenCount
|
||||
);
|
||||
this.handleItemChange(newActiveIdx, activeIdx);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleSwiping = ({ deltaX, dir }: SwipeEventData): void => {
|
||||
const isVerticalChange = [UP, DOWN].includes(dir);
|
||||
|
||||
if (!isVerticalChange) {
|
||||
this.stopChangeCheckLoop();
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
swipedAmount: deltaX,
|
||||
isChanging: true,
|
||||
isResizeBlocked: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleSwipedHorizontal = ({
|
||||
event,
|
||||
deltaX,
|
||||
velocity,
|
||||
}: SwipeEventData): void => {
|
||||
const isFlick = velocity > 0.1;
|
||||
const { placeholderWidth } = this.state;
|
||||
const { changeStartDelta } = this.props;
|
||||
|
||||
let newSwipedAmount: number = 0;
|
||||
if (isFlick) {
|
||||
if (deltaX > 0) {
|
||||
newSwipedAmount = placeholderWidth;
|
||||
} else if (deltaX < 0) {
|
||||
newSwipedAmount = -1 * placeholderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(prevState) => {
|
||||
let isChanging: boolean =
|
||||
isFlick || Math.abs(deltaX) <= changeStartDelta;
|
||||
return {
|
||||
swipedAmount: isFlick ? newSwipedAmount : prevState.swipedAmount,
|
||||
isChanging,
|
||||
isResizeBlocked: isChanging,
|
||||
};
|
||||
},
|
||||
() => {
|
||||
this.updateWindowItems(true);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleItemChange = (newItemIdx: number, oldItemIdx: number): void => {
|
||||
const { onItemChange } = this.props;
|
||||
|
||||
if (onItemChange) {
|
||||
onItemChange(
|
||||
this.getItemKeyByIdx(newItemIdx),
|
||||
this.getItemKeyByIdx(oldItemIdx)
|
||||
);
|
||||
this.stopChangeCheckLoop();
|
||||
this.setState({
|
||||
activeIdx: newItemIdx,
|
||||
isChanging: false,
|
||||
});
|
||||
|
||||
this.startResizeBlockCheckLoop(() => {
|
||||
this.setState({
|
||||
isResizeBlocked: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handlePlaceholderClick = (newIdx: number, evt: React.MouseEvent): void => {
|
||||
const { activeIdx } = this.state;
|
||||
const { onItemChange } = this.props;
|
||||
|
||||
if (onItemChange) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
this.handleItemChange(newIdx, activeIdx);
|
||||
}
|
||||
};
|
||||
|
||||
static getItemPositionX = (
|
||||
swipedAmount: number,
|
||||
offset: number,
|
||||
placeholderWidth: number
|
||||
): number => {
|
||||
let itemPositionX = swipedAmount + offset * placeholderWidth;
|
||||
|
||||
if (itemPositionX >= 0) {
|
||||
return Math.min(placeholderWidth * 2, itemPositionX);
|
||||
} else {
|
||||
return Math.max(-1 * placeholderWidth * 2, itemPositionX);
|
||||
}
|
||||
};
|
||||
|
||||
renderPlaceholders = (): React.ReactNode => {
|
||||
const {
|
||||
childByIdxLookup,
|
||||
windowItems,
|
||||
swipedAmount,
|
||||
placeholderWidth,
|
||||
isChanging,
|
||||
} = this.state;
|
||||
|
||||
let placeholderStyles: Array<TransitionStyle> = windowItems.map(
|
||||
(windowItem) => {
|
||||
const child = childByIdxLookup[windowItem.idx];
|
||||
|
||||
return {
|
||||
key: windowItem.key,
|
||||
style: {
|
||||
transform: spring(
|
||||
ComponentCarousel.getItemPositionX(
|
||||
swipedAmount,
|
||||
windowItem.offset,
|
||||
placeholderWidth
|
||||
),
|
||||
{ stiffness: 400, damping: 30 }
|
||||
),
|
||||
},
|
||||
data: {
|
||||
offset: windowItem.offset,
|
||||
itemIdx: windowItem.idx,
|
||||
child,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
let classNames: Array<string> = ["ct-component-carousel__placeholders"];
|
||||
if (isChanging) {
|
||||
classNames.push("ct-component-carousel__placeholders--changing");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} ref={this.placeholderRef}>
|
||||
<TransitionMotion styles={placeholderStyles}>
|
||||
{(interpolatedStyles) => (
|
||||
<React.Fragment>
|
||||
{interpolatedStyles.map((config) => {
|
||||
const { PlaceholderComponent, placeholderProps, itemKey } =
|
||||
config.data.child.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-component-carousel__placeholder"
|
||||
key={config.key}
|
||||
onClick={this.handlePlaceholderClick.bind(
|
||||
this,
|
||||
config.data.itemIdx
|
||||
)}
|
||||
style={{
|
||||
transform: `translateX(${config.style.transform}px)`,
|
||||
}}
|
||||
>
|
||||
<PlaceholderComponent {...placeholderProps} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
renderActiveComponent = (): React.ReactNode => {
|
||||
const { activeIdx, childByIdxLookup, filteredChildren } = this.state;
|
||||
|
||||
if (activeIdx === null || !filteredChildren.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let child = childByIdxLookup[activeIdx];
|
||||
const { ContentComponent } = child.props;
|
||||
|
||||
return (
|
||||
<div className="ct-component-carousel__active">
|
||||
<ContentComponent key={child.props.itemKey} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Swipeable
|
||||
handlerFunctions={{
|
||||
onSwiping: this.handleSwiping,
|
||||
onSwipedLeft: this.handleSwipedHorizontal,
|
||||
onSwipedRight: this.handleSwipedHorizontal,
|
||||
}}
|
||||
>
|
||||
<div className="ct-component-carousel">
|
||||
{this.renderPlaceholders()}
|
||||
{this.renderActiveComponent()}
|
||||
</div>
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
itemKey: string;
|
||||
PlaceholderComponent: React.ElementType;
|
||||
placeholderProps?: any;
|
||||
ContentComponent: React.ElementType;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
export default class ComponentCarouselItem extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
isEnabled: true,
|
||||
};
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ComponentCarouselItem from "./ComponentCarouselItem";
|
||||
|
||||
export default ComponentCarouselItem;
|
||||
export { ComponentCarouselItem };
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
import { ConditionName } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Condition,
|
||||
ConditionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
conditions: Array<Condition>;
|
||||
onClick: () => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ConditionsSummary extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
renderConditions = (): React.ReactNode => {
|
||||
const { conditions, theme } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{conditions.map((condition) => (
|
||||
<ConditionName
|
||||
theme={theme}
|
||||
key={ConditionUtils.getUniqueKey(condition)}
|
||||
condition={condition}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderDefault = (): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
return (
|
||||
<div
|
||||
className={`ct-conditions-summary__default ${
|
||||
theme.isDarkMode ? "ct-conditions-summary__default--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
Add Active Conditions
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { conditions } = this.props;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (conditions.length) {
|
||||
contentNode = this.renderConditions();
|
||||
} else {
|
||||
contentNode = this.renderDefault();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-conditions-summary" onClick={this.handleClick}>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ConditionsSummary from "./ConditionsSummary";
|
||||
|
||||
export default ConditionsSummary;
|
||||
export { ConditionsSummary };
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
header: React.ReactNode;
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
const ContentGroup: React.FC<Props> = ({ header, extra, children }) => {
|
||||
return (
|
||||
<div className="ct-content-group">
|
||||
{header && (
|
||||
<div className="ct-content-group__header">
|
||||
<div className="ct-content-group__header-content">{header}</div>
|
||||
{extra && (
|
||||
<div className="ct-content-group__header-extra">{extra}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ct-content-group__content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentGroup;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ContentGroup from "./ContentGroup";
|
||||
|
||||
export default ContentGroup;
|
||||
export { ContentGroup };
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import { CoinIcon } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterCurrencyContract,
|
||||
Constants,
|
||||
FormatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
const generateCoinString = (coin: CharacterCurrencyContract | null): string => {
|
||||
if (!coin) {
|
||||
return "";
|
||||
}
|
||||
const pp = coin.pp ? `${coin.pp} ${Constants.CoinNamesEnum.PLATINUM}` : "";
|
||||
const gp = coin.gp ? `${coin.gp} ${Constants.CoinNamesEnum.GOLD}` : "";
|
||||
const ep = coin.ep ? `${coin.ep} ${Constants.CoinNamesEnum.ELECTRUM}` : "";
|
||||
const sp = coin.sp ? `${coin.sp} ${Constants.CoinNamesEnum.SILVER}` : "";
|
||||
const cp = coin.cp ? `${coin.cp} ${Constants.CoinNamesEnum.COPPER}` : "";
|
||||
|
||||
return `${pp} ${gp} ${ep} ${sp} ${cp}`;
|
||||
};
|
||||
|
||||
interface CurrencyRowProps {
|
||||
amount: number;
|
||||
coinType: Constants.CoinTypeEnum;
|
||||
label: string;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const CurrencyRow: React.FC<CurrencyRowProps> = ({
|
||||
amount,
|
||||
coinType,
|
||||
label,
|
||||
isDarkMode,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="ct-currency-button__currency-item-count"
|
||||
role="listitem"
|
||||
aria-label={`${amount} ${label}`}
|
||||
>
|
||||
<Tooltip
|
||||
isDarkMode={isDarkMode}
|
||||
className={`ct-currency-button__currency-item`}
|
||||
title={label}
|
||||
isInteractive={true}
|
||||
>
|
||||
<span className="ct-currency-button__currency-item-count">
|
||||
{FormatUtils.renderLocaleNumber(amount)}
|
||||
</span>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="ct-currency-button__currency-item-preview"
|
||||
>
|
||||
<CoinIcon coinType={coinType} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CoinProps {
|
||||
coin: CharacterCurrencyContract;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const Coins: React.FC<CoinProps> = ({ coin, isDarkMode }) => {
|
||||
let renderEmptyGold: boolean = false;
|
||||
if (!coin.pp && !coin.gp && !coin.ep && !coin.sp && !coin.cp) {
|
||||
renderEmptyGold = true;
|
||||
}
|
||||
return (
|
||||
<div className="ct-currency-button__currency-items">
|
||||
{!!coin.pp && (
|
||||
<CurrencyRow
|
||||
amount={coin.pp}
|
||||
coinType={Constants.CoinTypeEnum.pp}
|
||||
label={Constants.CoinNamesEnum.PLATINUM}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!!coin.gp || renderEmptyGold) && (
|
||||
<CurrencyRow
|
||||
amount={coin.gp}
|
||||
coinType={Constants.CoinTypeEnum.gp}
|
||||
label={Constants.CoinNamesEnum.GOLD}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
{!!coin.ep && (
|
||||
<CurrencyRow
|
||||
amount={coin.ep}
|
||||
coinType={Constants.CoinTypeEnum.ep}
|
||||
label={Constants.CoinNamesEnum.ELECTRUM}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
{!!coin.sp && (
|
||||
<CurrencyRow
|
||||
amount={coin.sp}
|
||||
coinType={Constants.CoinTypeEnum.sp}
|
||||
label={Constants.CoinNamesEnum.SILVER}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
{!!coin.cp && (
|
||||
<CurrencyRow
|
||||
amount={coin.cp}
|
||||
coinType={Constants.CoinTypeEnum.cp}
|
||||
label={Constants.CoinNamesEnum.COPPER}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
coin: CharacterCurrencyContract | null;
|
||||
isDarkMode: boolean;
|
||||
shouldShowPartyInventory: boolean;
|
||||
handleCurrencyClick: (evt: React.KeyboardEvent | React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const CurrencyButton: React.FC<Props> = ({
|
||||
coin,
|
||||
isDarkMode,
|
||||
shouldShowPartyInventory,
|
||||
handleCurrencyClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Manage ${
|
||||
shouldShowPartyInventory ? "Party " : ""
|
||||
}Coin ${generateCoinString(coin)}`}
|
||||
className="ct-currency-button__currency"
|
||||
onClick={handleCurrencyClick}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handleCurrencyClick(evt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{coin ? (
|
||||
<Coins coin={coin} isDarkMode={isDarkMode} />
|
||||
) : (
|
||||
<div className="ct-currency-button__currency-items">
|
||||
<CurrencyRow
|
||||
amount={0}
|
||||
coinType={Constants.CoinTypeEnum.gp}
|
||||
label={Constants.CoinNamesEnum.GOLD}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyButton;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CurrencyButton from "./CurrencyButton";
|
||||
|
||||
export default CurrencyButton;
|
||||
export { CurrencyButton };
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import {
|
||||
CoinIcon,
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
FormatUtils,
|
||||
ContainerUtils,
|
||||
Container,
|
||||
Constants,
|
||||
CharacterCurrencyContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import CurrencyPaneAdjuster from "../../../Shared/containers/panes/CurrencyPane/CurrencyPaneAdjuster";
|
||||
import { CurrencyErrorTypeEnum } from "../../../Shared/containers/panes/CurrencyPane/CurrencyPaneConstants";
|
||||
import CurrencyPaneCurrencyRow from "../../../Shared/containers/panes/CurrencyPane/CurrencyPaneCurrencyRow";
|
||||
import { CoinManagerContext } from "../../../Shared/managers/CoinManagerContext";
|
||||
|
||||
interface Props {
|
||||
initiallyCollapsed: boolean;
|
||||
isReadonly: boolean;
|
||||
container: Container;
|
||||
heading: string;
|
||||
handleCurrencyChangeError: (
|
||||
currencyName: string,
|
||||
errorType: CurrencyErrorTypeEnum
|
||||
) => void;
|
||||
handleCurrencyAdjust: (
|
||||
coin: Partial<CharacterCurrencyContract>,
|
||||
multiplier: 1 | -1,
|
||||
containerDefinitionKey: string
|
||||
) => void;
|
||||
handleAmountSet: (
|
||||
containerDefinitionKey: string,
|
||||
key: keyof CharacterCurrencyContract,
|
||||
amount: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const CoinContent = ({
|
||||
containerDefinitionKey,
|
||||
isReadonly,
|
||||
handleCurrencyChangeError,
|
||||
handleCurrencyAdjust,
|
||||
handleAmountSet,
|
||||
}) => {
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
if (!coinManager) {
|
||||
return null;
|
||||
}
|
||||
const coin = coinManager.getContainerCoin(containerDefinitionKey);
|
||||
if (!coin) {
|
||||
return null;
|
||||
}
|
||||
const { pp, gp, ep, sp, cp } = coin;
|
||||
|
||||
const shouldReadonly =
|
||||
isReadonly ||
|
||||
(containerDefinitionKey ===
|
||||
coinManager.getPartyEquipmentContainerDefinitionKey() &&
|
||||
coinManager.isSharingTurnedDeleteOnly());
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="ct-currency-pane__currencies">
|
||||
<CurrencyPaneCurrencyRow
|
||||
name="Platinum (pp)"
|
||||
onChange={(value) =>
|
||||
handleAmountSet(containerDefinitionKey, "pp", value)
|
||||
}
|
||||
onError={(errorType) =>
|
||||
handleCurrencyChangeError("Platinum", errorType)
|
||||
}
|
||||
value={pp}
|
||||
coinType={Constants.CoinTypeEnum.pp}
|
||||
conversion="1 pp = 10 gp"
|
||||
isReadonly={shouldReadonly}
|
||||
/>
|
||||
<CurrencyPaneCurrencyRow
|
||||
name="Gold (gp)"
|
||||
onChange={(value) =>
|
||||
handleAmountSet(containerDefinitionKey, "gp", value)
|
||||
}
|
||||
onError={(errorType) => handleCurrencyChangeError("Gold", errorType)}
|
||||
value={gp}
|
||||
coinType={Constants.CoinTypeEnum.gp}
|
||||
isReadonly={shouldReadonly}
|
||||
/>
|
||||
<CurrencyPaneCurrencyRow
|
||||
name="Electrum (ep)"
|
||||
onChange={(value) =>
|
||||
handleAmountSet(containerDefinitionKey, "ep", value)
|
||||
}
|
||||
onError={(errorType) =>
|
||||
handleCurrencyChangeError("Electrum", errorType)
|
||||
}
|
||||
value={ep}
|
||||
conversion="1 gp = 2 ep"
|
||||
coinType={Constants.CoinTypeEnum.ep}
|
||||
isReadonly={shouldReadonly}
|
||||
/>
|
||||
<CurrencyPaneCurrencyRow
|
||||
name="Silver (sp)"
|
||||
onChange={(value) =>
|
||||
handleAmountSet(containerDefinitionKey, "sp", value)
|
||||
}
|
||||
onError={(errorType) =>
|
||||
handleCurrencyChangeError("Silver", errorType)
|
||||
}
|
||||
value={sp}
|
||||
conversion="1 gp = 10 sp"
|
||||
coinType={Constants.CoinTypeEnum.sp}
|
||||
isReadonly={shouldReadonly}
|
||||
/>
|
||||
<CurrencyPaneCurrencyRow
|
||||
name="Copper (cp)"
|
||||
onChange={(value) =>
|
||||
handleAmountSet(containerDefinitionKey, "cp", value)
|
||||
}
|
||||
onError={(errorType) =>
|
||||
handleCurrencyChangeError("Copper", errorType)
|
||||
}
|
||||
value={cp}
|
||||
conversion="1 gp = 100 cp"
|
||||
isReadonly={shouldReadonly}
|
||||
coinType={Constants.CoinTypeEnum.cp}
|
||||
/>
|
||||
</div>
|
||||
{(coinManager.isSharingTurnedOn() ||
|
||||
!coinManager.isSharedContainerDefinitionKey(
|
||||
containerDefinitionKey
|
||||
)) && (
|
||||
<CurrencyPaneAdjuster
|
||||
onAdjust={handleCurrencyAdjust}
|
||||
isReadonly={isReadonly}
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export const CurrencyCollapsible: React.FC<Props> = ({
|
||||
initiallyCollapsed,
|
||||
container,
|
||||
handleCurrencyChangeError,
|
||||
isReadonly,
|
||||
handleCurrencyAdjust,
|
||||
handleAmountSet,
|
||||
heading,
|
||||
}: Props) => {
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
const containerDefinitionKey = ContainerUtils.getDefinitionKey(container);
|
||||
return (
|
||||
<Collapsible
|
||||
initiallyCollapsed={initiallyCollapsed}
|
||||
overrideCollapsed={initiallyCollapsed}
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={heading}
|
||||
callout={
|
||||
<span className="ct-currency-pane__collapsible-callout">
|
||||
{FormatUtils.renderLocaleNumber(
|
||||
coinManager?.getTotalContainerCoinInGold(
|
||||
ContainerUtils.getDefinitionKey(container)
|
||||
) ?? 0
|
||||
)}{" "}
|
||||
<span className="ct-currency-pane__collapsible-callout-icon">
|
||||
<CoinIcon coinType={Constants.CoinTypeEnum.gp} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CoinContent
|
||||
containerDefinitionKey={containerDefinitionKey}
|
||||
isReadonly={isReadonly}
|
||||
handleCurrencyChangeError={handleCurrencyChangeError}
|
||||
handleCurrencyAdjust={handleCurrencyAdjust}
|
||||
handleAmountSet={handleAmountSet}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyCollapsible;
|
||||
@@ -0,0 +1,4 @@
|
||||
import CurrencyCollapsible from "./CurrencyCollapsible";
|
||||
|
||||
export default CurrencyCollapsible;
|
||||
export { CurrencyCollapsible };
|
||||
@@ -0,0 +1,178 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
ResistanceIcon,
|
||||
ImmunityIcon,
|
||||
VulnerabilityIcon,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
DefenseAdjustmentGroup,
|
||||
FormatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
const DEFENSE_GROUP = {
|
||||
RESISTANCE: "Resistance",
|
||||
IMMUNITY: "Immunity",
|
||||
VULNERABILITY: "Vulnerability",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
resistances: Array<DefenseAdjustmentGroup>;
|
||||
immunities: Array<DefenseAdjustmentGroup>;
|
||||
vulnerabilities: Array<DefenseAdjustmentGroup>;
|
||||
onClick?: () => void;
|
||||
theme?: CharacterTheme;
|
||||
}
|
||||
export default class DefensesSummary extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
renderDamageAdjustmentList = (
|
||||
defenses: Array<DefenseAdjustmentGroup>
|
||||
): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
let orderedDefenses = orderBy(defenses, (group) => group.name);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{orderedDefenses.map((defense: DefenseAdjustmentGroup, idx) => (
|
||||
<span
|
||||
className={`ct-defenses-summary__defense ${
|
||||
theme?.isDarkMode ? "ct-defenses-summary__defense--dark-mode" : ""
|
||||
}`}
|
||||
key={`${defense.name}-${idx}`}
|
||||
>
|
||||
<Tooltip
|
||||
isDarkMode={theme?.isDarkMode}
|
||||
title={defense.sources.join(", ")}
|
||||
tippyOpts={{ dynamicTitle: true }}
|
||||
>
|
||||
{defense.name}
|
||||
{defense.hasCustom ? "*" : ""}
|
||||
</Tooltip>
|
||||
</span>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
renderDamageAdjustmentGroup = (
|
||||
groupType: string,
|
||||
label: string,
|
||||
damageAdjustments: Array<DefenseAdjustmentGroup>,
|
||||
isSingleLine: boolean,
|
||||
theme: CharacterTheme | undefined
|
||||
): React.ReactNode => {
|
||||
if (!damageAdjustments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["ct-defenses-summary__group"];
|
||||
if (isSingleLine) {
|
||||
classNames.push("ct-defenses-summary__group--single");
|
||||
}
|
||||
let iconComponent: React.ReactNode;
|
||||
|
||||
switch (FormatUtils.slugify(groupType)) {
|
||||
case "resistance":
|
||||
iconComponent = <ResistanceIcon theme={theme} />;
|
||||
break;
|
||||
case "immunity":
|
||||
iconComponent = <ImmunityIcon theme={theme} />;
|
||||
break;
|
||||
case "vulnerability":
|
||||
iconComponent = <VulnerabilityIcon theme={theme} />;
|
||||
break;
|
||||
default:
|
||||
iconComponent = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<span className="ct-defenses-summary__group-preview">
|
||||
{iconComponent}
|
||||
</span>
|
||||
<span className="ct-defenses-summary__group-items">
|
||||
{this.renderDamageAdjustmentList(damageAdjustments)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
renderDefault = (): React.ReactNode => {
|
||||
const { theme } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-defenses-summary__default ${
|
||||
theme?.isDarkMode ? "ct-defenses-summary__default--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
Resistances, Immunities, or Vulnerabilities
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderDefenseGroups = (): React.ReactNode => {
|
||||
const { resistances, vulnerabilities, immunities, theme } = this.props;
|
||||
|
||||
let isSingleLine: boolean =
|
||||
resistances.length > 0 &&
|
||||
vulnerabilities.length > 0 &&
|
||||
immunities.length > 0;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.renderDamageAdjustmentGroup(
|
||||
DEFENSE_GROUP.RESISTANCE,
|
||||
"Resistances",
|
||||
resistances,
|
||||
isSingleLine,
|
||||
theme
|
||||
)}
|
||||
{this.renderDamageAdjustmentGroup(
|
||||
DEFENSE_GROUP.IMMUNITY,
|
||||
"Immunity",
|
||||
immunities,
|
||||
isSingleLine,
|
||||
theme
|
||||
)}
|
||||
{this.renderDamageAdjustmentGroup(
|
||||
DEFENSE_GROUP.VULNERABILITY,
|
||||
"Vulnerabilities",
|
||||
vulnerabilities,
|
||||
isSingleLine,
|
||||
theme
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { resistances, vulnerabilities, immunities } = this.props;
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (!resistances.length && !vulnerabilities.length && !immunities.length) {
|
||||
contentNode = this.renderDefault();
|
||||
} else {
|
||||
contentNode = this.renderDefenseGroups();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-defenses-summary" onClick={this.handleClick}>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import DefensesSummary from "./DefensesSummary";
|
||||
|
||||
export default DefensesSummary;
|
||||
export { DefensesSummary };
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
CharacterCurrencyContract,
|
||||
Constants,
|
||||
FormatUtils,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import CurrencyButton from "../CurrencyButton";
|
||||
|
||||
interface Props {
|
||||
currencies: CharacterCurrencyContract | null;
|
||||
weight: number;
|
||||
weightSpeedType: Constants.WeightSpeedTypeEnum;
|
||||
onCurrencyClick?: (evt: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
onWeightClick?: () => void;
|
||||
onCampaignClick?: () => void;
|
||||
onManageClick?: () => void;
|
||||
enableManage: boolean;
|
||||
isReadonly: boolean;
|
||||
isDarkMode: boolean;
|
||||
shouldShowPartyInventory: boolean;
|
||||
campaignName: string | null;
|
||||
}
|
||||
export default class EquipmentOverview extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
enableManage: true,
|
||||
isReadonly: false,
|
||||
};
|
||||
|
||||
handleCurrencyClick = (evt: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
const { onCurrencyClick } = this.props;
|
||||
|
||||
if (onCurrencyClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onCurrencyClick(evt);
|
||||
}
|
||||
};
|
||||
|
||||
handleCampaignClick = (evt: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
const { onCampaignClick } = this.props;
|
||||
|
||||
if (onCampaignClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onCampaignClick();
|
||||
}
|
||||
};
|
||||
|
||||
handleWeightClick = (evt: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
const { onWeightClick } = this.props;
|
||||
|
||||
if (onWeightClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onWeightClick();
|
||||
}
|
||||
};
|
||||
|
||||
handleManageClick = (evt: React.MouseEvent | React.KeyboardEvent): void => {
|
||||
const { onManageClick } = this.props;
|
||||
|
||||
if (onManageClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onManageClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
weight,
|
||||
weightSpeedType,
|
||||
enableManage,
|
||||
isReadonly,
|
||||
isDarkMode,
|
||||
shouldShowPartyInventory,
|
||||
campaignName,
|
||||
currencies,
|
||||
} = this.props;
|
||||
|
||||
let weightSpeedLabel =
|
||||
RuleDataUtils.getWeightSpeedTypeLabel(weightSpeedType);
|
||||
|
||||
return (
|
||||
// TODO: add a min height?
|
||||
<div className="ct-equipment-overview">
|
||||
{!shouldShowPartyInventory ? (
|
||||
<div
|
||||
role="button"
|
||||
className="ct-equipment-overview__weight"
|
||||
onClick={this.handleWeightClick}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
this.handleWeightClick(evt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ct-equipment-overview__weight-carried">
|
||||
<span
|
||||
className={`ct-equipment-overview__weight-carried-label ${
|
||||
isDarkMode
|
||||
? "ct-equipment-overview__weight-carried--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
Weight Carried:
|
||||
</span>
|
||||
<span
|
||||
className={`ct-equipment-overview__weight-carried-amount ${
|
||||
isDarkMode
|
||||
? "ct-equipment-overview__weight-carried--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<NumberDisplay
|
||||
data-testid="weight-carried-number"
|
||||
type="weightInLb"
|
||||
number={weight}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ct-equipment-overview__weight-speed ct-equipment-overview__weight-speed--${FormatUtils.slugify(
|
||||
weightSpeedLabel
|
||||
)}`}
|
||||
>
|
||||
{weightSpeedLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
className="ct-equipment-overview__weight"
|
||||
onClick={this.handleCampaignClick}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
this.handleCampaignClick(evt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ct-equipment-overview__weight-carried">
|
||||
<span
|
||||
className={`ct-equipment-overview__weight-carried-label ${
|
||||
isDarkMode
|
||||
? "ct-equipment-overview__weight-carried--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
CAMPAIGN:
|
||||
<span
|
||||
className={`ct-equipment-overview__weight-carried-amount ${
|
||||
isDarkMode
|
||||
? "ct-equipment-overview__weight-carried--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{campaignName || "N/A"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ct-equipment-overview__sep" />
|
||||
<CurrencyButton
|
||||
handleCurrencyClick={this.handleCurrencyClick}
|
||||
coin={currencies}
|
||||
isDarkMode={isDarkMode}
|
||||
shouldShowPartyInventory={shouldShowPartyInventory}
|
||||
/>
|
||||
{enableManage && !isReadonly && (
|
||||
<div
|
||||
role="button"
|
||||
className="ct-equipment-overview__manage"
|
||||
onClick={this.handleManageClick}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
this.handleManageClick(evt);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Manage Equipment" isDarkMode={isDarkMode}>
|
||||
<span className="ct-equipment-overview__manage-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import EquipmentOverview from "./EquipmentOverview";
|
||||
|
||||
export default EquipmentOverview;
|
||||
export { EquipmentOverview };
|
||||
@@ -0,0 +1,602 @@
|
||||
import { orderBy, intersection } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DarkFilterSvg,
|
||||
ThemedFilterSvg,
|
||||
LightFilterSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
ExtraManager,
|
||||
ExtrasFilterData,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { FilterUtils } from "../../../Shared/utils";
|
||||
import ExtrasFilterArrayFilter from "./ExtrasFilterArrayFilter";
|
||||
|
||||
interface Props {
|
||||
extras: Array<ExtraManager>;
|
||||
callout?: React.ReactNode;
|
||||
onDataUpdate: (filterData: ExtrasFilterData) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
interface DefaultExtrasFilterState {
|
||||
filterQuery: string;
|
||||
filterTypes: Array<string>;
|
||||
filterSubTypes: Array<string>;
|
||||
filterEnvironments: Array<string>;
|
||||
filterTags: Array<string>;
|
||||
filterMovements: Array<string>;
|
||||
}
|
||||
interface State extends DefaultExtrasFilterState {
|
||||
filteredExtras: Array<ExtraManager>;
|
||||
showAdvancedFilters: boolean;
|
||||
}
|
||||
|
||||
type ExtraFilterPropertyKey = keyof State;
|
||||
|
||||
export default class ExtrasFilter extends React.PureComponent<Props, State> {
|
||||
defaultFilterState: DefaultExtrasFilterState = {
|
||||
filterQuery: "",
|
||||
filterTypes: [],
|
||||
filterSubTypes: [],
|
||||
filterEnvironments: [],
|
||||
filterTags: [],
|
||||
filterMovements: [],
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...this.defaultFilterState,
|
||||
filteredExtras: props.extras,
|
||||
showAdvancedFilters: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.handleDataUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { extras } = this.props;
|
||||
|
||||
if (extras !== prevProps.extras) {
|
||||
this.setState(
|
||||
{
|
||||
filteredExtras: this.filterExtras(
|
||||
this.props,
|
||||
this.getFilters(prevState)
|
||||
),
|
||||
},
|
||||
this.handleDataUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getFilters = (state: State): DefaultExtrasFilterState => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
} = state;
|
||||
return {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
};
|
||||
};
|
||||
|
||||
filterExtras = (
|
||||
props: Props,
|
||||
filters: DefaultExtrasFilterState
|
||||
): Array<ExtraManager> => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
} = filters;
|
||||
|
||||
return props.extras.filter((extra) => {
|
||||
if (
|
||||
filterQuery &&
|
||||
!FilterUtils.doesQueryMatchData(
|
||||
filterQuery,
|
||||
extra.getName(),
|
||||
extra.getSearchTags()
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filterTypes.length &&
|
||||
!intersection(filterTypes, extra.getFilterTypes()).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filterEnvironments.length &&
|
||||
!intersection(filterEnvironments, extra.getEnvironmentNames()).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filterTags.length &&
|
||||
!intersection(filterTags, extra.getTags()).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filterMovements.length &&
|
||||
!intersection(filterMovements, extra.getMovementNames()).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
getFilterCount = (): number => {
|
||||
const {
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
} = this.state;
|
||||
|
||||
let count: number = 0;
|
||||
count += filterTags.length;
|
||||
count += filterTypes.length;
|
||||
count += filterSubTypes.length;
|
||||
count += filterEnvironments.length;
|
||||
count += filterMovements.length;
|
||||
return count;
|
||||
};
|
||||
|
||||
getTagOptions = (tags: Array<string>): Array<HtmlSelectOption> => {
|
||||
return tags.sort().map((tag) => ({ label: tag, value: tag }));
|
||||
};
|
||||
|
||||
getTypeOptions = (types: Array<string>): Array<HtmlSelectOption> => {
|
||||
return orderBy(
|
||||
types.map((type) => ({
|
||||
label: type,
|
||||
value: type,
|
||||
})),
|
||||
"label"
|
||||
);
|
||||
};
|
||||
|
||||
getEnvironmentOptions = (
|
||||
environmentNames: Array<string>
|
||||
): Array<HtmlSelectOption> => {
|
||||
return orderBy(
|
||||
environmentNames.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
"label"
|
||||
);
|
||||
};
|
||||
|
||||
getMovementOptions = (names: Array<string>): Array<HtmlSelectOption> => {
|
||||
return orderBy(
|
||||
names.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
})),
|
||||
"label"
|
||||
);
|
||||
};
|
||||
|
||||
isFiltering = (): boolean => {
|
||||
const { filterQuery } = this.state;
|
||||
|
||||
const filterCount = this.getFilterCount();
|
||||
return filterCount > 0 || !!filterQuery;
|
||||
};
|
||||
|
||||
handleDataUpdate = (): void => {
|
||||
const { onDataUpdate } = this.props;
|
||||
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate({
|
||||
...this.state,
|
||||
filterCount: this.getFilterCount(),
|
||||
isFiltering: this.isFiltering(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleFilterClear = (
|
||||
propertyKey: ExtraFilterPropertyKey,
|
||||
resetState: Array<string> | Array<number> | null
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let extras = this.filterExtras(this.props, {
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: resetState,
|
||||
});
|
||||
return {
|
||||
...prevState,
|
||||
[propertyKey]: resetState,
|
||||
filteredExtras: extras,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleArrayFilterClear = (
|
||||
propertyKey: ExtraFilterPropertyKey,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
this.handleFilterClear(propertyKey, []);
|
||||
};
|
||||
|
||||
handleBoolFilterClear = (
|
||||
propertyKey: ExtraFilterPropertyKey,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
this.handleFilterClear(propertyKey, null);
|
||||
};
|
||||
|
||||
handleFiltersClear = (): void => {
|
||||
const { extras } = this.props;
|
||||
this.setState(
|
||||
{
|
||||
...this.defaultFilterState,
|
||||
filteredExtras: extras,
|
||||
},
|
||||
this.handleDataUpdate
|
||||
);
|
||||
};
|
||||
|
||||
handleQueryUpdate = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let value = evt.target.value;
|
||||
this.setState((prevState: State) => {
|
||||
let extras = this.filterExtras(this.props, {
|
||||
...this.getFilters(prevState),
|
||||
filterQuery: value,
|
||||
});
|
||||
return {
|
||||
filterQuery: value,
|
||||
filteredExtras: extras,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleFilterAdvancedToggle = (): void => {
|
||||
this.setState(
|
||||
(prevState: State) => ({
|
||||
showAdvancedFilters: !prevState.showAdvancedFilters,
|
||||
}),
|
||||
this.handleDataUpdate
|
||||
);
|
||||
};
|
||||
|
||||
//TODO get array key by filtering for array type
|
||||
handleFilterArrayToggle = (
|
||||
arrayKey: string,
|
||||
value: string | number
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let includesItem: boolean = prevState[arrayKey].includes(value);
|
||||
let newFilterArray: Array<string> | Array<number> = [];
|
||||
if (includesItem) {
|
||||
newFilterArray = prevState[arrayKey].filter(
|
||||
(filterTag) => filterTag !== value
|
||||
);
|
||||
} else {
|
||||
newFilterArray = [...prevState[arrayKey], value];
|
||||
}
|
||||
let extras = this.filterExtras(this.props, {
|
||||
...this.getFilters(prevState),
|
||||
[arrayKey]: newFilterArray,
|
||||
});
|
||||
return {
|
||||
...prevState,
|
||||
[arrayKey]: newFilterArray,
|
||||
filteredExtras: extras,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleExclusiveCheckboxFilterChange = (
|
||||
propertyKey: ExtraFilterPropertyKey,
|
||||
value: boolean | null
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let extras = this.filterExtras(this.props, {
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: value,
|
||||
});
|
||||
return {
|
||||
...prevState,
|
||||
[propertyKey]: value,
|
||||
filteredExtras: extras,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
renderAdvancedFilters = (): React.ReactNode => {
|
||||
const {
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
} = this.state;
|
||||
const { extras } = this.props;
|
||||
|
||||
let uniqueTags: Set<string> = new Set();
|
||||
let uniqueTypes: Set<string> = new Set();
|
||||
let uniqueEnvironments: Set<string> = new Set();
|
||||
let uniqueMovements: Set<string> = new Set();
|
||||
for (let i = 0; i < extras.length; i++) {
|
||||
let extra = extras[i];
|
||||
|
||||
extra.getFilterTypes().forEach((type) => uniqueTypes.add(type));
|
||||
|
||||
let tags = extra.getTags();
|
||||
tags.forEach((tag) => uniqueTags.add(tag));
|
||||
|
||||
// let environmentIds = extra.getEnvironments();
|
||||
let environmentNames = extra.getEnvironmentNames();
|
||||
environmentNames.forEach((name) => uniqueEnvironments.add(name));
|
||||
|
||||
let movementNames = extra.getMovementNames();
|
||||
movementNames.forEach((name) => uniqueMovements.add(name));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-extras-filter__adv-filters">
|
||||
<ExtrasFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Type"
|
||||
propertyKey="filterTypes"
|
||||
currentValues={filterTypes}
|
||||
availableOptions={this.getTypeOptions(Array.from(uniqueTypes))}
|
||||
/>
|
||||
<ExtrasFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Tags"
|
||||
propertyKey="filterTags"
|
||||
currentValues={filterTags}
|
||||
availableOptions={this.getTagOptions(Array.from(uniqueTags))}
|
||||
/>
|
||||
<ExtrasFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Environments"
|
||||
propertyKey="filterEnvironments"
|
||||
currentValues={filterEnvironments}
|
||||
availableOptions={this.getEnvironmentOptions(
|
||||
Array.from(uniqueEnvironments)
|
||||
)}
|
||||
/>
|
||||
<ExtrasFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Movements"
|
||||
propertyKey="filterMovements"
|
||||
currentValues={filterMovements}
|
||||
availableOptions={this.getMovementOptions(
|
||||
Array.from(uniqueMovements)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActiveArray = (
|
||||
label: string,
|
||||
options: Array<HtmlSelectOption>,
|
||||
propertyKey: ExtraFilterPropertyKey
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<div
|
||||
className="ct-extras-filter__active"
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<div className="ct-extras-filter__active-label">{label}:</div>
|
||||
<div className="ct-extras-filter__active-value">
|
||||
{options.map((option) => option.label).join(" or ")}
|
||||
</div>
|
||||
<div className="ct-extras-filter__active-remove">
|
||||
<div
|
||||
className="ct-extras-filter__active-remove-icon"
|
||||
onClick={this.handleArrayFilterClear.bind(this, propertyKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActiveExclusiveCheckbox = (
|
||||
label: string,
|
||||
value: boolean,
|
||||
propertyKey: ExtraFilterPropertyKey,
|
||||
trueLabel: string = "Yes",
|
||||
falseLabel: string = "No"
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<div
|
||||
className="ct-extras-filter__active"
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<div className="ct-extras-filter__active-label">{label}:</div>
|
||||
<div className="ct-extras-filter__active-value">
|
||||
{value ? trueLabel : falseLabel}
|
||||
</div>
|
||||
<div className="ct-extras-filter__active-remove">
|
||||
<div
|
||||
className="ct-extras-filter__active-remove-icon"
|
||||
onClick={this.handleBoolFilterClear.bind(this, propertyKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActives = (): React.ReactNode => {
|
||||
const {
|
||||
filterTypes,
|
||||
filterSubTypes,
|
||||
filterEnvironments,
|
||||
filterTags,
|
||||
filterMovements,
|
||||
} = this.state;
|
||||
|
||||
let activeNodes: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
|
||||
if (filterTypes.length) {
|
||||
activeNodes.push({
|
||||
key: "filterTypes",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterTypes.length === 1 ? "Type" : "Types",
|
||||
this.getTypeOptions(filterTypes),
|
||||
"filterTypes"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterTags.length) {
|
||||
activeNodes.push({
|
||||
key: "filterTags",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterTags.length === 1 ? "Tag" : "Tags",
|
||||
this.getTagOptions(filterTags),
|
||||
"filterTags"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterEnvironments.length) {
|
||||
activeNodes.push({
|
||||
key: "filterEnvironments",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterEnvironments.length === 1 ? "Habitat" : "Habitats",
|
||||
this.getEnvironmentOptions(filterEnvironments),
|
||||
"filterEnvironments"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterMovements.length) {
|
||||
activeNodes.push({
|
||||
key: "filterMovements",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterMovements.length === 1 ? "Movement" : "Movements",
|
||||
this.getMovementOptions(filterMovements),
|
||||
"filterMovements"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!activeNodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-extras-filter__actives">
|
||||
{activeNodes.map((activeNode) => (
|
||||
<React.Fragment key={activeNode.key}>
|
||||
{activeNode.node}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { filterQuery, showAdvancedFilters } = this.state;
|
||||
const { callout, theme } = this.props;
|
||||
|
||||
const filterCount = this.getFilterCount();
|
||||
const hasFilter = this.isFiltering();
|
||||
|
||||
let advancedClassNames: Array<string> = ["ct-extras-filter__advanced"];
|
||||
let FilterIcon: React.ComponentType<any> = theme.isDarkMode
|
||||
? LightFilterSvg
|
||||
: DarkFilterSvg;
|
||||
if (filterCount > 0) {
|
||||
advancedClassNames.push("ct-extras-filter__advanced--active");
|
||||
FilterIcon = ThemedFilterSvg;
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["ct-extras-filter"];
|
||||
if (showAdvancedFilters) {
|
||||
classNames.push("ct-extras-filter--show-advanced");
|
||||
}
|
||||
if (hasFilter) {
|
||||
classNames.push("ct-extras-filter--has-filter");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-extras-filter__interactions">
|
||||
<div className="ct-extras-filter__box">
|
||||
{this.renderFilterBarActives()}
|
||||
<div className="ct-extras-filter__primary">
|
||||
<div className="ct-extras-filter__primary-group ct-extras-filter__primary-group--first">
|
||||
<div className="ct-extras-filter__icon" />
|
||||
</div>
|
||||
<div className="ct-extras-filter__field">
|
||||
<input
|
||||
className="ct-extras-filter__input"
|
||||
type="search"
|
||||
placeholder="Search Names, Types, or Tags"
|
||||
value={filterQuery}
|
||||
onChange={this.handleQueryUpdate}
|
||||
/>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<div
|
||||
className="ct-extras-filter__clear"
|
||||
onClick={this.handleFiltersClear}
|
||||
>
|
||||
Clear X
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={advancedClassNames.join(" ")}
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<FilterIcon theme={theme} />
|
||||
{filterCount > 0 && (
|
||||
<span className="ct-extras-filter__advanced-callout">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{callout && (
|
||||
<div className="ct-extras-filter__callout">{callout}</div>
|
||||
)}
|
||||
</div>
|
||||
{showAdvancedFilters && this.renderAdvancedFilters()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ExtrasFilterAdvancedFilter extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-extras-filter__adv-filter">{this.props.children}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterAdvancedFilter from "./ExtrasFilterAdvancedFilter";
|
||||
|
||||
export default ExtrasFilterAdvancedFilter;
|
||||
export { ExtrasFilterAdvancedFilter };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ExtrasFilterAdvancedFilterLabel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-extras-filter__adv-filter-label">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterAdvancedFilterLabel from "./ExtrasFilterAdvancedFilterLabel";
|
||||
|
||||
export default ExtrasFilterAdvancedFilterLabel;
|
||||
export { ExtrasFilterAdvancedFilterLabel };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ExtrasFilterAdvancedFilterOption extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-extras-filter__adv-filter-option">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterAdvancedFilterOption from "./ExtrasFilterAdvancedFilterOption";
|
||||
|
||||
export default ExtrasFilterAdvancedFilterOption;
|
||||
export { ExtrasFilterAdvancedFilterOption };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ExtrasFilterAdvancedFilterOptions extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-extras-filter__adv-filter-options">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterAdvancedFilterOptions from "./ExtrasFilterAdvancedFilterOptions";
|
||||
|
||||
export default ExtrasFilterAdvancedFilterOptions;
|
||||
export { ExtrasFilterAdvancedFilterOptions };
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
|
||||
import { HtmlSelectOption } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ExtrasFilterAdvancedFilter from "../ExtrasFilterAdvancedFilter";
|
||||
import ExtrasFilterAdvancedFilterLabel from "../ExtrasFilterAdvancedFilterLabel";
|
||||
import ExtrasFilterAdvancedFilterOption from "../ExtrasFilterAdvancedFilterOption";
|
||||
import ExtrasFilterAdvancedFilterOptions from "../ExtrasFilterAdvancedFilterOptions";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
label: string;
|
||||
currentValues: Array<string | number>;
|
||||
availableOptions: Array<HtmlSelectOption>;
|
||||
onUpdate: (propertyKey: string, value: string | number) => void;
|
||||
}
|
||||
export default class ExtrasFilterArrayFilter extends React.PureComponent<Props> {
|
||||
handleFilterArrayToggle = (
|
||||
value: string | number,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
const { onUpdate, propertyKey } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, value);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { availableOptions, currentValues, label } = this.props;
|
||||
|
||||
if (availableOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtrasFilterAdvancedFilter>
|
||||
<ExtrasFilterAdvancedFilterLabel>
|
||||
{label}
|
||||
</ExtrasFilterAdvancedFilterLabel>
|
||||
<ExtrasFilterAdvancedFilterOptions>
|
||||
{availableOptions.map((option) => {
|
||||
let classNames: Array<string> = [
|
||||
"ct-extras-filter__adv-filter-button",
|
||||
];
|
||||
if (currentValues.includes(option.value)) {
|
||||
classNames.push("ct-extras-filter__adv-filter-button--active");
|
||||
}
|
||||
return (
|
||||
<ExtrasFilterAdvancedFilterOption key={option.value}>
|
||||
<span
|
||||
className={classNames.join(" ")}
|
||||
onClick={this.handleFilterArrayToggle.bind(
|
||||
this,
|
||||
option.value
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</ExtrasFilterAdvancedFilterOption>
|
||||
);
|
||||
})}
|
||||
</ExtrasFilterAdvancedFilterOptions>
|
||||
</ExtrasFilterAdvancedFilter>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterArrayFilter from "./ExtrasFilterArrayFilter";
|
||||
|
||||
export default ExtrasFilterArrayFilter;
|
||||
export { ExtrasFilterArrayFilter };
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox } from "@dndbeyond/character-components/es";
|
||||
|
||||
import ExtrasFilterAdvancedFilter from "../ExtrasFilterAdvancedFilter";
|
||||
import ExtrasFilterAdvancedFilterLabel from "../ExtrasFilterAdvancedFilterLabel";
|
||||
import ExtrasFilterAdvancedFilterOption from "../ExtrasFilterAdvancedFilterOption";
|
||||
import ExtrasFilterAdvancedFilterOptions from "../ExtrasFilterAdvancedFilterOptions";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onUpdate: (propertyKey: string, value: boolean | null) => void;
|
||||
trueLabel: string;
|
||||
falseLabel: string;
|
||||
nullLabel: string;
|
||||
}
|
||||
interface State {
|
||||
value: boolean | null;
|
||||
}
|
||||
export default class ExclusiveCheckboxFilter extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
trueLabel: "Yes",
|
||||
falseLabel: "No",
|
||||
nullLabel: "Both",
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.value,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { value } = this.props;
|
||||
|
||||
if (value !== prevState.value) {
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdate = (value: boolean | null): void => {
|
||||
const { onUpdate, propertyKey } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, value);
|
||||
}
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { label, trueLabel, falseLabel, nullLabel } = this.props;
|
||||
|
||||
return (
|
||||
<ExtrasFilterAdvancedFilter>
|
||||
<ExtrasFilterAdvancedFilterLabel>
|
||||
{label}
|
||||
</ExtrasFilterAdvancedFilterLabel>
|
||||
<ExtrasFilterAdvancedFilterOptions>
|
||||
<ExtrasFilterAdvancedFilterOption>
|
||||
<div className="ct-extras-filter__adv-filter-checkbox">
|
||||
<div className="ct-extras-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, true)}
|
||||
enabled={value === true}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-extras-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, true)}
|
||||
>
|
||||
{trueLabel}
|
||||
</div>
|
||||
</div>
|
||||
</ExtrasFilterAdvancedFilterOption>
|
||||
<ExtrasFilterAdvancedFilterOption>
|
||||
<div className="ct-extras-filter__adv-filter-checkbox">
|
||||
<div className="ct-extras-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, false)}
|
||||
enabled={value === false}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-extras-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, false)}
|
||||
>
|
||||
{falseLabel}
|
||||
</div>
|
||||
</div>
|
||||
</ExtrasFilterAdvancedFilterOption>
|
||||
<ExtrasFilterAdvancedFilterOption>
|
||||
<div className="ct-extras-filter__adv-filter-checkbox">
|
||||
<div className="ct-extras-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, null)}
|
||||
enabled={value === null}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-extras-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, null)}
|
||||
>
|
||||
{nullLabel}
|
||||
</div>
|
||||
</div>
|
||||
</ExtrasFilterAdvancedFilterOption>
|
||||
</ExtrasFilterAdvancedFilterOptions>
|
||||
</ExtrasFilterAdvancedFilter>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ExtrasFilterExclusiveCheckboxFilter from "./ExtrasFilterExclusiveCheckboxFilter";
|
||||
|
||||
export default ExtrasFilterExclusiveCheckboxFilter;
|
||||
export { ExtrasFilterExclusiveCheckboxFilter };
|
||||
@@ -0,0 +1,18 @@
|
||||
import ExtrasFilter from "./ExtrasFilter";
|
||||
import ExtrasFilterAdvancedFilter from "./ExtrasFilterAdvancedFilter";
|
||||
import ExtrasFilterAdvancedFilterLabel from "./ExtrasFilterAdvancedFilterLabel";
|
||||
import ExtrasFilterAdvancedFilterOption from "./ExtrasFilterAdvancedFilterOption";
|
||||
import ExtrasFilterAdvancedFilterOptions from "./ExtrasFilterAdvancedFilterOptions";
|
||||
import ExtrasFilterArrayFilter from "./ExtrasFilterArrayFilter";
|
||||
import ExtrasFilterExclusiveCheckboxFilter from "./ExtrasFilterExclusiveCheckboxFilter";
|
||||
|
||||
export default ExtrasFilter;
|
||||
export {
|
||||
ExtrasFilter,
|
||||
ExtrasFilterAdvancedFilter,
|
||||
ExtrasFilterAdvancedFilterLabel,
|
||||
ExtrasFilterAdvancedFilterOption,
|
||||
ExtrasFilterAdvancedFilterOptions,
|
||||
ExtrasFilterArrayFilter,
|
||||
ExtrasFilterExclusiveCheckboxFilter,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
CharacterTheme,
|
||||
DataOriginRefData,
|
||||
FeatManager,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { CharacterFeaturesManagerContext } from "../../../Shared/managers/CharacterFeaturesManagerContext";
|
||||
import { FeatFeatureSnippet } from "../FeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
onFeatureClick: (feat: FeatManager) => void;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export const FeatsDetail: React.FC<Props> = ({
|
||||
dataOriginRefData,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
ruleData,
|
||||
snippetData,
|
||||
isReadonly,
|
||||
theme,
|
||||
onFeatureClick,
|
||||
proficiencyBonus,
|
||||
abilityLookup,
|
||||
}) => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
const currentFeats = characterFeaturesManager.getStandardFeats();
|
||||
|
||||
return (
|
||||
<div className="ct-feats-detail">
|
||||
{currentFeats.length ? (
|
||||
<React.Fragment>
|
||||
{currentFeats.map((feat) => (
|
||||
<FeatFeatureSnippet
|
||||
key={feat.getId()}
|
||||
feat={feat}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
ruleData={ruleData}
|
||||
snippetData={snippetData}
|
||||
isReadonly={isReadonly}
|
||||
onFeatureClick={() => onFeatureClick(feat)}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
abilityLookup={abilityLookup}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div
|
||||
className={`ct-feats-detail__default ${
|
||||
theme.isDarkMode ? "ct-feats-detail__default--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<p>
|
||||
You have no feats chosen, you can add feats outside of normal
|
||||
progression in the manage screen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FeatsDetail;
|
||||
@@ -0,0 +1,4 @@
|
||||
import FeatsDetail from "./FeatsDetail";
|
||||
|
||||
export default FeatsDetail;
|
||||
export { FeatsDetail };
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseFeat,
|
||||
CharClass,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
DataOriginBaseAction,
|
||||
DataOriginRefData,
|
||||
DataOriginSpell,
|
||||
InfusionChoice,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
AccessUtils,
|
||||
CharacterTheme,
|
||||
CharacterFeaturesManager,
|
||||
FeatManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { DataOriginTypeEnum } from "~/constants";
|
||||
import { FeatureSnippet } from "~/subApps/sheet/components/FeatureSnippet";
|
||||
|
||||
import FeatFeatureSnippet from "../FeatFeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
feature: ClassFeature;
|
||||
charClass: CharClass;
|
||||
extraMeta: Array<string>;
|
||||
onActionClick: (action: DataOriginBaseAction) => void;
|
||||
onActionUseSet: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onSpellClick: (spell: DataOriginSpell) => void;
|
||||
onSpellUseSet: (spell: DataOriginSpell, uses: number) => void;
|
||||
onFeatureClick: (feat: ClassFeature, charClass: CharClass) => void;
|
||||
onInfusionChoiceClick: (infusionChoice: InfusionChoice) => void;
|
||||
|
||||
showHeader?: boolean;
|
||||
showDescription?: boolean;
|
||||
|
||||
feats: Array<BaseFeat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
onFeatClick: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager;
|
||||
}
|
||||
export default class ClassFeatureSnippet extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
extraMeta: [],
|
||||
showDescription: false,
|
||||
};
|
||||
|
||||
handleFeatureClick = (): void => {
|
||||
const { feature, charClass, onFeatureClick } = this.props;
|
||||
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick(feature, charClass);
|
||||
}
|
||||
};
|
||||
|
||||
renderDescription = (): React.ReactNode => {
|
||||
const { feature, showDescription } = this.props;
|
||||
|
||||
const description: string | null = AccessUtils.isAccessible(
|
||||
ClassFeatureUtils.getAccessType(feature)
|
||||
)
|
||||
? ClassFeatureUtils.getDescription(feature)
|
||||
: "Check out the Marketplace to unlock this Class Feature.";
|
||||
|
||||
return showDescription
|
||||
? description
|
||||
: ClassFeatureUtils.getSnippet(feature);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
feature,
|
||||
charClass,
|
||||
onFeatureClick,
|
||||
showDescription,
|
||||
featuresManager,
|
||||
onFeatClick,
|
||||
...restProps
|
||||
} = this.props;
|
||||
|
||||
const feats = [
|
||||
// Both primary and parent. Feats from the Granted Feat system have the class feature as a parent.
|
||||
...featuresManager.getDataOriginOnlyFeatsByPrimary(
|
||||
DataOriginTypeEnum.CLASS_FEATURE,
|
||||
`${ClassFeatureUtils.getId(feature)}`
|
||||
),
|
||||
...featuresManager.getDataOriginOnlyFeatsByParent(
|
||||
DataOriginTypeEnum.CLASS_FEATURE,
|
||||
`${ClassFeatureUtils.getId(feature)}`
|
||||
),
|
||||
];
|
||||
|
||||
// When the class feature grants feats, show those rather than the class feature.
|
||||
return feats.length ? (
|
||||
feats.map((feat) => (
|
||||
<FeatFeatureSnippet
|
||||
{...restProps}
|
||||
key={feat.getId()}
|
||||
feat={feat}
|
||||
onFeatureClick={onFeatClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<FeatureSnippet
|
||||
{...restProps}
|
||||
heading={ClassFeatureUtils.getName(feature)}
|
||||
className="ct-feature-snippet--class"
|
||||
levelScale={ClassFeatureUtils.getLevelScale(feature)}
|
||||
actions={ClassFeatureUtils.getActions(feature)}
|
||||
options={ClassFeatureUtils.getOptions(feature)}
|
||||
choices={ClassFeatureUtils.getChoices(feature)}
|
||||
infusionChoices={ClassFeatureUtils.getInfusionChoices(feature)}
|
||||
classLevel={ClassUtils.getLevel(charClass)}
|
||||
subclass={ClassUtils.getSubclass(charClass)}
|
||||
spells={ClassFeatureUtils.getSpells(feature)}
|
||||
sourceId={ClassFeatureUtils.getSourceId(feature)}
|
||||
sourcePage={ClassFeatureUtils.getSourcePage(feature)}
|
||||
onFeatureClick={this.handleFeatureClick}
|
||||
showDescription={showDescription}
|
||||
>
|
||||
{this.renderDescription()}
|
||||
</FeatureSnippet>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import ClassFeatureSnippet from "./ClassFeatureSnippet";
|
||||
|
||||
export default ClassFeatureSnippet;
|
||||
export { ClassFeatureSnippet };
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
CharacterTheme,
|
||||
CharacterUtils,
|
||||
DataOriginRefData,
|
||||
FeatManager,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
SourceMappingContract,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { FeatureSnippet } from "~/subApps/sheet/components/FeatureSnippet";
|
||||
|
||||
import { CharacterFeaturesManagerContext } from "../../../../Shared/managers/CharacterFeaturesManagerContext";
|
||||
|
||||
interface Props {
|
||||
feat: FeatManager;
|
||||
extraMeta?: Array<string>;
|
||||
onActionClick?: (action: Action) => void;
|
||||
onActionUseSet?: (action: Action, uses: number) => void;
|
||||
onSpellClick?: (spell: Spell) => void;
|
||||
onSpellUseSet?: (spell: Spell, uses: number) => void;
|
||||
onFeatureClick?: (feat: FeatManager) => void;
|
||||
|
||||
showHeader?: boolean;
|
||||
showDescription?: boolean;
|
||||
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export const FeatFeatureSnippet: React.FC<Props> = ({
|
||||
feat,
|
||||
onFeatureClick,
|
||||
snippetData,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
isReadonly,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
onActionUseSet,
|
||||
showHeader,
|
||||
extraMeta = [],
|
||||
showDescription = false,
|
||||
}) => {
|
||||
const { characterFeaturesManager } = useContext(
|
||||
CharacterFeaturesManagerContext
|
||||
);
|
||||
const currentFeats = characterFeaturesManager.getFeats();
|
||||
const { entityUtils } = useCharacterEngine();
|
||||
|
||||
let sourceId: number | null = null;
|
||||
let sourcePage: number | null = null;
|
||||
let filteredSources = feat
|
||||
.getSources()
|
||||
.filter(CharacterUtils.isPrimarySource);
|
||||
let dataOrigin = feat.getDataOrigin();
|
||||
const dataOriginExtra = entityUtils.getDataOriginName(
|
||||
dataOrigin,
|
||||
"Unknown",
|
||||
true
|
||||
);
|
||||
|
||||
if (filteredSources.length) {
|
||||
let primarySource: SourceMappingContract = filteredSources[0];
|
||||
sourceId = primarySource.sourceId;
|
||||
sourcePage = primarySource.pageNumber;
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureSnippet
|
||||
snippetData={snippetData}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
isReadonly={isReadonly}
|
||||
extraMeta={extraMeta}
|
||||
onActionClick={onActionClick}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
showHeader={showHeader}
|
||||
feats={currentFeats.map((feat) => feat.feat)}
|
||||
dataOriginExtra={dataOriginExtra}
|
||||
heading={feat.getName()}
|
||||
className="ct-feature-snippet--feat"
|
||||
actions={feat.getActions()}
|
||||
options={feat.getOptions()}
|
||||
choices={feat.getChoices()}
|
||||
spells={feat.getSpells()}
|
||||
sourceId={sourceId}
|
||||
sourcePage={sourcePage}
|
||||
onFeatureClick={() => {
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick(feat);
|
||||
}
|
||||
}}
|
||||
showDescription={showDescription}
|
||||
>
|
||||
{showDescription ? feat.getDescription() : feat.getSnippet()}
|
||||
</FeatureSnippet>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatFeatureSnippet;
|
||||
@@ -0,0 +1,4 @@
|
||||
import FeatFeatureSnippet from "./FeatFeatureSnippet";
|
||||
|
||||
export default FeatFeatureSnippet;
|
||||
export { FeatFeatureSnippet };
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
ActionUtils,
|
||||
ActivationUtils,
|
||||
Constants,
|
||||
DataOriginBaseAction,
|
||||
LimitedUseUtils,
|
||||
RuleData,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { FeatureSnippetLimitedUse } from "../FeatureSnippetLimitedUse";
|
||||
|
||||
interface Props {
|
||||
action: DataOriginBaseAction;
|
||||
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetAction extends React.PureComponent<Props> {
|
||||
handleActionUseSet = (action: DataOriginBaseAction, uses: number): void => {
|
||||
const { onActionUseSet } = this.props;
|
||||
|
||||
if (onActionUseSet) {
|
||||
onActionUseSet(action, uses);
|
||||
}
|
||||
};
|
||||
|
||||
handleActionClick = (evt: React.MouseEvent): void => {
|
||||
const { action, onActionClick } = this.props;
|
||||
|
||||
if (onActionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onActionClick(action);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
action,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
let name = ActionUtils.getName(action);
|
||||
let activation = ActionUtils.getActivation(action);
|
||||
let limitedUseLabel: string | null = null;
|
||||
if (limitedUse) {
|
||||
let maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
limitedUseLabel = maxUses > 1 ? "Uses:" : "";
|
||||
}
|
||||
let activationDisplay: string = "(No Action)";
|
||||
if (activation) {
|
||||
switch (activation.activationType) {
|
||||
case Constants.ActivationTypeEnum.ACTION:
|
||||
case Constants.ActivationTypeEnum.REACTION:
|
||||
case Constants.ActivationTypeEnum.BONUS_ACTION:
|
||||
activationDisplay = ActivationUtils.renderActivation(
|
||||
activation,
|
||||
ruleData
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-feature-snippet__action"
|
||||
onClick={this.handleActionClick}
|
||||
>
|
||||
<div
|
||||
className={`ct-feature-snippet__action-summary ${
|
||||
theme?.isDarkMode
|
||||
? "ct-feature-snippet__action-summary--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{name}: {activationDisplay}
|
||||
</div>
|
||||
{limitedUse && (
|
||||
<div className="ct-feature-snippet__action-limited">
|
||||
{limitedUseLabel && (
|
||||
<div
|
||||
className={`ct-feature-snippet__action-limited-label ${
|
||||
theme?.isDarkMode
|
||||
? "ct-feature-snippet__action-limited-label--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{limitedUseLabel}
|
||||
</div>
|
||||
)}
|
||||
<FeatureSnippetLimitedUse
|
||||
component={action}
|
||||
limitedUse={limitedUse}
|
||||
onUseSet={this.handleActionUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetAction from "./FeatureSnippetAction";
|
||||
|
||||
export default FeatureSnippetAction;
|
||||
export { FeatureSnippetAction };
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
ActionUtils,
|
||||
DataOriginBaseAction,
|
||||
RuleData,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import FeatureSnippetAction from "../FeatureSnippetAction";
|
||||
|
||||
interface Props {
|
||||
actions: Array<DataOriginBaseAction>;
|
||||
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetActions extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
actions,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!actions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-feature-snippet__actions">
|
||||
{actions.map((action) => (
|
||||
<FeatureSnippetAction
|
||||
key={ActionUtils.getUniqueKey(action)}
|
||||
action={action}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetActions from "./FeatureSnippetActions";
|
||||
|
||||
export default FeatureSnippetActions;
|
||||
export { FeatureSnippetActions };
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseFeat,
|
||||
CharacterTheme,
|
||||
Choice,
|
||||
ClassDefinitionContract,
|
||||
Constants,
|
||||
DataOriginBaseAction,
|
||||
DataOriginRefData,
|
||||
FeatUtils,
|
||||
Option,
|
||||
OptionUtils,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
SourceData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import FeatureSnippetOption from "../FeatureSnippetOption";
|
||||
|
||||
interface Props {
|
||||
choices?: Array<Choice>;
|
||||
options?: Array<Option>;
|
||||
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onSpellUseSet?: (spell: Spell, uses: number) => void;
|
||||
onSpellClick?: (spell: Spell) => void;
|
||||
|
||||
feats?: Array<BaseFeat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
sourceDataLookup: Record<number, SourceData>;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
|
||||
classLevel?: number;
|
||||
subclass?: ClassDefinitionContract | null;
|
||||
|
||||
showDescription: boolean;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetChoices extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
showDescription: false,
|
||||
isInteractive: true,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
options,
|
||||
choices,
|
||||
subclass,
|
||||
feats,
|
||||
snippetData,
|
||||
classLevel,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
sourceDataLookup,
|
||||
dataOriginRefData,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
showDescription,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!choices || !choices.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-feature-snippet__choices">
|
||||
{choices.map((choice, idx) => {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
optionValue,
|
||||
subType,
|
||||
options: choiceOptions,
|
||||
} = choice;
|
||||
|
||||
let displayType: React.ReactNode;
|
||||
let selectedValue: React.ReactNode;
|
||||
switch (type) {
|
||||
case Constants.BuilderChoiceTypeEnum.RACIAL_TRAIT_OPTION:
|
||||
case Constants.BuilderChoiceTypeEnum.FEAT_OPTION:
|
||||
case Constants.BuilderChoiceTypeEnum.FEATURE_OPTION:
|
||||
displayType = "Feature Option";
|
||||
let selectedFeatureOption = options
|
||||
? options.find((opt) => OptionUtils.getId(opt) === optionValue)
|
||||
: undefined;
|
||||
if (selectedFeatureOption) {
|
||||
selectedValue = (
|
||||
<FeatureSnippetOption
|
||||
option={selectedFeatureOption}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
onActionClick={onActionClick}
|
||||
onActionUseSet={onActionUseSet}
|
||||
snippetData={snippetData}
|
||||
classLevel={classLevel}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
sourceDataLookup={sourceDataLookup}
|
||||
showDescription={showDescription}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
selectedValue = "Could not find selected feature option";
|
||||
}
|
||||
break;
|
||||
|
||||
case Constants.BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION:
|
||||
displayType = "Feat";
|
||||
let selectedFeat = feats
|
||||
? feats.find((feat) => FeatUtils.getId(feat) === optionValue)
|
||||
: undefined;
|
||||
selectedValue = selectedFeat
|
||||
? FeatUtils.getName(selectedFeat)
|
||||
: "Could not find selected feat";
|
||||
break;
|
||||
|
||||
case Constants.BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
|
||||
//The actual rendering spells scenario is being handled in FeatureSnippetSpells only IF the choice is not null
|
||||
//This is a fix to show "No Choice Made" when FeatureSnippetSpells is not rendering anything
|
||||
displayType = "Spell";
|
||||
if (optionValue === null) selectedValue = "No Choice Made";
|
||||
else return null;
|
||||
|
||||
break;
|
||||
|
||||
case Constants.BuilderChoiceTypeEnum.SUB_CLASS_OPTION:
|
||||
displayType = "Subclass";
|
||||
selectedValue =
|
||||
subclass && subclass.name
|
||||
? subclass.name
|
||||
: "Could not find selected subclass";
|
||||
break;
|
||||
|
||||
case Constants.BuilderChoiceTypeEnum.ONE_MODIFIER_TYPE_CHOICE:
|
||||
displayType = "One Modifier Type Choice";
|
||||
let selectedOneModifier = choiceOptions
|
||||
? choiceOptions.find((opt) => opt.id === optionValue)
|
||||
: undefined;
|
||||
selectedValue = selectedOneModifier
|
||||
? selectedOneModifier.label
|
||||
: "Could not find selected one modifier type choice";
|
||||
break;
|
||||
|
||||
case Constants.BuilderChoiceTypeEnum.MODIFIER_SUB_CHOICE:
|
||||
let selectedOption = choiceOptions
|
||||
? choiceOptions.find((opt) => opt.id === optionValue)
|
||||
: undefined;
|
||||
selectedValue = selectedOption
|
||||
? selectedOption.label
|
||||
: "could not find selected choice";
|
||||
switch (subType) {
|
||||
case Constants.BuilderChoiceSubtypeEnum.PROFICIENCY:
|
||||
displayType = "Proficiency";
|
||||
break;
|
||||
case Constants.BuilderChoiceSubtypeEnum.LANGUAGE:
|
||||
displayType = "Language";
|
||||
break;
|
||||
case Constants.BuilderChoiceSubtypeEnum.KENSEI:
|
||||
displayType = "Kensei Option";
|
||||
break;
|
||||
case Constants.BuilderChoiceSubtypeEnum.EXPERTISE:
|
||||
displayType = "Expertise";
|
||||
break;
|
||||
case Constants.BuilderChoiceSubtypeEnum
|
||||
.EXPERTISE_NO_REQUIREMENT:
|
||||
displayType = "Expertise";
|
||||
break;
|
||||
case Constants.BuilderChoiceSubtypeEnum.ABILITY_SCORE: {
|
||||
displayType = "Ability Score";
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown BUILDER_CHOICE_TYPE: ${type}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-feature-snippet__choice ${
|
||||
theme?.isDarkMode ? "ct-feature-snippet__choice--dark-mode" : ""
|
||||
}`}
|
||||
key={`${id}-${idx}`}
|
||||
>
|
||||
{optionValue === null
|
||||
? "No Choice Made"
|
||||
: selectedValue
|
||||
? selectedValue
|
||||
: "could not find selected value"}
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetChoices from "./FeatureSnippetChoices";
|
||||
|
||||
export default FeatureSnippetChoices;
|
||||
export { FeatureSnippetChoices };
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
InfusionChoice,
|
||||
InfusionChoiceUtils,
|
||||
InfusionUtils,
|
||||
KnownInfusionUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
type OnInfusionChoiceClick = (infusionChoice: InfusionChoice) => void;
|
||||
interface Props {
|
||||
infusionChoices: Array<InfusionChoice>;
|
||||
|
||||
onInfusionChoiceClick?: OnInfusionChoiceClick;
|
||||
}
|
||||
|
||||
export default class FeatureSnippetInfusionChoices extends React.PureComponent<Props> {
|
||||
handleInfusionChoiceClick = (
|
||||
infusionChoice: InfusionChoice,
|
||||
evt: React.MouseEvent
|
||||
) => {
|
||||
const { onInfusionChoiceClick } = this.props;
|
||||
|
||||
if (onInfusionChoiceClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onInfusionChoiceClick(infusionChoice);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { infusionChoices } = this.props;
|
||||
|
||||
let availableInfusionChoices = infusionChoices.filter(
|
||||
InfusionChoiceUtils.validateIsAvailable
|
||||
);
|
||||
|
||||
if (!availableInfusionChoices.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["ct-feature-snippet__infusion-choices"];
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
{availableInfusionChoices.map((infusionChoice, idx) => {
|
||||
const knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
|
||||
let nameNode: React.ReactNode = "No Infusion Choice Made";
|
||||
let onClick: OnInfusionChoiceClick | undefined;
|
||||
if (knownInfusion) {
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion !== null) {
|
||||
nameNode = (
|
||||
<React.Fragment>
|
||||
{InfusionUtils.getName(simulatedInfusion)}
|
||||
</React.Fragment>
|
||||
);
|
||||
onClick = this.handleInfusionChoiceClick.bind(
|
||||
this,
|
||||
infusionChoice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
|
||||
|
||||
//TODO this onclick handler needs to be converted to a new sub component to
|
||||
// get away from the need for binding as it messes with typing it
|
||||
return (
|
||||
<div
|
||||
className="ct-feature-snippet__infusion-choice"
|
||||
key={choiceKey === null ? "" : choiceKey}
|
||||
>
|
||||
<div
|
||||
className="ct-feature-snippet__infusion-choice-summary"
|
||||
onClick={onClick as any}
|
||||
>
|
||||
{nameNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetInfusionChoices from "./FeatureSnippetInfusionChoices";
|
||||
|
||||
export default FeatureSnippetInfusionChoices;
|
||||
export { FeatureSnippetInfusionChoices };
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
DataOriginBaseAction,
|
||||
DataOriginSpell,
|
||||
Constants,
|
||||
EntityLimitedUseContract,
|
||||
LimitedUseUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SlotManager from "../../../../Shared/components/SlotManager";
|
||||
import SlotManagerLarge from "../../../../Shared/components/SlotManagerLarge";
|
||||
|
||||
interface Props {
|
||||
component: DataOriginBaseAction | DataOriginSpell;
|
||||
limitedUse: EntityLimitedUseContract | null;
|
||||
onUseSet?: (
|
||||
component: DataOriginBaseAction | DataOriginSpell,
|
||||
uses: number
|
||||
) => void;
|
||||
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
isInteractive: boolean;
|
||||
largeLimitedUseAmount: number;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetLimitedUse extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
largeLimitedUseAmount: 8,
|
||||
};
|
||||
|
||||
handleUseSet = (uses: number): void => {
|
||||
const { onUseSet, component } = this.props;
|
||||
|
||||
if (onUseSet) {
|
||||
onUseSet(component, uses);
|
||||
}
|
||||
};
|
||||
|
||||
renderSmallAmountSlotPool = (): React.ReactNode => {
|
||||
const {
|
||||
limitedUse,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
} = this.props;
|
||||
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
if (!maxUses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SlotManager
|
||||
used={numberUsed}
|
||||
available={maxUses}
|
||||
size={"small"}
|
||||
onSet={this.handleUseSet}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderLargeAmountSlotPool = (): React.ReactNode => {
|
||||
const {
|
||||
limitedUse,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
} = this.props;
|
||||
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
if (!maxUses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SlotManagerLarge
|
||||
label="Current:"
|
||||
available={maxUses}
|
||||
used={numberUsed}
|
||||
onSet={this.handleUseSet}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
limitedUse,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
largeLimitedUseAmount,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resetType = LimitedUseUtils.getResetType(limitedUse);
|
||||
let maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
let totalSlots = Math.max(maxUses, numberUsed);
|
||||
|
||||
let usagesNode: React.ReactNode;
|
||||
let extraNode: React.ReactNode;
|
||||
if (totalSlots >= largeLimitedUseAmount) {
|
||||
usagesNode = maxUses;
|
||||
extraNode = this.renderLargeAmountSlotPool();
|
||||
} else if (maxUses === -1) {
|
||||
usagesNode = "Unlimited";
|
||||
} else {
|
||||
usagesNode = this.renderSmallAmountSlotPool();
|
||||
}
|
||||
|
||||
let resetNode: React.ReactNode;
|
||||
if (
|
||||
resetType !== Constants.LimitedUseResetTypeEnum.SHORT_REST &&
|
||||
resetType !== Constants.LimitedUseResetTypeEnum.LONG_REST
|
||||
) {
|
||||
resetNode = "Special";
|
||||
} else {
|
||||
resetNode = RuleDataUtils.getLimitedUseResetTypeName(resetType, ruleData);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className={`ct-feature-snippet__limited-use ${
|
||||
theme.isDarkMode ? "ct-feature-snippet__limited-use--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="ct-feature-snippet__limited-use-usages">
|
||||
{usagesNode}
|
||||
</div>
|
||||
<div className="ct-feature-snippet__limited-use-sep">/</div>
|
||||
<div className="ct-feature-snippet__limited-use-reset">
|
||||
{resetNode}
|
||||
</div>
|
||||
</div>
|
||||
{extraNode && (
|
||||
<div className="ct-feature-snippet__limited-use-extra">
|
||||
{extraNode}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetLimitedUse from "./FeatureSnippetLimitedUse";
|
||||
|
||||
export default FeatureSnippetLimitedUse;
|
||||
export { FeatureSnippetLimitedUse };
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import { Snippet } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
CharacterTheme,
|
||||
DataOriginBaseAction,
|
||||
DataOriginRefData,
|
||||
Option,
|
||||
OptionUtils,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
SourceData,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { FeatureSnippetActions } from "../FeatureSnippetActions";
|
||||
import { FeatureSnippetSpells } from "../FeatureSnippetSpells";
|
||||
|
||||
interface Props {
|
||||
option: Option;
|
||||
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onSpellClick?: (spell: Spell) => void;
|
||||
onSpellUseSet?: (spell: Spell, uses: number) => void;
|
||||
onOptionClick?: () => void;
|
||||
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
sourceDataLookup: Record<number, SourceData>;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
|
||||
snippetData: SnippetData;
|
||||
classLevel?: number;
|
||||
|
||||
showDescription: boolean;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetOption extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
showDescription: false,
|
||||
};
|
||||
|
||||
handleOptionClick = (evt: React.MouseEvent): void => {
|
||||
const { onOptionClick } = this.props;
|
||||
|
||||
if (onOptionClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onOptionClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
option,
|
||||
snippetData,
|
||||
classLevel,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
sourceDataLookup,
|
||||
dataOriginRefData,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
showDescription,
|
||||
isInteractive,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
let sourceId = OptionUtils.getSourceId(option);
|
||||
let sourcePage = OptionUtils.getSourcePage(option);
|
||||
let name = OptionUtils.getName(option);
|
||||
let content: string | null = showDescription
|
||||
? OptionUtils.getDescription(option)
|
||||
: OptionUtils.getSnippet(option);
|
||||
|
||||
const levelScale = OptionUtils.getLevelScale(option);
|
||||
const actions = OptionUtils.getActions(option);
|
||||
const spells = OptionUtils.getSpells(option);
|
||||
|
||||
let metaItems: Array<React.ReactNode> = [];
|
||||
let sourceNode: React.ReactNode;
|
||||
if (sourceId && sourcePage) {
|
||||
let source = sourceDataLookup[sourceId];
|
||||
// TODO should use GameRulesSourceAbbr
|
||||
sourceNode = (
|
||||
<span className="ct-feature-snippet__source">
|
||||
<Tooltip
|
||||
isDarkMode={theme.isDarkMode}
|
||||
title={source.description ? source.description : ""}
|
||||
>
|
||||
<span className="ct-feature-snippet__heading-source-abbr">
|
||||
{source.name}
|
||||
</span>
|
||||
{sourcePage && (
|
||||
<span className="ct-feature-snippet__heading-source-page">
|
||||
{sourcePage}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
metaItems.push(sourceNode);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-feature-snippet__option"
|
||||
onClick={this.handleOptionClick}
|
||||
>
|
||||
<div className="ct-feature-snippet__heading">
|
||||
{name}
|
||||
<span className="ct-feature-snippet__meta">
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span className="ct-feature-snippet__meta-item" key={idx}>
|
||||
{metaItem}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
{content && (
|
||||
<div className="ct-feature-snippet__option-content">
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
classLevel={classLevel}
|
||||
parseSnippet={!showDescription}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
levelScale={levelScale}
|
||||
>
|
||||
{content}
|
||||
</Snippet>
|
||||
</div>
|
||||
)}
|
||||
{(actions.length > 0 || spells.length > 0) && (
|
||||
<div className="ct-feature-snippet__extra">
|
||||
<FeatureSnippetActions
|
||||
actions={actions}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
<FeatureSnippetSpells
|
||||
spells={spells}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
onSpellClick={onSpellClick}
|
||||
isInteractive={isInteractive}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetOption from "./FeatureSnippetOption";
|
||||
|
||||
export default FeatureSnippetOption;
|
||||
export { FeatureSnippetOption };
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseSpell,
|
||||
CharacterTheme,
|
||||
DataOriginRefData,
|
||||
RuleData,
|
||||
SpellUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
|
||||
import { FeatureSnippetLimitedUse } from "../FeatureSnippetLimitedUse";
|
||||
|
||||
interface Props {
|
||||
spells?: Array<BaseSpell>;
|
||||
layoutType: "list" | "compact";
|
||||
|
||||
onSpellUseSet?: (spell: BaseSpell, uses: number) => void;
|
||||
onSpellClick?: (spell: BaseSpell) => void;
|
||||
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
isInteractive: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class FeatureSnippetSpells extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
layoutType: "list",
|
||||
};
|
||||
|
||||
handleSpellUseSet = (spell: BaseSpell, uses: number): void => {
|
||||
const { onSpellUseSet } = this.props;
|
||||
|
||||
if (onSpellUseSet) {
|
||||
onSpellUseSet(spell, uses);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpellClick = (spell: BaseSpell, evt: React.MouseEvent): void => {
|
||||
const { onSpellClick } = this.props;
|
||||
|
||||
if (onSpellClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onSpellClick(spell);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
spells,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
layoutType,
|
||||
isInteractive,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
if (!spells || !spells.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let orderedSpells = orderBy(
|
||||
spells,
|
||||
[
|
||||
(spell) => SpellUtils.getLevel(spell),
|
||||
(spell) => SpellUtils.getName(spell),
|
||||
],
|
||||
["asc", "asc"]
|
||||
);
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-feature-snippet__spells",
|
||||
`ct-feature-snippet__spells--layout-${layoutType}`,
|
||||
];
|
||||
if (theme?.isDarkMode) {
|
||||
classNames.push(`ct-feature-snippet__spells--dark-mode`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
{orderedSpells.map((spell, idx) => (
|
||||
<div
|
||||
className="ct-feature-snippet__spell"
|
||||
key={SpellUtils.getUniqueKey(spell)}
|
||||
>
|
||||
<div
|
||||
className="ct-feature-snippet__spell-summary"
|
||||
onClick={this.handleSpellClick.bind(this, spell)}
|
||||
>
|
||||
<SpellName
|
||||
spell={spell}
|
||||
showLegacy={true}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
</div>
|
||||
{layoutType !== "compact" && !SpellUtils.isCantrip(spell) && (
|
||||
<FeatureSnippetLimitedUse
|
||||
component={spell}
|
||||
theme={theme}
|
||||
limitedUse={SpellUtils.getLimitedUse(spell)}
|
||||
onUseSet={this.handleSpellUseSet}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
isInteractive={isInteractive}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
)}
|
||||
{layoutType === "compact" && idx + 1 < orderedSpells.length && (
|
||||
<span className="ct-feature-snippet__spell-sep">,</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import FeatureSnippetSpells from "./FeatureSnippetSpells";
|
||||
|
||||
export default FeatureSnippetSpells;
|
||||
export { FeatureSnippetSpells };
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { RacialTraitAccessors } from "@dndbeyond/character-rules-engine/es/engine/RacialTrait";
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseFeat,
|
||||
DataOriginBaseAction,
|
||||
DataOriginRefData,
|
||||
DataOriginSpell,
|
||||
RacialTrait,
|
||||
RacialTraitUtils,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
AccessUtils,
|
||||
CharacterTheme,
|
||||
FeatManager,
|
||||
CharacterFeaturesManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
|
||||
import { DataOriginTypeEnum } from "~/constants";
|
||||
import { FeatureSnippet } from "~/subApps/sheet/components/FeatureSnippet";
|
||||
|
||||
import FeatFeatureSnippet from "../FeatFeatureSnippet";
|
||||
|
||||
interface Props {
|
||||
speciesTrait: RacialTrait;
|
||||
extraMeta: Array<string>;
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onSpellClick?: (spell: DataOriginSpell) => void;
|
||||
onSpellUseSet?: (spell: DataOriginSpell, uses: number) => void;
|
||||
onFeatureClick?: (feature: RacialTrait) => void;
|
||||
showHeader?: boolean;
|
||||
showDescription?: boolean;
|
||||
feats: Array<BaseFeat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
onFeatClick: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager;
|
||||
}
|
||||
export default class SpeciesTraitFeatureSnippet extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
extraMeta: [],
|
||||
showDescription: false,
|
||||
};
|
||||
|
||||
handleFeatureClick = (): void => {
|
||||
const { speciesTrait, onFeatureClick } = this.props;
|
||||
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick(speciesTrait);
|
||||
}
|
||||
};
|
||||
|
||||
renderDescription = (): React.ReactNode => {
|
||||
const { speciesTrait, showDescription } = this.props;
|
||||
|
||||
const description: string | null = AccessUtils.isAccessible(
|
||||
RacialTraitUtils.getAccessType(speciesTrait)
|
||||
)
|
||||
? RacialTraitUtils.getDescription(speciesTrait)
|
||||
: "Check out the Marketplace to unlock this Species Trait.";
|
||||
|
||||
return showDescription
|
||||
? description
|
||||
: RacialTraitUtils.getSnippet(speciesTrait);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
speciesTrait,
|
||||
onFeatureClick,
|
||||
showDescription,
|
||||
featuresManager,
|
||||
onFeatClick,
|
||||
...restProps
|
||||
} = this.props;
|
||||
|
||||
const feats = featuresManager.getDataOriginOnlyFeatsByPrimary(
|
||||
DataOriginTypeEnum.RACE,
|
||||
`${RacialTraitAccessors.getId(speciesTrait)}`
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeatureSnippet
|
||||
{...restProps}
|
||||
heading={RacialTraitUtils.getName(speciesTrait)}
|
||||
className="ct-feature-snippet--racial-trait"
|
||||
actions={RacialTraitUtils.getActions(speciesTrait)}
|
||||
options={RacialTraitUtils.getOptions(speciesTrait)}
|
||||
choices={RacialTraitUtils.getChoices(speciesTrait)}
|
||||
spells={RacialTraitUtils.getSpells(speciesTrait)}
|
||||
sourceId={RacialTraitUtils.getSourceId(speciesTrait)}
|
||||
sourcePage={RacialTraitUtils.getSourcePage(speciesTrait)}
|
||||
onFeatureClick={this.handleFeatureClick}
|
||||
showDescription={showDescription}
|
||||
>
|
||||
{this.renderDescription()}
|
||||
</FeatureSnippet>
|
||||
{feats.map((feat) => (
|
||||
<FeatFeatureSnippet
|
||||
{...restProps}
|
||||
key={feat.getId()}
|
||||
feat={feat}
|
||||
onFeatureClick={onFeatClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import SpeciesTraitFeatureSnippet from "./SpeciesTraitFeatureSnippet";
|
||||
|
||||
export default SpeciesTraitFeatureSnippet;
|
||||
export { SpeciesTraitFeatureSnippet };
|
||||
@@ -0,0 +1,302 @@
|
||||
import { keyBy } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ItemPreview,
|
||||
Snippet,
|
||||
CreaturePreview,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AccessUtils,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
Creature,
|
||||
CreatureLookup,
|
||||
CreatureUtils,
|
||||
HelperUtils,
|
||||
Infusion,
|
||||
InfusionChoice,
|
||||
InfusionChoiceUtils,
|
||||
InfusionModifierDataContract,
|
||||
InfusionUtils,
|
||||
InventoryLookup,
|
||||
Item,
|
||||
ItemUtils,
|
||||
KnownInfusionUtils,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Hack__BaseCharClass,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
inventory: Array<Item>;
|
||||
creatures: Array<Creature>;
|
||||
infusionChoices: Array<InfusionChoice>;
|
||||
proficiencyBonus: number;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
isMobile: boolean;
|
||||
theme: CharacterTheme;
|
||||
|
||||
onInfusionChoiceShow?: (infusionChoice: InfusionChoice) => void;
|
||||
onItemShow?: (item: Item) => void;
|
||||
onCreatureShow?: (creature: Creature) => void;
|
||||
}
|
||||
interface State {
|
||||
inventoryLookup: InventoryLookup;
|
||||
creatureLookup: CreatureLookup;
|
||||
}
|
||||
export class Infusions extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
isReadonly: false,
|
||||
isMobile: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inventoryLookup: keyBy(props.inventory, (item) =>
|
||||
ItemUtils.getMappingId(item)
|
||||
),
|
||||
creatureLookup: keyBy(props.creatures, (creature) =>
|
||||
CreatureUtils.getMappingId(creature)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState, snapshot) {
|
||||
if (
|
||||
prevProps.inventory !== this.props.inventory ||
|
||||
prevProps.creatures !== this.props.creatures
|
||||
) {
|
||||
this.setState({
|
||||
inventoryLookup: keyBy(this.props.inventory, (item) =>
|
||||
ItemUtils.getMappingId(item)
|
||||
),
|
||||
creatureLookup: keyBy(this.props.creatures, (creature) =>
|
||||
CreatureUtils.getMappingId(creature)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick = (item: Item): void => {
|
||||
const { onItemShow } = this.props;
|
||||
|
||||
if (onItemShow) {
|
||||
onItemShow(item);
|
||||
}
|
||||
};
|
||||
|
||||
handleCreatureClick = (creature: Creature): void => {
|
||||
const { onCreatureShow } = this.props;
|
||||
|
||||
if (onCreatureShow) {
|
||||
onCreatureShow(creature);
|
||||
}
|
||||
};
|
||||
|
||||
handleInfusionClick = (
|
||||
infusionChoice: InfusionChoice,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
const { onInfusionChoiceShow } = this.props;
|
||||
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
if (onInfusionChoiceShow) {
|
||||
onInfusionChoiceShow(infusionChoice);
|
||||
}
|
||||
};
|
||||
|
||||
renderDescription = (
|
||||
simulatedInfusion: Infusion,
|
||||
infusionChoice: InfusionChoice,
|
||||
selectedModifierData: InfusionModifierDataContract | null
|
||||
): React.ReactNode => {
|
||||
const { snippetData, proficiencyBonus } = this.props;
|
||||
|
||||
if (
|
||||
AccessUtils.isAccessible(InfusionUtils.getAccessType(simulatedInfusion))
|
||||
) {
|
||||
let classLevel: number | null = selectedModifierData?.value ?? null;
|
||||
const dataOriginType =
|
||||
InfusionChoiceUtils.getDataOriginType(infusionChoice);
|
||||
|
||||
if (
|
||||
dataOriginType === Constants.DataOriginTypeEnum.CLASS_FEATURE &&
|
||||
!classLevel
|
||||
) {
|
||||
const dataOrigin = InfusionChoiceUtils.getDataOrigin(infusionChoice);
|
||||
classLevel = ClassUtils.getLevel(
|
||||
dataOrigin.parent as Hack__BaseCharClass
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
classLevel={classLevel}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
>
|
||||
{InfusionUtils.getSnippet(simulatedInfusion)}
|
||||
</Snippet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-infusions__infusion-empty">
|
||||
Check out the Marketplace to unlock this infusion.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderInfusions = (): React.ReactNode => {
|
||||
const { inventoryLookup, creatureLookup } = this.state;
|
||||
const { infusionChoices, ruleData, theme } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{infusionChoices.map((infusionChoice) => {
|
||||
const knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
|
||||
if (knownInfusion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
|
||||
if (simulatedInfusion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const infusion = InfusionChoiceUtils.getInfusion(infusionChoice);
|
||||
const infusionType = InfusionUtils.getType(simulatedInfusion);
|
||||
|
||||
let item: Item | null = null;
|
||||
let creature: Creature | null = null;
|
||||
let selectedModifierData: InfusionModifierDataContract | null = null;
|
||||
if (infusion) {
|
||||
let inventoryMappingId =
|
||||
InfusionUtils.getInventoryMappingId(infusion);
|
||||
item = HelperUtils.lookupDataOrFallback(
|
||||
inventoryLookup,
|
||||
inventoryMappingId
|
||||
);
|
||||
let creatureMappingId =
|
||||
InfusionUtils.getCreatureMappingId(infusion);
|
||||
if (creatureMappingId !== null) {
|
||||
creature = HelperUtils.lookupDataOrFallback(
|
||||
creatureLookup,
|
||||
creatureMappingId
|
||||
);
|
||||
}
|
||||
selectedModifierData =
|
||||
InfusionUtils.getSelectedModifierData(infusion);
|
||||
}
|
||||
|
||||
let itemNode: React.ReactNode;
|
||||
if (item) {
|
||||
itemNode = (
|
||||
<div className="ct-infusions__infusion-item">
|
||||
<ItemPreview
|
||||
theme={theme}
|
||||
item={item}
|
||||
onClick={this.handleItemClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
itemNode = (
|
||||
<div className="ct-infusions__infusion-empty">
|
||||
No item infused
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let creatureNode: React.ReactNode;
|
||||
if (creature) {
|
||||
creatureNode = (
|
||||
<div className="ct-infusions__infusion-item">
|
||||
<CreaturePreview
|
||||
creature={creature}
|
||||
ruleData={ruleData}
|
||||
onClick={this.handleCreatureClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let selectedModifierDataNode: React.ReactNode;
|
||||
if (selectedModifierData && selectedModifierData.name) {
|
||||
selectedModifierDataNode = (
|
||||
<div className="ct-infusions__infusion-item">
|
||||
{selectedModifierData.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let infusionName = InfusionUtils.getName(simulatedInfusion);
|
||||
let infusionNameExtraNode: React.ReactNode;
|
||||
if (infusionType === Constants.InfusionTypeEnum.REPLICATE) {
|
||||
let itemName = KnownInfusionUtils.getItemName(knownInfusion);
|
||||
if (itemName) {
|
||||
infusionNameExtraNode = `: ${itemName}`;
|
||||
}
|
||||
}
|
||||
|
||||
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
|
||||
return (
|
||||
<div
|
||||
className="ct-infusions__infusion"
|
||||
key={choiceKey === null ? "" : choiceKey}
|
||||
onClick={this.handleInfusionClick.bind(this, infusionChoice)}
|
||||
>
|
||||
<div className="ct-infusions__infusion-header">
|
||||
{infusionName}
|
||||
{infusionNameExtraNode}
|
||||
</div>
|
||||
<div className="ct-infusions__infusion-content">
|
||||
<div className="ct-infusions__infusion-description">
|
||||
{this.renderDescription(
|
||||
simulatedInfusion,
|
||||
infusionChoice,
|
||||
selectedModifierData
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-infusions__infusion-items">
|
||||
{itemNode}
|
||||
{creatureNode}
|
||||
{selectedModifierDataNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { infusionChoices } = this.props;
|
||||
|
||||
let contentNode: React.ReactNode = (
|
||||
<div className="ct-infusions__empty">
|
||||
No infusions choices have been made for this character
|
||||
</div>
|
||||
);
|
||||
if (infusionChoices.length > 0) {
|
||||
contentNode = this.renderInfusions();
|
||||
}
|
||||
|
||||
return <div className="ct-infusions">{contentNode}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default Infusions;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Infusions from "./Infusions";
|
||||
|
||||
export default Infusions;
|
||||
export { Infusions };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { BuilderLinkButton } from "../../../Shared/components/common/LinkButton";
|
||||
|
||||
interface InvalidCharacterProps {
|
||||
builderUrl: string;
|
||||
isReadonly: boolean;
|
||||
}
|
||||
|
||||
export const InvalidCharacter: FC<InvalidCharacterProps> = ({
|
||||
builderUrl,
|
||||
isReadonly,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ct-invalid-character">
|
||||
<h2>Character Not Ready!</h2>
|
||||
|
||||
<p>
|
||||
The current character is not accessible because it is missing either a{" "}
|
||||
<strong>Species</strong>, <strong>Class</strong> or{" "}
|
||||
<strong>Ability Scores</strong>.
|
||||
</p>
|
||||
|
||||
{!isReadonly && (
|
||||
<>
|
||||
<p>
|
||||
Return to the character builder to complete the character creation
|
||||
process.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<BuilderLinkButton
|
||||
url={builderUrl}
|
||||
className="ct-invalid-character__button"
|
||||
size="oversized"
|
||||
>
|
||||
Character Builder
|
||||
</BuilderLinkButton>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,654 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
DarkFilterSvg,
|
||||
ThemedFilterSvg,
|
||||
LightFilterSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
HtmlSelectOption,
|
||||
Item,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { FilterUtils } from "../../../Shared/utils";
|
||||
import InventoryFilterArrayFilter from "./InventoryFilterArrayFilter";
|
||||
import InventoryFilterExclusiveCheckboxFilter from "./InventoryFilterExclusiveCheckboxFilter";
|
||||
|
||||
interface Props {
|
||||
inventory: Array<Item>;
|
||||
partyInventory: Array<Item>;
|
||||
callout?: React.ReactNode;
|
||||
ruleData: RuleData;
|
||||
onDataUpdate: (filterData: any) => void; //TODO type filterData
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
interface DefaultInventoryFilterState {
|
||||
filterQuery: string;
|
||||
filterTypes: Array<number>;
|
||||
filterRarities: Array<string>;
|
||||
filterTags: Array<string>;
|
||||
filterRanged: boolean | null;
|
||||
}
|
||||
interface State extends DefaultInventoryFilterState {
|
||||
filteredInventory: Array<Item>;
|
||||
filteredPartyInventory: Array<Item>;
|
||||
showAdvancedFilters: boolean;
|
||||
}
|
||||
export default class InventoryFilter extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
filteredInventory: this.getInventory(props),
|
||||
filteredPartyInventory: this.getPartyInventory(props),
|
||||
filterQuery: "",
|
||||
filterTypes: [],
|
||||
filterRarities: [],
|
||||
filterTags: [],
|
||||
filterRanged: null,
|
||||
showAdvancedFilters: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.handleDataUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { inventory, partyInventory } = this.props;
|
||||
|
||||
if (
|
||||
inventory !== prevProps.inventory ||
|
||||
partyInventory !== prevProps.partyInventory
|
||||
) {
|
||||
let inventory = this.filterItems(
|
||||
this.props,
|
||||
this.getFilters(prevState),
|
||||
this.getInventory(this.props)
|
||||
);
|
||||
let partyInventory = this.filterItems(
|
||||
this.props,
|
||||
this.getFilters(prevState),
|
||||
this.getPartyInventory(this.props)
|
||||
);
|
||||
this.setState(
|
||||
{
|
||||
filteredInventory: inventory,
|
||||
filteredPartyInventory: partyInventory,
|
||||
},
|
||||
this.handleDataUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getFilters = (state: State): DefaultInventoryFilterState => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterRarities,
|
||||
filterTags,
|
||||
filterRanged,
|
||||
} = state;
|
||||
return {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterRarities,
|
||||
filterTags,
|
||||
filterRanged,
|
||||
};
|
||||
};
|
||||
|
||||
filterItems = (
|
||||
props: Props,
|
||||
filters: DefaultInventoryFilterState,
|
||||
inventory: Array<Item>
|
||||
): Array<Item> => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterRarities,
|
||||
filterTags,
|
||||
filterRanged,
|
||||
} = filters;
|
||||
|
||||
return inventory.filter((item) => {
|
||||
if (
|
||||
filterQuery &&
|
||||
!FilterUtils.doesQueryMatchData(filterQuery, ItemUtils.getName(item))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterTags.length) {
|
||||
let matches: boolean = false;
|
||||
let tags = ItemUtils.getTags(item);
|
||||
tags.forEach((tag) => {
|
||||
if (!matches && filterTags.includes(tag)) {
|
||||
matches = true;
|
||||
}
|
||||
});
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterTypes.length) {
|
||||
if (!filterTypes.includes(ItemUtils.getBaseTypeId(item))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterRarities.length) {
|
||||
let rarity = ItemUtils.getRarity(item);
|
||||
if (rarity === null || !filterRarities.includes(rarity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterRanged === true) {
|
||||
if (
|
||||
ItemUtils.getAttackType(item) !== Constants.AttackTypeRangeEnum.RANGED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (filterRanged === false) {
|
||||
if (
|
||||
ItemUtils.getAttackType(item) !== Constants.AttackTypeRangeEnum.MELEE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
getFilterCount = (): number => {
|
||||
const { filterTypes, filterRarities, filterTags, filterRanged } =
|
||||
this.state;
|
||||
|
||||
let count: number = 0;
|
||||
count += filterTags.length;
|
||||
count += filterTypes.length;
|
||||
count += filterRarities.length;
|
||||
count += filterRanged === null ? 0 : 1;
|
||||
return count;
|
||||
};
|
||||
|
||||
getTagOptions = (tags: Array<string>): Array<HtmlSelectOption> => {
|
||||
return tags.sort().map((tag) => ({ label: tag, value: tag }));
|
||||
};
|
||||
|
||||
getRarityOptions = (rarities: Array<string>): Array<HtmlSelectOption> => {
|
||||
return rarities.sort().map((rarity) => {
|
||||
return {
|
||||
label: rarity,
|
||||
value: rarity,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
getTypeOptions = (types: Array<number>): Array<HtmlSelectOption> => {
|
||||
return orderBy(
|
||||
types.map((type) => {
|
||||
return {
|
||||
label:
|
||||
type === 0 ? "Custom" : ItemUtils.deriveBaseTypeNameFromId(type),
|
||||
value: type,
|
||||
};
|
||||
}),
|
||||
"label"
|
||||
);
|
||||
};
|
||||
|
||||
getInventory = (props: Props): Array<Item> => {
|
||||
return [...props.inventory];
|
||||
};
|
||||
|
||||
getPartyInventory = (props: Props): Array<Item> => {
|
||||
return [...props.partyInventory];
|
||||
};
|
||||
|
||||
isFiltering = (): boolean => {
|
||||
const { filterQuery } = this.state;
|
||||
|
||||
const filterCount = this.getFilterCount();
|
||||
return filterCount > 0 || !!filterQuery;
|
||||
};
|
||||
|
||||
handleDataUpdate = (): void => {
|
||||
const { onDataUpdate } = this.props;
|
||||
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate({
|
||||
...this.state,
|
||||
filterCount: this.getFilterCount(),
|
||||
isFiltering: this.isFiltering(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleFilterClear = (
|
||||
propertyKey: keyof State,
|
||||
resetState: Array<string> | Array<number> | null
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let inventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: resetState,
|
||||
},
|
||||
this.getInventory(this.props)
|
||||
);
|
||||
let partyInventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: resetState,
|
||||
},
|
||||
this.getPartyInventory(this.props)
|
||||
);
|
||||
return {
|
||||
...prevState,
|
||||
[propertyKey]: resetState,
|
||||
filteredInventory: inventory,
|
||||
filteredPartyInventory: partyInventory,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleArrayFilterClear = (
|
||||
propertyKey: keyof State,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
this.handleFilterClear(propertyKey, []);
|
||||
};
|
||||
|
||||
handleBoolFilterClear = (
|
||||
propertyKey: keyof State,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
this.handleFilterClear(propertyKey, null);
|
||||
};
|
||||
|
||||
handleFiltersClear = (): void => {
|
||||
const inventory = this.getInventory(this.props);
|
||||
this.setState(
|
||||
{
|
||||
filteredInventory: inventory,
|
||||
filterQuery: "",
|
||||
filterTypes: [],
|
||||
filterRarities: [],
|
||||
filterTags: [],
|
||||
filterRanged: null,
|
||||
},
|
||||
this.handleDataUpdate
|
||||
);
|
||||
};
|
||||
|
||||
handleQueryUpdate = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let value = evt.target.value;
|
||||
this.setState((prevState: State) => {
|
||||
let inventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
filterQuery: value,
|
||||
},
|
||||
this.getInventory(this.props)
|
||||
);
|
||||
let partyInventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
filterQuery: value,
|
||||
},
|
||||
this.getPartyInventory(this.props)
|
||||
);
|
||||
return {
|
||||
filterQuery: value,
|
||||
filteredInventory: inventory,
|
||||
filteredPartyInventory: partyInventory,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleFilterAdvancedToggle = (): void => {
|
||||
this.setState(
|
||||
(prevState: State) => ({
|
||||
showAdvancedFilters: !prevState.showAdvancedFilters,
|
||||
}),
|
||||
this.handleDataUpdate
|
||||
);
|
||||
};
|
||||
|
||||
//TODO get array key by filtering for array type
|
||||
handleFilterArrayToggle = (
|
||||
arrayKey: string,
|
||||
value: string | number
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let includesItem: boolean = prevState[arrayKey].includes(value);
|
||||
let newFilterArray: Array<string> | Array<number> = [];
|
||||
if (includesItem) {
|
||||
newFilterArray = prevState[arrayKey].filter(
|
||||
(filterTag) => filterTag !== value
|
||||
);
|
||||
} else {
|
||||
newFilterArray = [...prevState[arrayKey], value];
|
||||
}
|
||||
let inventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[arrayKey]: newFilterArray,
|
||||
},
|
||||
this.getInventory(this.props)
|
||||
);
|
||||
let partyInventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[arrayKey]: newFilterArray,
|
||||
},
|
||||
this.getPartyInventory(this.props)
|
||||
);
|
||||
return {
|
||||
...prevState,
|
||||
[arrayKey]: newFilterArray,
|
||||
filteredInventory: inventory,
|
||||
filteredPartyInventory: partyInventory,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
handleExclusiveCheckboxFilterChange = (
|
||||
propertyKey: keyof State,
|
||||
value: boolean | null
|
||||
): void => {
|
||||
this.setState((prevState: State) => {
|
||||
let inventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: value,
|
||||
},
|
||||
this.getInventory(this.props)
|
||||
);
|
||||
let partyInventory = this.filterItems(
|
||||
this.props,
|
||||
{
|
||||
...this.getFilters(prevState),
|
||||
[propertyKey]: value,
|
||||
},
|
||||
this.getPartyInventory(this.props)
|
||||
);
|
||||
return {
|
||||
...prevState,
|
||||
[propertyKey]: value,
|
||||
filteredInventory: inventory,
|
||||
filteredPartyInventory: partyInventory,
|
||||
};
|
||||
}, this.handleDataUpdate);
|
||||
};
|
||||
|
||||
renderAdvancedFilters = (): React.ReactNode => {
|
||||
const { filterTags, filterRarities, filterTypes, filterRanged } =
|
||||
this.state;
|
||||
|
||||
const inventory = this.getInventory(this.props);
|
||||
|
||||
let uniqueTags = new Set<string>();
|
||||
let uniqueRarities = new Set<string>();
|
||||
let uniqueTypes = new Set<number>();
|
||||
for (let i = 0; i < inventory.length; i++) {
|
||||
let item = inventory[i];
|
||||
|
||||
let tags = ItemUtils.getTags(item);
|
||||
for (let j = 0; j < tags.length; j++) {
|
||||
uniqueTags.add(tags[j]);
|
||||
}
|
||||
|
||||
let rarity = ItemUtils.getRarity(item);
|
||||
if (rarity !== null) {
|
||||
uniqueRarities.add(rarity);
|
||||
}
|
||||
|
||||
uniqueTypes.add(ItemUtils.getBaseTypeId(item));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-inventory-filter__adv-filters">
|
||||
<InventoryFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Item Type"
|
||||
propertyKey="filterTypes"
|
||||
currentValues={filterTypes}
|
||||
availableOptions={this.getTypeOptions(Array.from(uniqueTypes))}
|
||||
/>
|
||||
<InventoryFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Tags"
|
||||
propertyKey="filterTags"
|
||||
currentValues={filterTags}
|
||||
availableOptions={this.getTagOptions(Array.from(uniqueTags))}
|
||||
/>
|
||||
<InventoryFilterArrayFilter
|
||||
onUpdate={this.handleFilterArrayToggle}
|
||||
label="Rarity"
|
||||
propertyKey="filterRarities"
|
||||
currentValues={filterRarities}
|
||||
availableOptions={this.getRarityOptions(Array.from(uniqueRarities))}
|
||||
/>
|
||||
<InventoryFilterExclusiveCheckboxFilter
|
||||
label="Attack Type"
|
||||
onUpdate={this.handleExclusiveCheckboxFilterChange}
|
||||
propertyKey="filterRanged"
|
||||
value={filterRanged}
|
||||
trueLabel="Ranged"
|
||||
falseLabel="Melee"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActiveArray = (
|
||||
label: string,
|
||||
options: Array<HtmlSelectOption>,
|
||||
propertyKey: keyof State
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<div
|
||||
className="ct-inventory-filter__active"
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<div className="ct-inventory-filter__active-label">{label}:</div>
|
||||
<div className="ct-inventory-filter__active-value">
|
||||
{options.map((option) => option.label).join(" or ")}
|
||||
</div>
|
||||
<div className="ct-inventory-filter__active-remove">
|
||||
<div
|
||||
className="ct-inventory-filter__active-remove-icon"
|
||||
onClick={this.handleArrayFilterClear.bind(this, propertyKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActiveExclusiveCheckbox = (
|
||||
label: string,
|
||||
value: boolean,
|
||||
propertyKey: keyof State,
|
||||
trueLabel: string = "Yes",
|
||||
falseLabel: string = "No"
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
className="ct-inventory-filter__active"
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<div className="ct-inventory-filter__active-label">{label}:</div>
|
||||
<div className="ct-inventory-filter__active-value">
|
||||
{value ? trueLabel : falseLabel}
|
||||
</div>
|
||||
<div className="ct-inventory-filter__active-remove">
|
||||
<div
|
||||
className="ct-inventory-filter__active-remove-icon"
|
||||
onClick={this.handleBoolFilterClear.bind(this, propertyKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderFilterBarActives = (): React.ReactNode => {
|
||||
const { filterTags, filterRarities, filterTypes, filterRanged } =
|
||||
this.state;
|
||||
|
||||
let activeNodes: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
|
||||
if (filterTypes.length) {
|
||||
activeNodes.push({
|
||||
key: "filterTypes",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterTypes.length === 1 ? "Type" : "Types",
|
||||
this.getTypeOptions(filterTypes),
|
||||
"filterTypes"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterTags.length) {
|
||||
activeNodes.push({
|
||||
key: "filterTags",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
filterTags.length === 1 ? "Tag" : "Tags",
|
||||
this.getTagOptions(filterTags),
|
||||
"filterTags"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterRarities.length) {
|
||||
activeNodes.push({
|
||||
key: "filterRarities",
|
||||
node: this.renderFilterBarActiveArray(
|
||||
"Rarity",
|
||||
this.getRarityOptions(filterRarities),
|
||||
"filterRarities"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (filterRanged !== null) {
|
||||
activeNodes.push({
|
||||
key: "filterRanged",
|
||||
node: this.renderFilterBarActiveExclusiveCheckbox(
|
||||
"Attack Type",
|
||||
filterRanged,
|
||||
"filterRanged",
|
||||
"Ranged",
|
||||
"Melee"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!activeNodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-inventory-filter__actives">
|
||||
{activeNodes.map((activeNode) => (
|
||||
<React.Fragment key={activeNode.key}>
|
||||
{activeNode.node}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { filterQuery, showAdvancedFilters } = this.state;
|
||||
const { callout, theme } = this.props;
|
||||
|
||||
const filterCount = this.getFilterCount();
|
||||
const hasFilter = this.isFiltering();
|
||||
|
||||
let advancedClassNames: Array<string> = ["ct-inventory-filter__advanced"];
|
||||
let FilterIcon: React.ComponentType<any> = theme.isDarkMode
|
||||
? LightFilterSvg
|
||||
: DarkFilterSvg;
|
||||
if (filterCount > 0) {
|
||||
advancedClassNames.push("ct-inventory-filter__advanced--active");
|
||||
FilterIcon = ThemedFilterSvg;
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["ct-inventory-filter"];
|
||||
if (showAdvancedFilters) {
|
||||
classNames.push("ct-inventory-filter--show-advanced");
|
||||
}
|
||||
if (hasFilter) {
|
||||
classNames.push("ct-inventory-filter--has-filter");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
<div className="ct-inventory-filter__interactions">
|
||||
<div className="ct-inventory-filter__box">
|
||||
{this.renderFilterBarActives()}
|
||||
<div className="ct-inventory-filter__primary">
|
||||
<div className="ct-inventory-filter__primary-group ct-inventory-filter__primary-group--first">
|
||||
<div className="ct-inventory-filter__icon" />
|
||||
</div>
|
||||
<div className="ct-inventory-filter__field">
|
||||
<input
|
||||
className="ct-inventory-filter__input"
|
||||
type="search"
|
||||
placeholder="Search Item Names, Types, Rarities, or Tags"
|
||||
value={filterQuery}
|
||||
onChange={this.handleQueryUpdate}
|
||||
/>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<div
|
||||
className="ct-inventory-filter__clear"
|
||||
onClick={this.handleFiltersClear}
|
||||
>
|
||||
Clear X
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={advancedClassNames.join(" ")}
|
||||
onClick={this.handleFilterAdvancedToggle}
|
||||
>
|
||||
<FilterIcon theme={theme} />
|
||||
{filterCount > 0 && (
|
||||
<span className="ct-inventory-filter__advanced-callout">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{callout && (
|
||||
<div className="ct-inventory-filter__callout">{callout}</div>
|
||||
)}
|
||||
</div>
|
||||
{showAdvancedFilters && this.renderAdvancedFilters()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class InventoryFilterAdvancedFilter extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-inventory-filter__adv-filter">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterAdvancedFilter from "./InventoryFilterAdvancedFilter";
|
||||
|
||||
export default InventoryFilterAdvancedFilter;
|
||||
export { InventoryFilterAdvancedFilter };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class InventoryFilterAdvancedFilterLabel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-inventory-filter__adv-filter-label">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterAdvancedFilterLabel from "./InventoryFilterAdvancedFilterLabel";
|
||||
|
||||
export default InventoryFilterAdvancedFilterLabel;
|
||||
export { InventoryFilterAdvancedFilterLabel };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class InventoryFilterAdvancedFilterOption extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-inventory-filter__adv-filter-option">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterAdvancedFilterOption from "./InventoryFilterAdvancedFilterOption";
|
||||
|
||||
export default InventoryFilterAdvancedFilterOption;
|
||||
export { InventoryFilterAdvancedFilterOption };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class InventoryFilterAdvancedFilterOptions extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-inventory-filter__adv-filter-options">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterAdvancedFilterOptions from "./InventoryFilterAdvancedFilterOptions";
|
||||
|
||||
export default InventoryFilterAdvancedFilterOptions;
|
||||
export { InventoryFilterAdvancedFilterOptions };
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
|
||||
import { HtmlSelectOption } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import InventoryFilterAdvancedFilter from "../InventoryFilterAdvancedFilter";
|
||||
import InventoryFilterAdvancedFilterLabel from "../InventoryFilterAdvancedFilterLabel";
|
||||
import InventoryFilterAdvancedFilterOption from "../InventoryFilterAdvancedFilterOption";
|
||||
import InventoryFilterAdvancedFilterOptions from "../InventoryFilterAdvancedFilterOptions";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
label: string;
|
||||
currentValues: Array<string | number>;
|
||||
availableOptions: Array<HtmlSelectOption>;
|
||||
onUpdate: (propertyKey: string, value: string | number) => void;
|
||||
}
|
||||
export default class InventoryFilterArrayFilter extends React.PureComponent<Props> {
|
||||
handleFilterArrayToggle = (
|
||||
value: string | number,
|
||||
evt: React.MouseEvent
|
||||
): void => {
|
||||
const { onUpdate, propertyKey } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, value);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { availableOptions, currentValues, label } = this.props;
|
||||
|
||||
return (
|
||||
<InventoryFilterAdvancedFilter>
|
||||
<InventoryFilterAdvancedFilterLabel>
|
||||
{label}
|
||||
</InventoryFilterAdvancedFilterLabel>
|
||||
<InventoryFilterAdvancedFilterOptions>
|
||||
{availableOptions.map((option) => {
|
||||
let classNames: Array<String> = [
|
||||
"ct-inventory-filter__adv-filter-button",
|
||||
];
|
||||
if (currentValues.includes(option.value)) {
|
||||
classNames.push("ct-inventory-filter__adv-filter-button--active");
|
||||
}
|
||||
return (
|
||||
<InventoryFilterAdvancedFilterOption key={option.value}>
|
||||
<span
|
||||
className={classNames.join(" ")}
|
||||
onClick={this.handleFilterArrayToggle.bind(
|
||||
this,
|
||||
option.value
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</InventoryFilterAdvancedFilterOption>
|
||||
);
|
||||
})}
|
||||
</InventoryFilterAdvancedFilterOptions>
|
||||
</InventoryFilterAdvancedFilter>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterArrayFilter from "./InventoryFilterArrayFilter";
|
||||
|
||||
export default InventoryFilterArrayFilter;
|
||||
export { InventoryFilterArrayFilter };
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox } from "@dndbeyond/character-components/es";
|
||||
|
||||
import InventoryFilterAdvancedFilter from "../InventoryFilterAdvancedFilter";
|
||||
import InventoryFilterAdvancedFilterLabel from "../InventoryFilterAdvancedFilterLabel";
|
||||
import InventoryFilterAdvancedFilterOption from "../InventoryFilterAdvancedFilterOption";
|
||||
import InventoryFilterAdvancedFilterOptions from "../InventoryFilterAdvancedFilterOptions";
|
||||
|
||||
interface Props {
|
||||
propertyKey: string;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onUpdate: (propertyKey: string, value: boolean | null) => void;
|
||||
trueLabel: string;
|
||||
falseLabel: string;
|
||||
nullLabel: string;
|
||||
}
|
||||
interface State {
|
||||
value: boolean | null;
|
||||
}
|
||||
export default class InventoryFilterExclusiveCheckboxFilter extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
trueLabel: "Yes",
|
||||
falseLabel: "No",
|
||||
nullLabel: "Both",
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.value,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { value } = this.props;
|
||||
|
||||
if (value !== prevState.value) {
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdate = (value: boolean | null): void => {
|
||||
const { onUpdate, propertyKey } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, value);
|
||||
}
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { label, trueLabel, falseLabel, nullLabel } = this.props;
|
||||
|
||||
return (
|
||||
<InventoryFilterAdvancedFilter>
|
||||
<InventoryFilterAdvancedFilterLabel>
|
||||
{label}
|
||||
</InventoryFilterAdvancedFilterLabel>
|
||||
<InventoryFilterAdvancedFilterOptions>
|
||||
<InventoryFilterAdvancedFilterOption>
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox">
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, true)}
|
||||
enabled={value === true}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-inventory-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, true)}
|
||||
>
|
||||
{trueLabel}
|
||||
</div>
|
||||
</div>
|
||||
</InventoryFilterAdvancedFilterOption>
|
||||
<InventoryFilterAdvancedFilterOption>
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox">
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, false)}
|
||||
enabled={value === false}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-inventory-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, false)}
|
||||
>
|
||||
{falseLabel}
|
||||
</div>
|
||||
</div>
|
||||
</InventoryFilterAdvancedFilterOption>
|
||||
<InventoryFilterAdvancedFilterOption>
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox">
|
||||
<div className="ct-inventory-filter__adv-filter-checkbox-input">
|
||||
<Checkbox
|
||||
onChange={this.handleUpdate.bind(this, null)}
|
||||
enabled={value === null}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="ct-inventory-filter__adv-filter-checkbox-label"
|
||||
onClick={this.handleUpdate.bind(this, null)}
|
||||
>
|
||||
{nullLabel}
|
||||
</div>
|
||||
</div>
|
||||
</InventoryFilterAdvancedFilterOption>
|
||||
</InventoryFilterAdvancedFilterOptions>
|
||||
</InventoryFilterAdvancedFilter>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import InventoryFilterExclusiveCheckboxFilter from "./InventoryFilterExclusiveCheckboxFilter";
|
||||
|
||||
export default InventoryFilterExclusiveCheckboxFilter;
|
||||
export { InventoryFilterExclusiveCheckboxFilter };
|
||||
@@ -0,0 +1,18 @@
|
||||
import InventoryFilter from "./InventoryFilter";
|
||||
import InventoryFilterAdvancedFilter from "./InventoryFilterAdvancedFilter";
|
||||
import InventoryFilterAdvancedFilterLabel from "./InventoryFilterAdvancedFilterLabel";
|
||||
import InventoryFilterAdvancedFilterOption from "./InventoryFilterAdvancedFilterOption";
|
||||
import InventoryFilterAdvancedFilterOptions from "./InventoryFilterAdvancedFilterOptions";
|
||||
import InventoryFilterArrayFilter from "./InventoryFilterArrayFilter";
|
||||
import InventoryFilterExclusiveCheckboxFilter from "./InventoryFilterExclusiveCheckboxFilter";
|
||||
|
||||
export default InventoryFilter;
|
||||
export {
|
||||
InventoryFilter,
|
||||
InventoryFilterAdvancedFilter,
|
||||
InventoryFilterAdvancedFilterLabel,
|
||||
InventoryFilterAdvancedFilterOption,
|
||||
InventoryFilterAdvancedFilterOptions,
|
||||
InventoryFilterArrayFilter,
|
||||
InventoryFilterExclusiveCheckboxFilter,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import { NoteComponents } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
ItemManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import { ItemSlotManager } from "../../../Shared/components/ItemSlotManager";
|
||||
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
|
||||
|
||||
interface Props {
|
||||
item: ItemManager;
|
||||
onEquip?: () => void;
|
||||
onUnequip?: () => void;
|
||||
onItemShow?: (mappingId: number) => void;
|
||||
className?: string;
|
||||
showNotes?: boolean;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export const InventoryItem: React.FC<Props> = ({
|
||||
item,
|
||||
onEquip,
|
||||
onUnequip,
|
||||
onItemShow,
|
||||
className = "",
|
||||
showNotes = true,
|
||||
isReadonly,
|
||||
theme,
|
||||
}) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
|
||||
const quantity = item.getQuantity();
|
||||
const isStackable = item.isStackable();
|
||||
const canEquip = inventoryManager.canEquipUnequipItem(item.item);
|
||||
const isEquippedToCurrentCharacter = item.isEquippedToCurrentCharacter();
|
||||
const isEquipped = item.isEquipped();
|
||||
const weight = item.getWeight();
|
||||
const cost = item.getCost();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-inventory-item ${className}`}
|
||||
onClick={(evt) => {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (onItemShow) {
|
||||
onItemShow(item.getMappingId());
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
data-testid={`inventory-item-${item.getName()}`
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, "-")}
|
||||
>
|
||||
<div className="ct-inventory-item__action">
|
||||
<ItemSlotManager
|
||||
isUsed={!!item.isEquipped()}
|
||||
isReadonly={isReadonly}
|
||||
canUse={canEquip}
|
||||
onSet={(used) => {
|
||||
//TODO use different component than SlotManager for equip/unequip
|
||||
if (used === 0 && onUnequip) {
|
||||
onUnequip();
|
||||
}
|
||||
|
||||
if (used === 1 && onEquip) {
|
||||
onEquip();
|
||||
}
|
||||
}}
|
||||
theme={theme}
|
||||
useTooltip={!!isEquipped && !isEquippedToCurrentCharacter}
|
||||
tooltipTitle={`Equipped by ${item.getEquippedCharacterName()}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-inventory-item__name">
|
||||
<div className="ct-inventory-item__heading">
|
||||
<ItemName item={item.item} />
|
||||
</div>
|
||||
<div
|
||||
className={`ct-inventory-item__meta ${
|
||||
theme?.isDarkMode ? "ct-inventory-item__meta--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{item.getMetaText().map((metaItem, idx) => (
|
||||
<span className="ct-inventory-item__meta-item" key={idx}>
|
||||
{metaItem}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-inventory-item__weight">
|
||||
{weight ? (
|
||||
<Tooltip title={`${weight} lb.`} isDarkMode={theme?.isDarkMode}>
|
||||
<NumberDisplay type="weightInLb" number={weight} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`ct-inventory-item__quantity ${
|
||||
theme?.isDarkMode ? "ct-inventory-item__quantity--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{isStackable ? (
|
||||
<Tooltip title={quantity.toString()} isDarkMode={theme?.isDarkMode}>
|
||||
{quantity}
|
||||
</Tooltip>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`ct-inventory-item__cost ${
|
||||
theme?.isDarkMode ? "ct-inventory-item__cost--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{cost ? (
|
||||
<Tooltip title={cost.toString()} isDarkMode={theme?.isDarkMode}>
|
||||
{cost}
|
||||
</Tooltip>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</div>
|
||||
{showNotes && (
|
||||
<div className="ct-inventory-item__notes">
|
||||
<NoteComponents notes={item.getNotes()} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryItem;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InventoryItem from "./InventoryItem";
|
||||
|
||||
export default InventoryItem;
|
||||
export { InventoryItem };
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
showNotes?: boolean;
|
||||
}
|
||||
export const InventoryTableHeader: React.FC<Props> = ({ showNotes = true }) => {
|
||||
return (
|
||||
<div className="ct-inventory__row-header">
|
||||
<div className="ct-inventory__col ct-inventory__col--active">Active</div>
|
||||
<div className="ct-inventory__col ct-inventory__col--name">Name</div>
|
||||
<div className="ct-inventory__col ct-inventory__col--weight">Weight</div>
|
||||
<div className="ct-inventory__col ct-inventory__col--qty">Qty</div>
|
||||
<div className="ct-inventory__col ct-inventory__col--cost">Cost (gp)</div>
|
||||
{showNotes && (
|
||||
<div className="ct-inventory__col ct-inventory__col--notes">Notes</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryTableHeader;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InventoryTableHeader from "./InventoryTableHeader";
|
||||
|
||||
export default InventoryTableHeader;
|
||||
export { InventoryTableHeader };
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Collapsible,
|
||||
TypeScriptUtils,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
HtmlSelectOption,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import CurrencyPaneSelectEditor from "../../../Shared/containers/panes/CurrencyPane/CurrencyPaneSelectEditor";
|
||||
|
||||
const Lifestyle = ({
|
||||
lifestyle,
|
||||
ruleData,
|
||||
isReadonly,
|
||||
handleLifestyleUpdate,
|
||||
}) => {
|
||||
const lifestyleData = RuleDataUtils.getLifestyles(ruleData);
|
||||
const lifestyleOptions: Array<HtmlSelectOption> = lifestyleData
|
||||
.map((lifestyle) => {
|
||||
if (lifestyle.id === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: `${lifestyle.name} ${
|
||||
lifestyle.cost === "-" ? "" : `(${lifestyle.cost})`
|
||||
}`,
|
||||
value: lifestyle.id,
|
||||
};
|
||||
})
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
let lifestyleDescriptionNode: React.ReactNode;
|
||||
if (lifestyle !== null) {
|
||||
lifestyleDescriptionNode = lifestyle.description;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-currency-pane__lifestyle">
|
||||
<CurrencyPaneSelectEditor
|
||||
label="Lifestyle Expenses"
|
||||
options={lifestyleOptions}
|
||||
defaultValue={lifestyle === null ? null : lifestyle.id}
|
||||
propertyKey="lifestyleId"
|
||||
onUpdate={handleLifestyleUpdate}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
{lifestyleDescriptionNode && (
|
||||
<Collapsible
|
||||
className={"ct-currency-pane__lifestyle-detail"}
|
||||
header={
|
||||
<span className="ct-currency-pane__lifestyle-detail-header">
|
||||
{lifestyle?.name ?? "Lifestyle"} Details
|
||||
</span>
|
||||
}
|
||||
layoutType="minimal"
|
||||
>
|
||||
<div className="ct-currency-pane__lifestyle-description">
|
||||
{lifestyleDescriptionNode}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lifestyle;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Lifestyle from "./Lifestyle";
|
||||
|
||||
export default Lifestyle;
|
||||
export { Lifestyle };
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
BeveledEdgeCornerSvg,
|
||||
BeveledEdgeRepeatSvg,
|
||||
ManageIcon,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
isEnd: boolean;
|
||||
onClick?: () => void;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
isTablet: boolean;
|
||||
}
|
||||
export default class MobileDivider extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
isEnd: false,
|
||||
isReadonly: false,
|
||||
isTablet: false,
|
||||
};
|
||||
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, isEnd, onClick, isReadonly, theme, isTablet } = this.props;
|
||||
|
||||
let classNames: Array<string> = ["ct-mobile-divider"];
|
||||
if (label) {
|
||||
classNames.push("ct-mobile-divider--has-label");
|
||||
}
|
||||
if (isEnd) {
|
||||
classNames.push("ct-mobile-divider--end");
|
||||
}
|
||||
|
||||
let hasOnClick: boolean = !!onClick;
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} onClick={this.handleClick}>
|
||||
<div className="ct-mobile-divider__divider">
|
||||
<div className="ct-mobile-divider__edge ct-mobile-divider__edge--first">
|
||||
<BeveledEdgeCornerSvg theme={theme} isTablet={isTablet} />
|
||||
</div>
|
||||
<div className="ct-mobile-divider__repeat">
|
||||
<BeveledEdgeRepeatSvg theme={theme} isTablet={isTablet} />
|
||||
</div>
|
||||
<div className="ct-mobile-divider__edge ct-mobile-divider__edge--last">
|
||||
<BeveledEdgeCornerSvg theme={theme} isTablet={isTablet} />
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
<div
|
||||
className={`ct-mobile-divider__label ${
|
||||
theme.isDarkMode ? "ct-mobile-divider__label--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="ct-mobile-divider__label-text">
|
||||
{label}
|
||||
{hasOnClick && !isReadonly && (
|
||||
<ManageIcon
|
||||
theme={theme}
|
||||
enableTooltip={false}
|
||||
showIconOnEdge={false}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import MobileDivider from "./MobileDivider";
|
||||
|
||||
export default MobileDivider;
|
||||
export { MobileDivider };
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CharacterNotes,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
notes: CharacterNotes;
|
||||
onClick?: () => void;
|
||||
}
|
||||
export default class OtherPossessions extends React.PureComponent<Props> {
|
||||
handlePossessionsManage = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { notes } = this.props;
|
||||
|
||||
let notesData = notes[Constants.NoteKeyEnum.PERSONAL_POSSESSIONS];
|
||||
let hasContent: boolean = !!notesData;
|
||||
let content: React.ReactNode = hasContent
|
||||
? notesData
|
||||
: "+ Add other possessions, treasure, or holdings for your character in this section.";
|
||||
|
||||
let classNames: Array<string> = ["ct-other-possessions"];
|
||||
if (!hasContent) {
|
||||
classNames.push("ct-other-possessions--no-content");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(" ")}
|
||||
onClick={this.handlePossessionsManage}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import OtherPossessions from "./OtherPossessions";
|
||||
|
||||
export default OtherPossessions;
|
||||
export { OtherPossessions };
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
BeveledBoxSvg94x89,
|
||||
BoxBackground,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
interface Props {
|
||||
proficiencyBonus: number;
|
||||
onClick?: () => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ProficiencyBonusBox extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { proficiencyBonus, theme } = this.props;
|
||||
|
||||
return (
|
||||
<section className="ct-proficiency-bonus-box" onClick={this.handleClick}>
|
||||
<BoxBackground StyleComponent={BeveledBoxSvg94x89} theme={theme} />
|
||||
<h2 style={visuallyHidden}>Proficiency Bonus</h2>
|
||||
<div
|
||||
className={`ct-proficiency-bonus-box__heading ${
|
||||
theme.isDarkMode
|
||||
? "ct-proficiency-bonus-box__heading--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
Proficiency
|
||||
</div>
|
||||
<div
|
||||
className={`ct-proficiency-bonus-box__value ${
|
||||
theme.isDarkMode ? "ct-proficiency-bonus-box__value--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<NumberDisplay
|
||||
type="signed"
|
||||
number={proficiencyBonus}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`ct-proficiency-bonus-box__label ${
|
||||
theme.isDarkMode ? "ct-proficiency-bonus-box__label--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
Bonus
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ProficiencyBonusBox from "./ProficiencyBonusBox";
|
||||
|
||||
export default ProficiencyBonusBox;
|
||||
export { ProficiencyBonusBox };
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
ProficiencyGroup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
proficiencyGroups: Array<ProficiencyGroup>;
|
||||
onClick?: () => void;
|
||||
theme?: CharacterTheme;
|
||||
}
|
||||
export default class ProficiencyGroups extends React.PureComponent<Props> {
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { proficiencyGroups, theme } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-proficiency-groups" onClick={this.handleClick}>
|
||||
{proficiencyGroups.map((group) => {
|
||||
let itemsNode: React.ReactNode = "None";
|
||||
if (group.modifierGroups.length) {
|
||||
itemsNode = (
|
||||
<React.Fragment>
|
||||
{group.modifierGroups.map((modifierGroup, idx) => {
|
||||
let title: string = modifierGroup.sources.join(", ");
|
||||
return (
|
||||
<Tooltip
|
||||
isDarkMode={theme?.isDarkMode}
|
||||
tippyOpts={{
|
||||
dynamicTitle: true,
|
||||
}}
|
||||
title={title}
|
||||
key={modifierGroup.label}
|
||||
>
|
||||
<span
|
||||
className={`ct-proficiency-groups__group-item ${
|
||||
theme?.isDarkMode
|
||||
? "ct-proficiency-groups__group-item--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{modifierGroup.label}
|
||||
{modifierGroup.restriction
|
||||
? ` (${modifierGroup.restriction})`
|
||||
: ""}
|
||||
{idx + 1 !== group.modifierGroups.length ? ", " : ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-proficiency-groups__group"
|
||||
key={group.label ? group.label : ""}
|
||||
style={
|
||||
theme?.isDarkMode
|
||||
? { borderColor: `${theme.themeColor}66` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`ct-proficiency-groups__group-label ${
|
||||
theme?.isDarkMode
|
||||
? "ct-proficiency-groups__group-label--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
<div
|
||||
className={`ct-proficiency-groups__group-items ${
|
||||
theme?.isDarkMode
|
||||
? "ct-proficiency-groups__group-items--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{itemsNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user