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:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,733 @@
import { visuallyHidden } from "@mui/utils";
import React, { useContext } from "react";
import { connect, DispatchProp } from "react-redux";
import {
AbilityLookup,
Action,
ActionUtils,
Activatable,
Attack,
AttacksPerActionInfo,
BaseInventoryContract,
BasicActionContract,
characterActions,
CharacterTheme,
CharClass,
ClassFeature,
ClassUtils,
Constants,
DataOriginRefData,
Feat,
FeatureUtils,
FeatUtils,
Hack__BaseCharClass,
InventoryLookup,
InventoryManager,
Item,
ItemManager,
ItemUtils,
RacialTrait,
RacialTraitUtils,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
SnippetData,
Spell,
SpellCasterInfo,
SpellUtils,
WeaponSpellDamageGroup,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import { Link } from "~/components/Link";
import { TabFilter } from "~/components/TabFilter";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import {
PaneComponentEnum,
PaneIdentifiers,
} from "~/subApps/sheet/components/Sidebar/types";
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../Shared/selectors";
import { SharedAppState } from "../../../Shared/stores/typings";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import { ActionsList } from "../../components/ActionsList";
type AttackGroups = Record<string, Array<Attack>>;
interface ActionListConfig {
onBasicActionClick: (basicAction: BasicActionContract) => void;
onActionClick: (action: Action) => void;
onActionUseSet: (action: Action, used: number) => void;
onSpellClick: (spell: Spell) => void;
onSpellUseSet: (spell: Spell, used: number) => void;
onAttackClick: (attack: Attack) => void;
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
snippetData: SnippetData;
spellCasterInfo: SpellCasterInfo;
showNotes: boolean;
abilityLookup: AbilityLookup;
ruleData: RuleData;
inventoryLookup: InventoryLookup;
isInteractive: boolean;
diceEnabled: boolean;
theme: CharacterTheme;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
rollContext: IRollContext;
}
interface Props extends DispatchProp {
showNotes: boolean;
activatables: Array<Activatable>;
attacks: Array<Attack>;
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
attacksPerActionInfo: AttacksPerActionInfo;
ritualSpells: Array<Spell>;
abilityLookup: AbilityLookup;
inventoryLookup: InventoryLookup;
ruleData: RuleData;
snippetData: SnippetData;
spellCasterInfo: SpellCasterInfo;
isReadonly: boolean;
diceEnabled: boolean;
theme: CharacterTheme;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
characterRollContext: IRollContext;
inventoryManager: InventoryManager;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
interface State {
attackGroups: AttackGroups;
}
class Actions extends React.PureComponent<Props, State> {
static defaultProps = {
showNotes: true,
};
constructor(props: Props) {
super(props);
this.state = this.generateStateData(props);
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>
): void {
const { attacks } = this.props;
if (attacks !== prevProps.attacks) {
this.setState(this.generateStateData(this.props));
}
}
generateStateData = (props: Props): State => {
const { attacks } = props;
let attackGroups: AttackGroups = {};
attackGroups["ACTIONS"] = attacks.filter(
(attack) => attack.activation === Constants.ActivationTypeEnum.ACTION
);
attackGroups["BONUS_ACTIONS"] = attacks.filter(
(attack) =>
attack.activation === Constants.ActivationTypeEnum.BONUS_ACTION
);
attackGroups["REACTIONS"] = attacks.filter(
(attack) => attack.activation === Constants.ActivationTypeEnum.REACTION
);
attackGroups["OTHER"] = attacks.filter(
(attack) =>
attack.activation !== Constants.ActivationTypeEnum.ACTION &&
attack.activation !== Constants.ActivationTypeEnum.BONUS_ACTION &&
attack.activation !== Constants.ActivationTypeEnum.REACTION
);
return {
attackGroups,
};
};
getActionListConfig = (): ActionListConfig => {
const {
weaponSpellDamageGroups,
ruleData,
abilityLookup,
inventoryLookup,
spellCasterInfo,
snippetData,
showNotes,
isReadonly,
diceEnabled,
theme,
dataOriginRefData,
proficiencyBonus,
characterRollContext,
} = this.props;
return {
onBasicActionClick: this.handleBasicActionShow,
onActionClick: this.handleActionShow,
onActionUseSet: this.handleActionUseSet,
onSpellClick: this.handleSpellDetailShow,
onSpellUseSet: this.handleSpellUseSet,
onAttackClick: this.handleAttackClick,
weaponSpellDamageGroups,
abilityLookup,
inventoryLookup,
ruleData,
snippetData,
spellCasterInfo,
showNotes,
isInteractive: !isReadonly,
diceEnabled,
theme,
dataOriginRefData,
proficiencyBonus,
rollContext: characterRollContext,
};
};
handleActionUseSet = (action: Action, uses: number): void => {
const { dispatch } = this.props;
const dataOrigin = ActionUtils.getDataOrigin(action);
const dataOriginType = ActionUtils.getDataOriginType(action);
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
const itemData = dataOrigin.primary as BaseInventoryContract;
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
item.handleItemLimitedUseSet(uses);
} else {
const id = ActionUtils.getId(action);
const entityTypeId = ActionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(id, entityTypeId, uses, dataOriginType)
);
}
}
};
handleSpellUseSet = (spell: Spell, uses: number): void => {
const { dispatch } = this.props;
const mappingId = SpellUtils.getMappingId(spell);
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
if (mappingId && mappingEntityTypeId) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
};
handleSpellDetailShow = (spell: Spell): void => {
const { paneHistoryStart } = this.props;
let spellMappingId = SpellUtils.getMappingId(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
const dataOrigin = SpellUtils.getDataOrigin(spell);
if (spellMappingId !== null) {
let paneComponent: PaneComponentEnum;
let identifiers: PaneIdentifiers;
if (dataOriginType === Constants.DataOriginTypeEnum.CLASS) {
paneComponent = PaneComponentEnum.CLASS_SPELL_DETAIL;
identifiers = PaneIdentifierUtils.generateClassSpell(
ClassUtils.getMappingId(dataOrigin.primary as Hack__BaseCharClass),
spellMappingId
);
} else {
paneComponent = PaneComponentEnum.CHARACTER_SPELL_DETAIL;
identifiers =
PaneIdentifierUtils.generateCharacterSpell(spellMappingId);
}
paneHistoryStart(paneComponent, identifiers);
}
};
// this is not being used downstream is marked for deletion, was used in ActionsList
handleClassFeatureShow = (
feature: ClassFeature,
charClass: CharClass
): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
FeatureUtils.getId(feature),
ClassUtils.getMappingId(charClass)
)
);
};
// this is not being used downstream is marked for deletion, was used in ActionsList
handleRacialTraitShow = (racialTrait: RacialTrait): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.SPECIES_TRAIT_DETAIL,
PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(racialTrait)
)
);
};
// this is not being used downstream is marked for deletion, was used in ActionsList
handleFeatShow = (feat: Feat): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(FeatUtils.getId(feat))
);
};
handleActionShow = (action: Action): void => {
const { paneHistoryStart } = this.props;
const id = ActionUtils.getMappingId(action);
const entityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (id === null) {
return;
}
let dataOriginType = ActionUtils.getDataOriginType(action);
let paneComponentType: PaneComponentEnum = PaneComponentEnum.ACTION;
let paneComponentIdentifiers: PaneIdentifiers =
PaneIdentifierUtils.generateAction(id, entityTypeId);
if (dataOriginType === Constants.DataOriginTypeEnum.CUSTOM) {
paneComponentType = PaneComponentEnum.CUSTOM_ACTION;
paneComponentIdentifiers = PaneIdentifierUtils.generateCustomAction(id);
}
paneHistoryStart(paneComponentType, paneComponentIdentifiers);
};
handleManageCustomActionsShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.CUSTOM_ACTIONS);
};
handleBasicActionShow = (basicAction: BasicActionContract): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.BASIC_ACTION,
PaneIdentifierUtils.generateBasicAction(basicAction.id)
);
};
handleAttackClick = (attack: Attack): void => {
const { paneHistoryStart } = this.props;
let paneComponentType: PaneComponentEnum | null = null;
let paneComponentIdentifiers: PaneIdentifiers | null = null;
switch (attack.type) {
case Constants.AttackSourceTypeEnum.ACTION: {
const action = attack.data as Action;
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneComponentType = PaneComponentEnum.ACTION;
paneComponentIdentifiers = PaneIdentifierUtils.generateAction(
mappingId,
mappingEntityTypeId
);
}
break;
}
case Constants.AttackSourceTypeEnum.CUSTOM: {
const action = attack.data as Action;
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneComponentType = PaneComponentEnum.CUSTOM_ACTION;
paneComponentIdentifiers = PaneIdentifierUtils.generateAction(
mappingId,
mappingEntityTypeId
);
}
break;
}
case Constants.AttackSourceTypeEnum.ITEM: {
paneComponentType = PaneComponentEnum.ITEM_DETAIL;
paneComponentIdentifiers = PaneIdentifierUtils.generateItem(
ItemUtils.getMappingId(attack.data as Item)
);
break;
}
case Constants.AttackSourceTypeEnum.SPELL: {
const spell: Spell = attack.data as Spell;
const mappingId = SpellUtils.getMappingId(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
const dataOrigin = SpellUtils.getDataOrigin(spell);
if (mappingId !== null) {
if (dataOriginType === Constants.DataOriginTypeEnum.CLASS) {
paneComponentType = PaneComponentEnum.CLASS_SPELL_DETAIL;
paneComponentIdentifiers = PaneIdentifierUtils.generateClassSpell(
ClassUtils.getMappingId(
dataOrigin.primary as Hack__BaseCharClass
),
mappingId
);
} else {
paneComponentType = PaneComponentEnum.CHARACTER_SPELL_DETAIL;
paneComponentIdentifiers =
PaneIdentifierUtils.generateCharacterSpell(mappingId);
}
}
break;
}
default:
// not implemented
}
if (paneComponentType !== null) {
paneHistoryStart(paneComponentType, paneComponentIdentifiers);
}
};
renderManageCustomLink = (): React.ReactNode => {
const { isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<Link
useTheme={true}
className="ct-actions__manage-custom-link"
onClick={this.handleManageCustomActionsShow}
>
Manage Custom
</Link>
);
};
renderActionsHeader = (): React.ReactNode => {
const { attacksPerActionInfo, theme } = this.props;
return (
<React.Fragment>
<div className="ct-actions__attacks-heading">
Actions &bull;{" "}
<span
className={`ct-actions__attacks-per-action ${
theme?.isDarkMode
? "ct-actions__attacks-per-action--dark-mode"
: ""
}`}
>
Attacks per Action: {attacksPerActionInfo.value}
</span>
</div>
{attacksPerActionInfo.restriction && (
<div className="ct-actions__attacks-restriction">
{attacksPerActionInfo.restriction}
</div>
)}
</React.Fragment>
);
};
render() {
const { attackGroups } = this.state;
const { activatables, ritualSpells, ruleData } = this.props;
let actionGroups: Record<string, Array<Activatable>> = {};
actionGroups["ACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType === Constants.ActivationTypeEnum.ACTION &&
a.type !== Constants.ActivatableTypeEnum.CLASS_SPELL &&
a.type !== Constants.ActivatableTypeEnum.CHARACTER_SPELL
);
actionGroups["BONUS_ACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType ===
Constants.ActivationTypeEnum.BONUS_ACTION
);
actionGroups["REACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType === Constants.ActivationTypeEnum.REACTION
);
actionGroups["OTHER"] = activatables.filter(
(a) =>
a.activation.activationType !== Constants.ActivationTypeEnum.ACTION &&
a.activation.activationType !==
Constants.ActivationTypeEnum.BONUS_ACTION &&
a.activation.activationType !== Constants.ActivationTypeEnum.REACTION
);
let otherBasicActions: Array<BasicActionContract> = [
...RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.NO_ACTION,
ruleData
),
...RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.MINUTE,
ruleData
),
...RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.HOUR,
ruleData
),
...RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.SPECIAL,
ruleData
),
];
let limitedUseActionGroups: Record<string, Array<Activatable>> = {};
limitedUseActionGroups["ACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType === Constants.ActivationTypeEnum.ACTION &&
a.limitedUse
);
limitedUseActionGroups["BONUS_ACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType ===
Constants.ActivationTypeEnum.BONUS_ACTION && a.limitedUse
);
limitedUseActionGroups["REACTIONS"] = activatables.filter(
(a) =>
a.activation.activationType === Constants.ActivationTypeEnum.REACTION &&
a.limitedUse
);
limitedUseActionGroups["OTHER"] = activatables.filter(
(a) =>
a.activation.activationType !== Constants.ActivationTypeEnum.ACTION &&
a.activation.activationType !==
Constants.ActivationTypeEnum.BONUS_ACTION &&
a.activation.activationType !== Constants.ActivationTypeEnum.REACTION &&
a.limitedUse
);
let limitedUseRitualSpells = ritualSpells.filter((spell) =>
SpellUtils.getLimitedUse(spell)
);
const actionListData: ActionListConfig = {
...this.getActionListConfig(),
};
const hasLimitedUse =
!!limitedUseActionGroups["ACTIONS"].length ||
!!limitedUseActionGroups["BONUS_ACTIONS"].length ||
!!limitedUseActionGroups["REACTIONS"].length ||
!!limitedUseActionGroups["OTHER"].length ||
!!limitedUseRitualSpells.length;
const hasAttackGroups =
!!attackGroups["ACTIONS"].length ||
!!attackGroups["BONUS_ACTIONS"].length ||
!!attackGroups["REACTIONS"].length ||
!!attackGroups["OTHER"].length;
return (
<section className="ct-actions">
<h2 style={visuallyHidden}>Actions</h2>
<TabFilter
filters={[
...(!hasAttackGroups
? []
: [
{
label: "Attack",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading={this.renderActionsHeader()}
attacks={attackGroups["ACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Bonus Actions"
attacks={attackGroups["BONUS_ACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Reactions"
attacks={attackGroups["REACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Other"
attacks={attackGroups["OTHER"]}
{...actionListData}
/>
</>
),
},
]),
{
label: "Action",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading={this.renderActionsHeader()}
actions={actionGroups["ACTIONS"]}
attacks={attackGroups["ACTIONS"]}
basicActions={RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.ACTION,
ruleData
)}
{...actionListData}
/>
</>
),
},
{
label: "Bonus Action",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading="Bonus Actions"
actions={actionGroups["BONUS_ACTIONS"]}
attacks={attackGroups["BONUS_ACTIONS"]}
basicActions={RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.BONUS_ACTION,
ruleData
)}
{...actionListData}
/>
</>
),
},
{
label: "Reaction",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading="Reactions"
actions={actionGroups["REACTIONS"]}
attacks={attackGroups["REACTIONS"]}
basicActions={RuleDataUtils.getActivationTypeBasicActions(
Constants.ActivationTypeEnum.REACTION,
ruleData
)}
{...actionListData}
/>
</>
),
},
{
label: "Other",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading="Other"
actions={actionGroups["OTHER"]}
attacks={attackGroups["OTHER"]}
basicActions={otherBasicActions}
ritualSpells={ritualSpells}
showActivationInfo
{...actionListData}
/>
</>
),
},
...(!hasLimitedUse
? []
: [
{
label: "Limited Use",
content: (
<>
{this.renderManageCustomLink()}
<ActionsList
heading="Actions"
actions={limitedUseActionGroups["ACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Bonus Actions"
actions={limitedUseActionGroups["BONUS_ACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Reactions"
actions={limitedUseActionGroups["REACTIONS"]}
{...actionListData}
/>
<ActionsList
heading="Other"
actions={limitedUseActionGroups["OTHER"]}
ritualSpells={limitedUseRitualSpells}
showActivationInfo
{...actionListData}
/>
</>
),
},
]),
]}
/>
</section>
);
}
}
function mapStateToProps(state: SharedAppState) {
return {
activatables: rulesEngineSelectors.getActivatables(state),
attacks: rulesEngineSelectors.getAttacks(state),
weaponSpellDamageGroups:
rulesEngineSelectors.getWeaponSpellDamageGroups(state),
attacksPerActionInfo: rulesEngineSelectors.getAttacksPerActionInfo(state),
ritualSpells: rulesEngineSelectors.getRitualSpells(state),
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
inventoryLookup: rulesEngineSelectors.getInventoryLookup(state),
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
ruleData: rulesEngineSelectors.getRuleData(state),
snippetData: rulesEngineSelectors.getSnippetData(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
characterRollContext:
characterRollContextSelectors.getCharacterRollContext(state),
};
}
const ActionsContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<Actions
inventoryManager={inventoryManager}
paneHistoryStart={paneHistoryStart}
{...props}
/>
);
};
export default connect(mapStateToProps)(ActionsContainer);
@@ -0,0 +1,4 @@
import Actions from "./Actions";
export default Actions;
export { Actions };
@@ -0,0 +1,181 @@
import { groupBy, orderBy } from "lodash";
import React, { useContext } from "react";
import { connect } from "react-redux";
import { AttunementSlot } from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
Item,
ItemUtils,
CampaignUtils,
CharacterTheme,
InventoryManager,
serviceDataSelectors,
PartyInfo,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import { SheetAppState } from "../../typings";
import AttunementItem from "./AttunementItem";
interface Props {
items: Array<Item>;
attunedSlots: Array<Item | null>;
isFiltered: boolean;
isMobile: boolean;
theme: CharacterTheme;
inventoryManager: InventoryManager;
partyInfo: PartyInfo | null;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class Attunement extends React.PureComponent<Props> {
static defaultProps = {
isFiltered: false,
isMobile: false,
};
handleItemShow = (item: Item): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
renderItems = (): React.ReactNode => {
const { items, theme, inventoryManager, partyInfo } = this.props;
const attunableItems = items.filter((item) => ItemUtils.canAttune(item));
const sortedItems = groupBy(
orderBy(
attunableItems,
[
(item) => ItemUtils.getRarityLevel(item),
(item) => ItemUtils.getName(item),
(item) => ItemUtils.getMappingId(item),
],
["desc", "asc"]
),
(item) => {
if (inventoryManager.isShared(item)) {
return "party";
} else {
return "character";
}
}
);
return (
<div className="ct-attunement__group ct-attunement__group--items">
<div className="ct-attunement__group-header">
Items Requiring Attunement
</div>
<div className="ct-attunement__group-items">
{sortedItems.character?.length > 0 ? (
sortedItems.character?.map((item) => (
<AttunementItem
key={ItemUtils.getUniqueKey(item)}
item={item}
onItemShow={this.handleItemShow}
theme={theme}
/>
))
) : (
<div className="ct-attunement__group-empty">
Items that you can attune to will display here as you make them
active.
</div>
)}
</div>
{partyInfo &&
CampaignUtils.isSharingStateActive(
CampaignUtils.getSharingState(partyInfo)
) && (
<>
<div className="ct-attunement__group-header">
Party Items Requiring Attunement
</div>
<div className="ct-attunement__group-items">
{sortedItems.party?.length > 0 ? (
sortedItems.party?.map((item) => (
<AttunementItem
key={ItemUtils.getUniqueKey(item)}
item={item}
onItemShow={this.handleItemShow}
theme={theme}
/>
))
) : (
<div className="ct-attunement__group-empty">
Party items that you can attune to will display here as you
make them active.
</div>
)}
</div>
</>
)}
</div>
);
};
renderAttuned = (): React.ReactNode => {
const { attunedSlots, isMobile, theme } = this.props;
return (
<div className="ct-attunement__group ct-attunement__group--attuned">
<div className="ct-attunement__group-header">Attuned Items</div>
<div className="ct-attunement__group-items">
{attunedSlots.map((slot, idx) => (
<AttunementSlot
theme={theme}
key={`slot-${idx}-${slot ? ItemUtils.getUniqueKey(slot) : ""}`}
slot={slot}
onClick={this.handleItemShow}
isMobile={isMobile}
/>
))}
</div>
</div>
);
};
render() {
return (
<div className="ct-attunement">
{this.renderAttuned()}
{this.renderItems()}
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
attunedSlots: rulesEngineSelectors.getAttunedSlots(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
items: rulesEngineSelectors.getEquippedItems(state),
partyInfo: serviceDataSelectors.getPartyInfo(state),
};
}
const AttunementContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<Attunement
inventoryManager={inventoryManager}
paneHistoryStart={paneHistoryStart}
{...props}
/>
);
};
export default connect(mapStateToProps)(AttunementContainer);
@@ -0,0 +1,149 @@
import React, { useContext } from "react";
import {
CharacterTheme,
InventoryManager,
Item,
ItemUtils,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { ItemSlotManager } from "../../../../Shared/components/ItemSlotManager";
import { InventoryManagerContext } from "../../../../Shared/managers/InventoryManagerContext";
interface Props {
item: Item;
onItemShow?: (item: Item) => void;
className: string;
theme: CharacterTheme;
inventoryManager: InventoryManager;
}
export class AttunementItem extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleSetAttune = (uses: number): void => {
const { inventoryManager, item } = this.props;
//TODO use different component than SlotManager for attune/unattune
if (uses === 1) {
inventoryManager.handleAttune({ item });
}
if (uses === 0) {
inventoryManager.handleUnattune({ item });
}
};
handleItemClick = (evt: React.MouseEvent): void => {
const { onItemShow, item } = this.props;
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
if (onItemShow) {
onItemShow(item);
}
};
renderMetaItems = (): React.ReactNode => {
const { item, inventoryManager } = this.props;
let metaItems: Array<string> = [];
if (ItemUtils.isLegacy(item)) {
metaItems.push("Legacy");
}
let type = ItemUtils.getType(item);
if (type !== null) {
metaItems.push(type);
}
if (ItemUtils.isWeaponContract(item)) {
if (ItemUtils.isOffhand(item)) {
metaItems.push("Dual Wield");
}
} else if (ItemUtils.isArmorContract(item)) {
let baseArmorName = ItemUtils.getBaseArmorName(item);
if (baseArmorName) {
metaItems.push(baseArmorName);
}
} else if (ItemUtils.isGearContract(item)) {
let subType = ItemUtils.getSubType(item);
if (subType) {
metaItems.push(subType);
}
if (ItemUtils.isOffhand(item)) {
metaItems.push("Dual Wield");
}
}
if (
ItemUtils.isEquipped(item) &&
!inventoryManager.isEquippedToCurrentCharacter(item)
) {
metaItems.push(
`${
ItemUtils.isAttuned(item) ? "Attuned" : "Equipped"
} by ${inventoryManager.getEquippedCharacterName(item)}`
);
}
return (
<div className="ct-attunement__meta">
{metaItems.map((metaItem, idx) => (
<span className="ct-attunement__meta-item" key={idx}>
{metaItem}
</span>
))}
</div>
);
//`
};
render() {
const { item, className, theme, inventoryManager } = this.props;
const canAttune = inventoryManager.canAttuneUnattuneItem(item);
return (
<div
className={`ct-attunement__item ${className}`}
onClick={this.handleItemClick}
>
<div className="ct-attunement__item-action">
<ItemSlotManager
isUsed={!!ItemUtils.isAttuned(item)}
canUse={canAttune}
theme={theme}
onSet={this.handleSetAttune}
useTooltip={
!!ItemUtils.isEquipped(item) &&
!inventoryManager.isEquippedToCurrentCharacter(item)
}
tooltipTitle={`${
ItemUtils.isAttuned(item) ? "Attuned" : "Equipped"
} by ${inventoryManager.getEquippedCharacterName(item)}`}
/>
</div>
<div className="ct-attunement__item-name">
<div className="ct-attunement__item-heading">
<ItemName item={item} />
</div>
{this.renderMetaItems()}
</div>
</div>
);
//`
}
}
const AttunementItemContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
return <AttunementItem inventoryManager={inventoryManager} {...props} />;
};
export default AttunementItemContainer;
@@ -0,0 +1,4 @@
import AttunementItem from "./AttunementItem";
export default AttunementItem;
export { AttunementItem };
@@ -0,0 +1,5 @@
import Attunement from "./Attunement";
import AttunementItem from "./AttunementItem";
export default Attunement;
export { Attunement, AttunementItem };
@@ -0,0 +1,88 @@
import jss, { StyleSheet } from "jss";
import preset from "jss-preset-default";
import React from "react";
import { BackdropInfo } from "@dndbeyond/character-rules-engine/es";
import { DDB_MEDIA_URL } from "../../../../../constants";
interface Props {
backdrop: BackdropInfo;
}
export default class BackdropStyles extends React.PureComponent<Props> {
sheet: StyleSheet | null = null;
componentDidMount() {
jss.setup(preset());
this.renderStyleSheet(this.props);
}
componentWillUnmount() {
this.removeStyleSheet();
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<{}>,
snapshot?: any
): void {
this.removeStyleSheet();
this.renderStyleSheet(this.props);
}
renderStyleSheet = (props: Props): void => {
const { backdrop } = props;
if (backdrop.backdropAvatarUrl !== null) {
this.sheet = jss.createStyleSheet({});
if (this.sheet !== null) {
let breakpointRules: Record<string, string> = [
{ width: 768, height: 152, image: backdrop.backdropAvatarUrl },
{ width: 1024, height: 218, image: backdrop.backdropAvatarUrl },
{ width: 1200, height: 230, image: backdrop.backdropAvatarUrl },
{
width: 1921,
height: 230,
image:
backdrop.smallBackdropAvatarUrl === null
? ""
: backdrop.smallBackdropAvatarUrl,
},
{
width: 2561,
height: 230,
image:
backdrop.largeBackdropAvatarUrl === null
? ""
: backdrop.largeBackdropAvatarUrl,
},
].reduce((acc, breakpoint) => {
acc[`@media (min-width: ${breakpoint.width}px)`] = {
background: `url(${breakpoint.image}) no-repeat center ${breakpoint.height}px, url(${DDB_MEDIA_URL}/attachments/0/84/background_texture.png) #f9f9f9 !important`,
};
return acc;
}, {});
this.sheet.addRules({
"@global": {
"html body.body-rpgcharacter-sheet": breakpointRules,
},
});
this.sheet.attach();
}
}
};
removeStyleSheet = (): void => {
if (this.sheet) {
jss.removeStyleSheet(this.sheet);
this.sheet = null;
}
};
render() {
return null;
}
}
@@ -0,0 +1,4 @@
import BackdropStyles from "./BackdropStyles";
export default BackdropStyles;
export { BackdropStyles };
@@ -0,0 +1,242 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import { Link as RouterLink } from "react-router-dom";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CampaignSummary,
LightBuilderSvg,
ThemedLongRestSvg,
ThemedShareSvg,
ThemedShortRestSvg,
} from "@dndbeyond/character-components/es";
import {
CampaignDataContract,
CharacterPreferences,
characterSelectors,
CharacterStatusSlug,
CharacterTheme,
Constants,
Item,
ItemUtils,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import { GameLogState } from "../../../Shared/stores/typings";
import WatchTourDialog from "../../components/WatchTourDialog";
import { sheetAppSelectors } from "../../selectors";
import { SheetAppState } from "../../typings";
import CharacterHeaderInfo from "../CharacterHeaderInfo";
interface Props extends DispatchProp {
campaign: CampaignDataContract | null;
builderUrl: string;
items: Array<Item>;
preferences: CharacterPreferences;
theme: CharacterTheme;
isReadonly: boolean;
gameLog: GameLogState;
status: CharacterStatusSlug | null;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class CharacterHeaderDesktop extends React.PureComponent<Props> {
constructor(props: Props) {
super(props);
}
handleCampaignShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
};
handleShortResetClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.SHORT_REST);
};
handleLongResetClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.LONG_REST);
};
handleShareClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.SHARE_URL);
};
hasMagicItem = (): boolean => {
const { items } = this.props;
return !!items.find((item) => ItemUtils.isMagic(item));
};
renderSideContent = (): React.ReactNode => {
const {
builderUrl,
campaign,
preferences,
isReadonly,
theme,
gameLog,
status,
} = this.props;
if (isReadonly) {
if (status === CharacterStatusSlug.PREMADE) {
return <WatchTourDialog />;
}
if (!campaign) {
return null;
}
return (
<CampaignSummary
campaign={campaign}
onCampaignShow={this.handleCampaignShow}
gameLog={gameLog}
theme={theme}
/>
);
}
return (
<React.Fragment>
{preferences !== null &&
preferences.privacyType ===
Constants.PreferencePrivacyTypeEnum.PUBLIC && (
<div className="ct-character-header-desktop__group ct-character-header-desktop__group--share">
<div
className="ct-character-header-desktop__button"
onClick={this.handleShareClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-desktop__button-icon">
<ThemedShareSvg theme={theme} />
</div>
<span className="ct-character-header-desktop__button-label">
Share
</span>
</div>
</div>
)}
<div className="ct-character-header-desktop__group ct-character-header-desktop__group--short-rest">
<div
className="ct-character-header-desktop__button"
onClick={this.handleShortResetClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-desktop__button-icon">
<ThemedShortRestSvg theme={theme} />
</div>
<span className="ct-character-header-desktop__button-label">
Short Rest
</span>
</div>
</div>
<div className="ct-character-header-desktop__group ct-character-header-desktop__group--long-rest">
<div
className="ct-character-header-desktop__button"
onClick={this.handleLongResetClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-desktop__button-icon">
<ThemedLongRestSvg theme={theme} />
</div>
<span className="ct-character-header-desktop__button-label">
Long Rest
</span>
</div>
</div>
{/*{this.hasMagicItem() &&*/}
{/*<div className="ct-character-header-desktop__group">*/}
{/*<div className="ct-character-header-desktop__button" onClick={this.handleDawnClick}>*/}
{/*<i className="i-menu-dawn" />*/}
{/*<span className="ct-character-header-desktop__button-label">Dawn</span>*/}
{/*</div>*/}
{/*</div>*/}
{/*}*/}
{campaign !== null && (
<CampaignSummary
campaign={campaign}
onCampaignShow={this.handleCampaignShow}
gameLog={gameLog}
theme={theme}
/>
)}
<div className="ct-character-header-desktop__group ct-character-header-desktop__group--builder">
<Tooltip
isDarkMode={theme.isDarkMode}
title="Go to builder"
className="ct-character-header-desktop__builder"
>
<RouterLink
to={builderUrl}
className="ct-character-header-desktop__builder-link"
aria-label="Go to builder"
>
<LightBuilderSvg />
</RouterLink>
</Tooltip>
</div>
</React.Fragment>
);
};
render() {
return (
<div className="ct-character-header-desktop">
<div className="ct-character-header-desktop__group ct-character-header-desktop__group-tidbits">
<CharacterHeaderInfo />
</div>
<div className="ct-character-header-desktop__group ct-character-header-desktop__group--gap" />
{this.renderSideContent()}
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
builderUrl: sheetAppSelectors.getBuilderUrl(state),
campaign: rulesEngineSelectors.getCampaign(state),
items: rulesEngineSelectors.getInventory(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
gameLog: appEnvSelectors.getGameLog(state),
status: characterSelectors.getStatusSlug(state),
};
}
const CharacterHeaderDesktopContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<CharacterHeaderDesktop paneHistoryStart={paneHistoryStart} {...props} />
);
};
export default connect(mapStateToProps)(CharacterHeaderDesktopContainer);
@@ -0,0 +1,110 @@
import React from "react";
import { connect } from "react-redux";
import { CharacterTidbits } from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
CharacterPreferences,
CharClass,
Constants,
ExperienceInfo,
Race,
RuleData,
DecorationInfo,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { ThemeButton } from "../../../Shared/components/common/Button";
import { appEnvSelectors } from "../../../Shared/selectors";
import { SheetAppState } from "../../typings";
interface Props {
classes: Array<CharClass>;
name: string;
gender: string | null;
species: Race | null;
deathCause: Constants.DeathCauseEnum;
ruleData: RuleData;
xpInfo: ExperienceInfo;
preferences: CharacterPreferences;
decorationInfo: DecorationInfo;
isReadonly: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class CharacterHeaderInfo extends React.PureComponent<Props, {}> {
handleInfoClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.CHARACTER_MANAGE);
};
render() {
const {
deathCause,
name,
classes,
gender,
xpInfo,
species,
preferences,
ruleData,
decorationInfo,
isReadonly,
} = this.props;
return (
<div className="ct-character-header-info">
<div
className="ct-character-header-info__content"
onClick={this.handleInfoClick}
>
<CharacterTidbits
classes={classes}
decorationInfo={decorationInfo}
name={name}
gender={gender}
species={species}
deathCause={deathCause}
preferences={preferences}
ruleData={ruleData}
xpInfo={xpInfo}
isInteractive={!isReadonly}
calloutNode={
<ThemeButton size="small" style="outline">
Manage
</ThemeButton>
}
/>
</div>
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
name: rulesEngineSelectors.getName(state),
gender: rulesEngineSelectors.getGender(state),
species: rulesEngineSelectors.getRace(state),
classes: rulesEngineSelectors.getClasses(state),
xpInfo: rulesEngineSelectors.getExperienceInfo(state),
deathCause: rulesEngineSelectors.getDeathCause(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
ruleData: rulesEngineSelectors.getRuleData(state),
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
const CharacterHeaderInfoContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <CharacterHeaderInfo paneHistoryStart={paneHistoryStart} {...props} />;
};
export default connect(mapStateToProps)(CharacterHeaderInfoContainer);
@@ -0,0 +1,4 @@
import CharacterHeaderInfo from "./CharacterHeaderInfo";
export default CharacterHeaderInfo;
export { CharacterHeaderInfo };
@@ -0,0 +1,114 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
rulesEngineSelectors,
characterActions,
characterSelectors,
CharacterStatusSlug,
Constants,
HitPointInfo,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import StatusSummaryMobile from "../../components/StatusSummaryMobile";
import WatchTourDialog from "../../components/WatchTourDialog";
import { SheetAppState } from "../../typings";
import CharacterHeaderInfo from "../CharacterHeaderInfo";
interface Props extends DispatchProp {
hitPointInfo: HitPointInfo;
fails: number;
successes: number;
deathCause: Constants.DeathCauseEnum;
inspiration: boolean;
ruleData: RuleData;
isReadonly: boolean;
status: CharacterStatusSlug | null;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class CharacterHeaderMobile extends React.PureComponent<Props> {
handleHealthSummaryClick = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.HEALTH_MANAGE);
}
};
handleInspirationClick = (): void => {
const { inspiration, dispatch } = this.props;
dispatch(characterActions.inspirationSet(!inspiration));
};
render() {
const {
hitPointInfo,
fails,
successes,
deathCause,
inspiration,
ruleData,
isReadonly,
status,
} = this.props;
return (
<div className="ct-character-header-mobile">
<div className="ct-character-header-mobile__group ct-character-header-mobile__group-tidbits">
<CharacterHeaderInfo />
</div>
<div className="ct-character-header-mobile__group ct-character-header-mobile__group--gap" />
{isReadonly && status === CharacterStatusSlug.PREMADE ? (
<div>
<WatchTourDialog />
</div>
) : (
<div className="ct-character-header-mobile__group ct-character-header-mobile__group--summary">
<StatusSummaryMobile
hitPointInfo={hitPointInfo}
fails={fails}
successes={successes}
deathCause={deathCause}
inspiration={inspiration}
ruleData={ruleData}
onHealthClick={this.handleHealthSummaryClick}
onInspirationClick={this.handleInspirationClick}
isInteractive={!isReadonly}
/>
</div>
)}
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
hitPointInfo: rulesEngineSelectors.getHitPointInfo(state),
fails: rulesEngineSelectors.getDeathSavesFailCount(state),
successes: rulesEngineSelectors.getDeathSavesSuccessCount(state),
inspiration: rulesEngineSelectors.getInspiration(state),
ruleData: rulesEngineSelectors.getRuleData(state),
deathCause: rulesEngineSelectors.getDeathCause(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
status: characterSelectors.getStatusSlug(state),
};
}
const CharacterHeaderMobileContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<CharacterHeaderMobile paneHistoryStart={paneHistoryStart} {...props} />
);
};
export default connect(mapStateToProps)(CharacterHeaderMobileContainer);
@@ -0,0 +1,4 @@
import CharacterHeaderMobile from "./CharacterHeaderMobile";
export default CharacterHeaderMobile;
export { CharacterHeaderMobile };
@@ -0,0 +1,281 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
LightBuilderSvg,
ThemedShareSvg,
ThemedLongRestSvg,
ThemedShortRestSvg,
} from "@dndbeyond/character-components/es";
import {
characterActions,
CampaignDataContract,
CharacterPreferences,
characterSelectors,
CharacterStatusSlug,
CharacterTheme,
Constants,
HitPointInfo,
Item,
ItemUtils,
RuleData,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import StatusSummaryMobile from "../../components/StatusSummaryMobile";
import WatchTourDialog from "../../components/WatchTourDialog";
import { sheetAppSelectors } from "../../selectors";
import { SheetAppState } from "../../typings";
import { CharacterHeaderInfo } from "../CharacterHeaderInfo";
interface Props extends DispatchProp {
hitPointInfo: HitPointInfo;
fails: number;
successes: number;
deathCause: Constants.DeathCauseEnum;
inspiration: boolean;
campaign: CampaignDataContract | null;
builderUrl: string;
items: Array<Item>;
preferences: CharacterPreferences;
ruleData: RuleData;
isReadonly: boolean;
theme: CharacterTheme;
status: CharacterStatusSlug | null;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class CharacterHeaderTablet extends React.PureComponent<Props> {
handleCampaignShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
};
handleShortResetClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.SHORT_REST);
};
handleGameLogClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.GAME_LOG);
};
handleLongResetClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.LONG_REST);
};
handleShareClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.SHARE_URL);
};
handleHealthSummaryClick = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.HEALTH_MANAGE);
}
};
handleInspirationClick = (): void => {
const { inspiration, dispatch } = this.props;
dispatch(characterActions.inspirationSet(!inspiration));
};
hasMagicItem = (): boolean => {
const { items } = this.props;
return !!items.find((item) => ItemUtils.isMagic(item));
};
renderSideContent = (): React.ReactNode => {
const {
hitPointInfo,
fails,
successes,
deathCause,
inspiration,
builderUrl,
preferences,
isReadonly,
ruleData,
theme,
status,
} = this.props;
if (isReadonly) {
if (status === CharacterStatusSlug.PREMADE) {
return (
<div>
<WatchTourDialog />
</div>
);
}
return (
<React.Fragment>
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--summary">
<StatusSummaryMobile
hitPointInfo={hitPointInfo}
fails={fails}
successes={successes}
inspiration={inspiration}
ruleData={ruleData}
deathCause={deathCause}
onHealthClick={this.handleHealthSummaryClick}
onInspirationClick={this.handleInspirationClick}
isInteractive={!isReadonly}
/>
</div>
</React.Fragment>
);
}
return (
<React.Fragment>
{preferences !== null &&
preferences.privacyType ===
Constants.PreferencePrivacyTypeEnum.PUBLIC && (
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--share">
<div
className="ct-character-header-tablet__button"
onClick={this.handleShareClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-tablet__button-icon">
<ThemedShareSvg theme={theme} />
</div>
<span className="ct-character-header-tablet__button-label">
Share
</span>
</div>
</div>
)}
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--short-rest">
<div
className="ct-character-header-tablet__button"
onClick={this.handleShortResetClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-tablet__button-icon">
<ThemedShortRestSvg theme={theme} />
</div>
<span className="ct-character-header-tablet__button-label">
Short Rest
</span>
</div>
</div>
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--long-rest">
<div
className="ct-character-header-tablet__button"
onClick={this.handleLongResetClick}
role="button"
tabIndex={0}
>
<div className="ct-character-header-tablet__button-icon">
<ThemedLongRestSvg theme={theme} />
</div>
<span className="ct-character-header-tablet__button-label">
Long Rest
</span>
</div>
</div>
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--builder">
<Tooltip
isDarkMode={theme.isDarkMode}
title="Go to builder"
className="ct-character-header-tablet__builder"
>
<Link
href={builderUrl}
className="ct-character-header-tablet__builder-link"
useRouter
>
<LightBuilderSvg />
</Link>
</Tooltip>
</div>
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--summary">
<StatusSummaryMobile
hitPointInfo={hitPointInfo}
fails={fails}
successes={successes}
deathCause={deathCause}
inspiration={inspiration}
ruleData={ruleData}
onHealthClick={this.handleHealthSummaryClick}
onInspirationClick={this.handleInspirationClick}
isInteractive={!isReadonly}
/>
</div>
</React.Fragment>
);
};
render() {
return (
<div className="ct-character-header-tablet">
<div className="ct-character-header-tablet__group ct-character-header-tablet__group-tidbits">
<CharacterHeaderInfo />
</div>
<div className="ct-character-header-tablet__group ct-character-header-tablet__group--gap" />
{this.renderSideContent()}
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
hitPointInfo: rulesEngineSelectors.getHitPointInfo(state),
fails: rulesEngineSelectors.getDeathSavesFailCount(state),
successes: rulesEngineSelectors.getDeathSavesSuccessCount(state),
inspiration: rulesEngineSelectors.getInspiration(state),
builderUrl: sheetAppSelectors.getBuilderUrl(state),
campaign: rulesEngineSelectors.getCampaign(state),
items: rulesEngineSelectors.getInventory(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
ruleData: rulesEngineSelectors.getRuleData(state),
deathCause: rulesEngineSelectors.getDeathCause(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
status: characterSelectors.getStatusSlug(state),
};
}
const CharacterHeaderTabletContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<CharacterHeaderTablet paneHistoryStart={paneHistoryStart} {...props} />
);
};
export default connect(mapStateToProps)(CharacterHeaderTabletContainer);
@@ -0,0 +1,4 @@
import CharacterHeaderTablet from "./CharacterHeaderTablet";
export default CharacterHeaderTablet;
export { CharacterHeaderTablet };
@@ -0,0 +1,676 @@
import { throttle } from "lodash";
import { createRef, PureComponent } from "react";
import { connect, DispatchProp } from "react-redux";
import {
characterEnvSelectors,
Constants,
FormatUtils,
rulesEngineSelectors,
CharacterTheme,
CharacterPreferences,
characterSelectors,
CharacterStatusSlug,
} from "@dndbeyond/character-rules-engine/es";
import { GameLogContextProvider } from "@dndbeyond/game-log-components";
import { Link } from "~/components/Link";
import { NotificationSystem } from "~/components/NotificationSystem";
import { PremadeCharacterEditStatus } from "~/components/PremadeCharacterEditStatus";
import { usePositioning } from "~/hooks/usePositioning";
import { MobileNav } from "~/subApps/sheet/components/MobileNav";
import { useHeadContext } from "../../../../../contexts/Head";
import { ThemeManagerProvider } from "../../../../../contexts/ThemeManager";
import { getCharacterSlots } from "../../../../../helpers/characterServiceApi";
import SynchronousBlocker from "../../../CharacterBuilder/containers/SynchronousBlocker";
import { appEnvActions, toastMessageActions } from "../../../Shared/actions";
import LoadingBlocker from "../../../Shared/components/LoadingBlocker";
import AppErrorTypeEnum from "../../../Shared/constants/AppErrorTypeEnum";
import UserRoles from "../../../Shared/constants/UserRoles";
import { DiceContainer } from "../../../Shared/containers/DiceContainer";
import {
appEnvSelectors,
appInfoSelectors,
toastMessageSelectors,
} from "../../../Shared/selectors";
import {
AppEnvDimensionsState,
AppInfoErrorState,
DiceFeatureConfigurationState,
GameLogState,
ToastMessageState,
} from "../../../Shared/stores/typings";
import config from "../../../config";
import { sheetActions } from "../../actions";
import ClaimPremadeButton from "../../components/ClaimPremadeButton";
import { InvalidCharacter } from "../../components/InvalidCharacter";
import {
DESKTOP_COMPONENT_START_WIDTH,
MQ_IS_MOBILE_SIZE,
TABLET_COMPONENT_START_WIDTH,
} from "../../config";
import { sheetAppSelectors, sheetSelectors } from "../../selectors";
import { SheetAppState, SheetPositioningInfo } from "../../typings";
import CharacterSheetDesktop from "../CharacterSheetDesktop";
import CharacterSheetGuidedTour from "../CharacterSheetGuidedTour";
import CharacterSheetMobile from "../CharacterSheetMobile";
import CharacterSheetTablet from "../CharacterSheetTablet";
interface Props extends DispatchProp {
sheetPosition: SheetPositioningInfo;
initFailed: boolean;
loadingStatus: Constants.CharacterLoadingStatusEnum;
toastMessages: ToastMessageState;
builderUrl: string;
isMobile: boolean;
envDimensions: AppEnvDimensionsState;
isReadonly: boolean;
canEdit: boolean;
nearTopLimit: number;
appError: AppInfoErrorState | null;
isCharacterSheetReady: boolean;
diceEnabled: boolean;
characterId: number | null;
authEndpoint: string;
diceFeatureConfiguration: DiceFeatureConfigurationState;
gameLog: GameLogState;
theme: CharacterTheme;
username: string;
characterName: string | null;
preferences: CharacterPreferences;
setManager: (manager: any) => void;
setTitle: (title: string) => void;
characterStatus: CharacterStatusSlug | null;
userRoles: UserRoles;
}
interface State {
curseHeaderHeight: number;
scrollTop: number;
isNotificationOpen: boolean;
isLoaded: boolean;
isSlotLimitsLoaded: boolean;
}
class CharacterSheet extends PureComponent<Props, State> {
static defaultProps = {
nearTopLimit: 40,
characterJson: null,
ruleDataJson: null,
diceEnabled: false,
};
// useImperativeHandle is hard to type when we us our own context this will just disapear
notificationSystem = createRef<any>();
sheetRef = createRef<HTMLDivElement>();
mobileMql: MediaQueryList | null = null;
constructor(props) {
super(props);
this.state = {
curseHeaderHeight: 0,
scrollTop: 0,
isNotificationOpen: false,
isLoaded: false,
isSlotLimitsLoaded: false,
};
}
componentDidMount() {
const { dispatch } = this.props;
const { isLoaded } = this.state;
if (!isLoaded) {
dispatch(sheetActions.characterInit());
this.setState({ isLoaded: true });
}
this.initScrollWatchers();
this.mobileMql = window.matchMedia(MQ_IS_MOBILE_SIZE);
dispatch(appEnvActions.mobileSet(this.mobileMql.matches));
this.mobileMql.onchange = (e) => {
dispatch(appEnvActions.mobileSet(e.matches));
};
this.initResizeWatchers();
}
componentDidUpdate(prevProps, prevState) {
const {
dispatch,
loadingStatus,
canEdit,
diceEnabled,
characterName,
setTitle,
characterStatus,
username,
userRoles,
} = this.props;
if (loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED) {
if (characterStatus === CharacterStatusSlug.PREMADE) {
dispatch(
appEnvActions.dataSet({
canOverrideReadOnly:
userRoles.includes(UserRoles.ADMIN) ||
userRoles.includes(UserRoles.DEV) ||
userRoles.includes(UserRoles.LOREKEEPER),
})
);
} else {
dispatch(
appEnvActions.dataSet({
isReadonly: !canEdit,
diceEnabled: canEdit ? diceEnabled : false,
})
);
}
if (characterName) {
setTitle(`${characterName}'s Character Sheet`);
}
if (
username &&
characterStatus === CharacterStatusSlug.PREMADE &&
!this.state.isSlotLimitsLoaded
) {
this.setState({ isSlotLimitsLoaded: true });
getCharacterSlots().then((characterSlots) => {
dispatch(
appEnvActions.dataSet({
characterSlots,
})
);
});
}
}
this.checkForMessages();
}
updateSheetScrollState = (): void => {
const curScrollTop = window.scrollY;
const { scrollTop } = this.state;
const { nearTopLimit } = this.props;
if (curScrollTop === 0) {
document.body.classList.add("ct-character-sheet--top");
document.body.classList.remove("ct-character-sheet--not-top");
} else {
document.body.classList.add("ct-character-sheet--not-top");
document.body.classList.remove("ct-character-sheet--top");
}
if (curScrollTop < nearTopLimit) {
document.body.classList.add("ct-character-sheet--near-top");
document.body.classList.remove("ct-character-sheet--far-top");
} else {
document.body.classList.add("ct-character-sheet--far-top");
document.body.classList.remove("ct-character-sheet--near-top");
}
if (curScrollTop < scrollTop) {
// Going up
document.body.classList.add("ct-character-sheet--going-up");
document.body.classList.remove("ct-character-sheet--going-down");
} else {
// Going down
document.body.classList.add("ct-character-sheet--going-down");
document.body.classList.remove("ct-character-sheet--going-up");
}
this.setState({
scrollTop: curScrollTop,
});
};
// TODO: we should setup a remove listener for this
initScrollWatchers = (): void => {
// Assuming there should be only 1
const curseHeader = document.querySelector<HTMLElement>("header.main");
const curseHeaderHeight: number = curseHeader
? curseHeader.offsetHeight
: 0;
this.setState({ curseHeaderHeight: curseHeaderHeight });
this.updateSheetScrollState();
const SCROLL_THROTTLE_AMOUNT = 70;
document.addEventListener(
"scroll",
throttle(() => {
this.updateSheetScrollState();
}, SCROLL_THROTTLE_AMOUNT)
);
};
updateEnvDimensions = (): void => {
const { dispatch } = this.props;
if (this.sheetRef.current === null) {
return;
}
dispatch(
appEnvActions.dimensionsSet({
window: {
width: window.innerWidth,
height: window.innerHeight,
},
sheet: {
width: this.sheetRef.current.clientWidth,
height: this.sheetRef.current.clientHeight,
},
})
);
};
initResizeWatchers = (): void => {
const { envDimensions } = this.props;
if (this.sheetRef.current === null) {
return;
}
this.updateEnvDimensions();
const RESIZE_THROTTLE_AMOUNT: number = 300;
window.addEventListener(
"resize",
throttle(() => {
this.updateEnvDimensions();
}, RESIZE_THROTTLE_AMOUNT)
);
window.addEventListener(
"orientationchange",
throttle(() => {
let oldSize: number = envDimensions.window.width;
let count: number = 0;
let orientationIntervalId = window.setInterval(() => {
if (oldSize !== envDimensions.window.width || count > 15) {
window.clearInterval(orientationIntervalId);
return;
}
if (oldSize !== window.innerWidth) {
this.updateEnvDimensions();
}
count++;
}, 100);
}, RESIZE_THROTTLE_AMOUNT)
);
};
checkForMessages = (): void => {
if (this.notificationSystem.current === null) {
return;
}
const { toastMessages, dispatch } = this.props;
Object.keys(toastMessages).forEach((messageId) => {
const message = toastMessages[messageId];
const { componentProps, Component, ...metaProps } = message.meta;
let children: React.ReactNode;
if (Component) {
let childrenProps = componentProps ? componentProps : {};
children = <Component {...childrenProps} />;
}
if (this.notificationSystem.current !== null) {
this.notificationSystem.current.addNotification({
title: message.payload.title,
message: message.payload.message,
uid: messageId,
children,
onRemove: () => dispatch(toastMessageActions.removeToast(messageId)),
...metaProps,
});
}
});
};
isSheetErrored = (): boolean => {
const { initFailed, appError } = this.props;
return initFailed || appError !== null;
};
renderCharacterSheet = (): React.ReactNode => {
const { envDimensions, isCharacterSheetReady, builderUrl, isReadonly } =
this.props;
if (!isCharacterSheetReady) {
return (
<InvalidCharacter builderUrl={builderUrl} isReadonly={isReadonly} />
);
}
if (envDimensions.window.width >= DESKTOP_COMPONENT_START_WIDTH) {
return <CharacterSheetDesktop />;
} else if (envDimensions.window.width >= TABLET_COMPONENT_START_WIDTH) {
return <CharacterSheetTablet />;
}
return <CharacterSheetMobile />;
};
renderSyncBlocker = (): React.ReactNode => {
const { initFailed, appError } = this.props;
if (appError !== null) {
return null;
}
if (initFailed) {
return null;
}
return <SynchronousBlocker />;
};
renderInitFailed = (): React.ReactNode => {
return (
<div className="ct-character-sheet__failed">
<div className="ct-character-sheet__failed-content">
<p>
Whoops! We rolled a 1 on our Retrieve Character check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</div>
</div>
);
};
renderAppError = (): React.ReactNode => {
const { appError } = this.props;
if (appError === null) {
return null;
}
let contentNode: React.ReactNode;
switch (appError.type) {
case AppErrorTypeEnum.AUTH_MISSING:
let signInUrl = `/sign-in?returnUrl=${encodeURIComponent(
window.location.pathname
)}`;
contentNode = (
<>
<p>
You are no longer signed into D&D Beyond. Go to the{" "}
<Link href={signInUrl}>Sign In page</Link> to continue the
adventure!
</p>
</>
);
break;
case AppErrorTypeEnum.API_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.API_DATA_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API Data check. We're heading into
town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.AUTH_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our Authentication check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.ACCESS_DENIED:
contentNode = (
<>
<p>You don't have access to this page.</p>
<p>
If you are trying to access a character sheet, make sure its
privacy setting is configured correctly for you to access it.
</p>
<p>
It is also possible that you are trying to enter the 403rd level
of the endless dungeon and the ancient dragon Rylzrayrth is rising
up to block your path...
</p>
</>
);
break;
case AppErrorTypeEnum.NOT_FOUND:
appError.errorId = null;
contentNode = (
<>
<p>
We live in a world of uncertainty. But certainly, the page you
were looking for isnt here. Perhaps this halfling has stolen it
and hidden it in another place. Try searching for what you were
looking for in another realm.
</p>
</>
);
break;
case AppErrorTypeEnum.GENERIC:
default:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
}
return (
<div
className={[
"ct-character-sheet__failed",
`ct-character-sheet__failed--${FormatUtils.slugify(appError.type)}`,
].join(" ")}
>
<div className="ct-character-sheet__failed-content">
{contentNode}
{appError.errorId && (
<>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Error Code
</div>
<div className="ct-character-sheet__failed-code-value">
{appError.errorId}
</div>
</div>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Version
</div>
<div className="ct-character-sheet__failed-code-value">
{config.version}
</div>
</div>
</>
)}
</div>
</div>
);
};
renderContent = (): React.ReactNode => {
const { loadingStatus, initFailed, appError } = this.props;
if (appError !== null) {
return this.renderAppError();
}
if (initFailed) {
return this.renderInitFailed();
}
const isLoaded =
loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED;
return (
<>
{isLoaded && this.renderCharacterSheet()}
<LoadingBlocker isFinished={isLoaded || initFailed} />
</>
);
};
render() {
const {
loadingStatus,
sheetPosition,
envDimensions,
isCharacterSheetReady,
diceEnabled,
characterId,
authEndpoint,
diceFeatureConfiguration,
gameLog,
theme,
isReadonly,
characterStatus,
} = this.props;
let sheetStyles: React.CSSProperties = {};
if (envDimensions.window.width >= DESKTOP_COMPONENT_START_WIDTH) {
sheetStyles = {
transform: `translateX(${sheetPosition.left}px)`,
WebkitTransform: `translateX(${sheetPosition.left}px)`,
};
}
let classNames: Array<string> = ["ct-character-sheet"];
if (this.isSheetErrored()) {
classNames.push("ct-character-sheet--failed");
}
if (!isCharacterSheetReady) {
classNames.push("ct-character-sheet--invalid");
}
if (diceEnabled) {
classNames.push("ct-character-sheet--dice-enabled");
}
if (theme.isDarkMode) {
classNames.push("ct-character-sheet--dark-mode");
}
const everythingIsLoadedWithoutErrors: boolean =
loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED &&
isCharacterSheetReady &&
!this.isSheetErrored();
return (
<CharacterSheetGuidedTour>
<GameLogContextProvider
baseUrl={gameLog.apiEndpoint}
ddbApiUrl={gameLog.ddbApiEndpoint}
diceServiceUrl={diceFeatureConfiguration.apiEndpoint}
authUrl={authEndpoint}
diceThumbnailsUrl={`${diceFeatureConfiguration.assetBaseLocation}/images/thumbnails`}
entityId={characterId ? characterId.toString() : ""}
>
<div className={classNames.join(" ")}>
{this.renderSyncBlocker()}
<div
className="ct-character-sheet__inner"
ref={this.sheetRef}
style={sheetStyles}
>
<ThemeManagerProvider
manager={{
lightOrDark: theme.isDarkMode ? "dark" : "light",
primary: theme.themeColor,
}}
>
{everythingIsLoadedWithoutErrors && (
<PremadeCharacterEditStatus
characterStatus={characterStatus}
isReadonly={isReadonly}
/>
)}
{this.renderContent()}
{everythingIsLoadedWithoutErrors && <MobileNav />}
</ThemeManagerProvider>
</div>
<NotificationSystem ref={this.notificationSystem} />
{diceEnabled && !isReadonly && (
<DiceContainer canShow={everythingIsLoadedWithoutErrors} />
)}
{everythingIsLoadedWithoutErrors &&
characterStatus === CharacterStatusSlug.PREMADE &&
isReadonly && <ClaimPremadeButton />}
</div>
</GameLogContextProvider>
</CharacterSheetGuidedTour>
);
}
}
const CharacterSheetWrapper = (props) => {
const { setTitle } = useHeadContext();
const { getSheetPositioning } = usePositioning();
return (
<CharacterSheet
{...props}
setTitle={setTitle}
sheetPosition={getSheetPositioning()}
/>
);
};
function mapStateToProps(state: SheetAppState) {
return {
initFailed: sheetSelectors.getInitFailed(state),
loadingStatus: characterEnvSelectors.getLoadingStatus(state),
toastMessages: toastMessageSelectors.getToastMessages(state),
builderUrl: sheetAppSelectors.getBuilderUrl(state),
isMobile: appEnvSelectors.getIsMobile(state),
envDimensions: appEnvSelectors.getDimensions(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
userRoles: appEnvSelectors.getUserRoles(state),
appError: appInfoSelectors.getError(state),
isCharacterSheetReady: rulesEngineSelectors.isCharacterSheetReady(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
characterId: appEnvSelectors.getCharacterId(state),
diceFeatureConfiguration:
appEnvSelectors.getDiceFeatureConfiguration(state),
gameLog: appEnvSelectors.getGameLog(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
canEdit: rulesEngineSelectors.getCanEdit(state),
username: appEnvSelectors.getUsername(state),
characterName: rulesEngineSelectors.getName(state),
preferences: rulesEngineSelectors.getPreferences(state),
characterStatus: characterSelectors.getStatusSlug(state),
};
}
export default connect(mapStateToProps)(CharacterSheetWrapper);
@@ -0,0 +1,4 @@
import CharacterSheet from "./CharacterSheet";
export default CharacterSheet;
export { CharacterSheet };
@@ -0,0 +1,45 @@
import { useLayoutEffect } from "react";
import { SheetProvider } from "~/subApps/sheet/contexts/Sheet";
import ErrorBoundary from "../../../Shared/components/ErrorBoundary";
import CharacterSheet from "../CharacterSheet";
export function CharacterSheetContainer({
authEndpoint,
}: {
authEndpoint: string;
}) {
useLayoutEffect(() => {
document
.getElementsByTagName("body")[0]
.classList.add(
"site",
"body-rpgcharacter-sheet",
"site-dndbeyond",
"body-rpgcharacter",
"body-rpgcharacter-details"
);
return () => {
document
.getElementsByTagName("body")[0]
.classList.remove(
"site",
"body-rpgcharacter-sheet",
"site-dndbeyond",
"body-rpgcharacter",
"body-rpgcharacter-details"
);
};
}, []);
return (
<ErrorBoundary>
<SheetProvider>
<CharacterSheet authEndpoint={authEndpoint} />
</SheetProvider>
</ErrorBoundary>
);
}
export default CharacterSheetContainer;
@@ -0,0 +1,4 @@
import CharacterSheetContainer from "./CharacterSheetContainer";
export default CharacterSheetContainer;
export { CharacterSheetContainer };
@@ -0,0 +1,181 @@
import { spring, TransitionMotion } from "@serprex/react-motion";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import { ThemeStyles } from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
DecorationInfo,
DecorationUtils,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { SidebarInfo } from "~/contexts/Sidebar/Sidebar";
import { usePositioning } from "~/hooks/usePositioning";
import { Sidebar } from "~/subApps/sheet/components/Sidebar";
import { SidebarPositionInfo } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import Subsections from "../../components/Subsections";
import { DESKTOP_LARGE_COMPONENT_START_WIDTH } from "../../config";
import { SheetAppState } from "../../typings";
import BackdropStyles from "../BackdropStyles";
import { CharacterHeaderDesktop } from "../CharacterHeaderDesktop";
import Combat from "../Combat";
import PrimaryBox from "../PrimaryBox";
import ProficiencyGroupsDesktop from "../ProficiencyGroupsDesktop";
import QuickInfo from "../QuickInfo";
import SavingThrowsDesktop from "../SavingThrowsDesktop";
import SensesDesktop from "../SensesDesktop";
import SkillsDesktop from "../SkillsDesktop";
interface Props extends DispatchProp {
sidebarInfo: SidebarInfo;
sidebarPosition: SidebarPositionInfo;
decorationInfo: DecorationInfo;
envDimensions: AppEnvDimensionsState;
}
interface State {
swipedAmount: number;
}
class CharacterSheetDesktop extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
swipedAmount: 0,
};
}
getPositionX = (swipedAmount: number): number => {
const { sidebarInfo } = this.props;
let position: number =
(sidebarInfo.isVisible ? 0 : sidebarInfo.width) + swipedAmount;
if (swipedAmount > 0) {
return Math.min(sidebarInfo.width, position);
} else {
return Math.max(0, position);
}
};
renderContent = (): React.ReactNode => {
return (
<React.Fragment>
<CharacterHeaderDesktop />
<QuickInfo />
<Subsections>
<SavingThrowsDesktop />
<SensesDesktop />
<ProficiencyGroupsDesktop />
<SkillsDesktop />
<Combat />
<PrimaryBox />
</Subsections>
</React.Fragment>
);
};
renderSmall = (): React.ReactNode => {
const { swipedAmount } = this.state;
const { sidebarPosition } = this.props;
return (
<div className="ct-character-sheet-desktop">
{this.renderContent()}
<TransitionMotion
styles={[
{
key: "1",
style: {
transform: spring(this.getPositionX(swipedAmount), {
stiffness: 400,
damping: 30,
}),
},
},
]}
>
{(interpolatedStyles) => (
<React.Fragment>
{interpolatedStyles.map((config) => (
<>
<Sidebar
key={config.key}
style={{
...sidebarPosition,
transform: `translateX(${config.style.transform}px)`,
}}
setSwipedAmount={(swipedAmount) =>
this.setState({ swipedAmount })
}
/>
</>
))}
</React.Fragment>
)}
</TransitionMotion>
</div>
);
};
renderLarge = (): React.ReactNode => {
const { sidebarPosition } = this.props;
return (
<div className="ct-character-sheet-desktop">
{this.renderContent()}
<Sidebar style={sidebarPosition} />
</div>
);
};
renderSheet = (): React.ReactNode => {
const { envDimensions } = this.props;
if (envDimensions.window.width < DESKTOP_LARGE_COMPONENT_START_WIDTH) {
return this.renderSmall();
}
return this.renderLarge();
};
render() {
const { decorationInfo } = this.props;
return (
<React.Fragment>
{this.renderSheet()}
<BackdropStyles
backdrop={DecorationUtils.getBackdropInfo(decorationInfo)}
/>
<ThemeStyles decorationInfo={decorationInfo} />
</React.Fragment>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
envDimensions: appEnvSelectors.getDimensions(state),
};
}
const CharacterSheetContainer = (props) => {
const { sidebar } = useSidebar();
const { getSidebarPositioning } = usePositioning();
return (
<CharacterSheetDesktop
sidebarInfo={sidebar}
sidebarPosition={getSidebarPositioning()}
{...props}
/>
);
};
export default connect(mapStateToProps)(CharacterSheetContainer);
@@ -0,0 +1,4 @@
import CharacterSheetDesktop from "./CharacterSheetDesktop";
export default CharacterSheetDesktop;
export { CharacterSheetDesktop };
@@ -0,0 +1,74 @@
import { useMediaQuery } from "@mui/material";
import { useContext, useEffect, useState } from "react";
import { connect, useSelector } from "react-redux";
import {
Constants,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { GuidedTour } from "../../../Shared/containers/GuidedTour";
import { getCharacterSheetSteps } from "./getCharacterSheetSteps";
const CharacterSheetGuidedTour = ({ children }) => {
const [step, setStep] = useState(0);
const { characterSheetTourFlag } = useFeatureFlags();
const isTablet = useMediaQuery("(min-width: 768px)");
const isDesktop = useMediaQuery("(min-width: 1024px)");
const ready = useSelector(rulesEngineSelectors.isCharacterSheetReady);
// const hasSpells = useSelector(rulesEngineSelectors.hasSpells);
const [loading, setLoading] = useState(true);
const setCurrentStep = (currentStep) => {
switch (currentStep) {
case 11: {
if (!isTablet && !isDesktop) {
const toggle = document.querySelector(
"[class^='styles_navToggle']"
) as HTMLButtonElement;
toggle?.click();
}
break;
}
case 13: {
if (isTablet && !isDesktop) {
const toggle = document.querySelector(
"[class^='styles_navToggle']"
) as HTMLButtonElement;
toggle?.click();
}
break;
}
default:
break;
}
setStep(currentStep);
};
useEffect(() => {
// If character sheet has loaded, enable tour
if (ready) setLoading(false);
}, [ready]);
return (
<>
{characterSheetTourFlag && !loading ? (
<GuidedTour
steps={getCharacterSheetSteps(false, isTablet, isDesktop)}
step={step}
setStep={setCurrentStep}
showOnFirstLoad={true}
cookieName="characterSheetGuidedTour"
>
{children}
</GuidedTour>
) : (
<>{children}</>
)}
</>
);
};
export default CharacterSheetGuidedTour;
@@ -0,0 +1,457 @@
import { StepType } from "@reactour/tour";
import { GuidedTourStep } from "~/tools/js/Shared/containers/GuidedTour";
/**
* CHARACTER SHEET TOUR STEPS
**/
export const getCharacterSheetSteps = (
hasSpells: boolean,
isTablet: boolean,
isDesktop: boolean
): StepType[] => {
const getSelector = (
mobileSelector: string,
tabletSelector?: string,
desktopSelector?: string
) =>
isDesktop && desktopSelector
? desktopSelector
: isTablet && tabletSelector
? tabletSelector
: mobileSelector;
const stepList = [
{
selector: getSelector("html"),
content: (
<GuidedTourStep
title="Welcome!"
content={
<>
Welcome to your character sheet! Here, you can find information
about your character, roll dice, and find more information about
other characters in your game. If you ever have any questions
about rules or what something is for, you can click on almost
anything to learn more!
</>
}
showClose
/>
),
},
{
selector: getSelector(".dice-toolbar"),
content: (
<GuidedTourStep
title="Digital Dice"
content={
<>
Click here if you need to make a custom dice roll. Your dice
collection can be found{" "}
<a
href="https://www.dndbeyond.com/my-dice"
target="_blank"
rel="noopener"
>
here
</a>
.
</>
}
/>
),
},
{
selector: getSelector(
".ct-character-header-mobile__group-tidbits",
".ct-character-header-tablet__group-tidbits",
".ct-character-header-desktop__group-tidbits"
),
content: (
<GuidedTourStep
title="Character Overview"
content={
<>
Basic information about your character such as Name, Species,
Class, and Level can be found here. Click this area to change your
character's name, sheet styles, and modify other settings for your
character.
</>
}
/>
),
},
{
selector: ".ct-character-header-desktop__group--builder",
content: (
<GuidedTourStep
title="Character Builder"
content={
<>
Click here to visit the Character Builder. Each time you level up,
check here to see how to develop your character.
</>
}
/>
),
},
{
selector: getSelector(
".ct-main-mobile__abilities",
".ct-main-tablet__abilities",
".ct-quick-info__abilities"
),
content: (
<GuidedTourStep
title="Ability Scores"
content={
<>
Much of what your character does in the game depends on his or her
six abilities: Strength, Dexterity, Constitution, Intelligence,
Wisdom, and Charisma. Learn more about an ability by clicking on
it.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--proficiency",
".ct-combat-tablet__extra--proficiency",
".ct-quick-info__box--proficiency"
),
content: (
<GuidedTourStep
title="Proficiency Bonus"
content={
<>
Proficiency is added to rolls made to accomplish tasks with which
your character is proficient. The bonus is automatically added to
rolls when it is needed. Click here to learn more about how
Proficiency Bonuses are used.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--speed",
".ct-combat-tablet__extra--speed",
".ct-quick-info__box--speed"
),
content: (
<GuidedTourStep
title="Speed"
content={
<>
In each round of combat, your character can move up to the total
distance indicated by your speed. Click here to learn more or
customize your speed.
</>
}
/>
),
},
{
selector: getSelector(
".ct-status-summary-mobile__health",
".ct-status-summary-mobile__health",
".ct-health-summary"
),
content: (
<GuidedTourStep
title="Hit Points"
content={
<>
In this area, you can manage your character's hit points. When
your character reaches zero hit points, they are on the brink of
death and begin making death saving throws.
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--initiative",
".ct-combat-tablet__extra--initiative",
".ct-combat__summary-group--initiative"
),
content: (
<GuidedTourStep
title="Initiative"
content={
<>
Initiative rolls determine the order in which you go in combat.
The higher the number, the sooner you get to fight. Click here to
roll initiative!
</>
}
/>
),
},
{
selector: getSelector(
".ct-combat-mobile__extra--ac",
".ct-combat-tablet__extra--ac",
".ct-combat__summary-group--ac"
),
content: (
<GuidedTourStep
title="Armor Class"
content={
<>
Your Armor Class (AC) represents how well your character avoids
being wounded in battle.
</>
}
/>
),
},
{
selector: getSelector(
".ct-main-mobile__saving-throws",
".ct-saving-throws-box",
".ct-saving-throws-box"
),
content: (
<GuidedTourStep
title="Saving Throws"
content={
<>
Your DM may ask you to roll a saving throw (or make a save) to
resist an incoming effect. Click on a save to automatically roll.
</>
}
/>
),
},
...(isDesktop || isTablet
? [
{
selector: ".ct-proficiency-groups-box",
content: (
<GuidedTourStep
title="Proficiencies"
content={
<>
Proficiencies tell you what tools, equipment, and languages
your character is skilled at using.
</>
}
/>
),
},
{
selector: ".ct-skills-box",
content: (
<GuidedTourStep
title="Skills"
content={
<>
If you wish to perform an action, your DM will determine
which of these skills you will use. Click on a skill to roll
or learn more.
</>
}
/>
),
},
]
: [
{
selector: ".ct-quick-nav__menu-item--skills",
content: (
<GuidedTourStep
title="Skills"
content={
<>
If you wish to perform an action, your DM will determine
which of these skills you will use. Click on a skill to roll
or learn more.
</>
}
/>
),
},
]),
...(!isDesktop
? [
{
selector: ".ct-quick-nav__menu-item--actions",
content: (
<GuidedTourStep
title="Actions"
content={
<>
When you take your action on your turn, you can take one of
the actions presented here. You can track actions or make
rolls to perform them by clicking in this panel.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--equipment",
content: (
<GuidedTourStep
title="Inventory"
content={
<>
View and manage your character's items and coin from this
panel.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--spells",
content: (
<GuidedTourStep
title="Spells"
content={
<>
If your character has the ability to cast spells, look here
for a list of spells and to track your spellcasting.
</>
}
/>
),
},
{
selector: ".ct-quick-nav__menu-item--features",
content: (
<GuidedTourStep
title="Features & Traits"
content={
<>
This section describes the source of your character's
abilities. How this manifests in your character's
personality is up to you.
</>
}
/>
),
},
...(!isTablet
? [
{
selector: ".ct-quick-nav__menu-item--proficiencies",
content: (
<GuidedTourStep
title="Proficiencies"
content={
<>
Proficiencies tell you what tools, equipment, and
languages your character is skilled at using.
</>
}
/>
),
},
]
: []),
{
selector: ".ct-quick-nav__menu-item--description",
content: (
<GuidedTourStep
title="Description"
content={
<>
Use the description panel to tell your character's story.
Your character's background features are also found here.
</>
}
showClose
/>
),
},
]
: [
{
selector: ".ct-primary-box__tab--actions",
content: (
<GuidedTourStep
title="Actions"
content={
<>
When you take your action on your turn, you can take one of
the actions presented here. You can track actions or make
rolls to perform them by clicking in this panel.
</>
}
/>
),
},
...(hasSpells
? [
{
selector: ".ct-primary-box__tab--spells",
content: (
<GuidedTourStep
title="Spells"
content={
<>
If your character has the ability to cast spells, look
here for a list of spells and to track your
spellcasting.
</>
}
/>
),
},
]
: []),
{
selector: ".ct-primary-box__tab--equipment",
content: (
<GuidedTourStep
title="Inventory"
content={
<>
View and manage your character's items and coin from this
panel.
</>
}
/>
),
},
{
selector: ".ct-primary-box__tab--features",
content: (
<GuidedTourStep
title="Features & Traits"
content={
<>
This section describes the source of your character's
abilities. How this manifests in your character's
personality is up to you.
</>
}
/>
),
},
{
selector: ".ct-primary-box__tab--description",
content: (
<GuidedTourStep
title="Description"
content={
<>
Use the description panel to tell your character's story.
Your character's background features are also found here.
</>
}
showClose
/>
),
},
]),
];
return stepList.filter((s) => s);
};
@@ -0,0 +1,4 @@
import CharacterSheetGuidedTour from "./CharacterSheetGuidedTour";
export default CharacterSheetGuidedTour;
export { CharacterSheetGuidedTour };
@@ -0,0 +1,317 @@
import { spring, TransitionMotion } from "@serprex/react-motion";
import React, { useContext } from "react";
import { connect } from "react-redux";
import {
ThemeStyles,
DarkAbilitiesSvg,
DarkActionsSvg,
DarkSkillsSvg,
DarkEquipmentSvg,
DarkSpellsSvg,
DarkProficienciesSvg,
DarkDescriptionSvg,
DarkNotesSvg,
DarkFeaturesSvg,
DarkExtrasSvg,
LightAbilitiesSvg,
LightActionsSvg,
LightSkillsSvg,
LightEquipmentSvg,
LightSpellsSvg,
LightProficienciesSvg,
LightDescriptionSvg,
LightNotesSvg,
LightFeaturesSvg,
LightExtrasSvg,
} from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
DecorationInfo,
DecorationUtils,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { SidebarInfo } from "~/contexts/Sidebar/Sidebar";
import { usePositioning } from "~/hooks/usePositioning";
import { Sidebar } from "~/subApps/sheet/components/Sidebar";
import { SidebarPositionInfo } from "~/subApps/sheet/components/Sidebar/types";
import { SheetContext } from "~/subApps/sheet/contexts/Sheet";
import { MobileSections } from "~/subApps/sheet/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import {
ComponentCarousel,
ComponentCarouselItem,
} from "../../components/ComponentCarousel";
import SectionPlaceholder from "../../components/SectionPlaceholder";
import { SheetAppState } from "../../typings";
import CharacterHeaderMobile from "../CharacterHeaderMobile";
import ActionsMobile from "../mobile/ActionsMobile";
import CombatMobile from "../mobile/CombatMobile";
import { DescriptionMobile } from "../mobile/DescriptionMobile";
import EquipmentMobile from "../mobile/EquipmentMobile";
import ExtrasMobile from "../mobile/ExtrasMobile";
import FeaturesMobile from "../mobile/FeaturesMobile";
import MainMobile from "../mobile/MainMobile";
import NotesMobile from "../mobile/NotesMobile";
import { ProficiencyGroupsMobile } from "../mobile/ProficiencyGroupsMobile";
import SkillsMobile from "../mobile/SkillsMobile";
import SpellsMobile from "../mobile/SpellsMobile";
interface Props {
sidebarInfo: SidebarInfo;
sidebarPosition: SidebarPositionInfo;
envDimensions: AppEnvDimensionsState;
decorationInfo: DecorationInfo;
hasSpells: boolean;
isReadonly: boolean;
mobileActiveSectionId: MobileSections;
setMobileActiveSectionId: (id: MobileSections) => void;
}
interface State {
swipedAmount: number;
}
class CharacterSheetMobile extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
swipedAmount: 0,
};
}
handleComponentChange = (id: MobileSections): void => {
const { setMobileActiveSectionId } = this.props;
setMobileActiveSectionId(id);
window.scrollTo(0, 0);
};
handleItemChange = (newKey: MobileSections, oldKey: MobileSections): void => {
if (newKey !== oldKey) {
window.scrollTo(0, 0);
}
const { setMobileActiveSectionId } = this.props;
setMobileActiveSectionId(newKey);
};
getPositionX = (swipedAmount: number): number => {
const { sidebarInfo } = this.props;
let position: number =
(sidebarInfo.isVisible ? 0 : sidebarInfo.width) + swipedAmount;
if (swipedAmount > 0) {
return Math.min(sidebarInfo.width, position);
} else {
return Math.max(0, position);
}
};
renderSections = (activeSectionId: MobileSections): React.ReactNode => {
const { envDimensions, hasSpells, isReadonly, decorationInfo } = this.props;
const isDarkMode = DecorationUtils.isDarkMode(decorationInfo);
return (
<ComponentCarousel
activeItemKey={activeSectionId}
onItemChange={this.handleItemChange}
envDimensions={envDimensions}
>
<ComponentCarouselItem
itemKey="main"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Abilities, Saves, Senses",
IconComponent: isDarkMode ? LightAbilitiesSvg : DarkAbilitiesSvg,
}}
ContentComponent={MainMobile}
/>
<ComponentCarouselItem
itemKey="skills"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Skills",
IconComponent: isDarkMode ? LightSkillsSvg : DarkSkillsSvg,
}}
ContentComponent={SkillsMobile}
/>
<ComponentCarouselItem
itemKey="actions"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Actions",
IconComponent: isDarkMode ? LightActionsSvg : DarkActionsSvg,
}}
ContentComponent={ActionsMobile}
/>
<ComponentCarouselItem
itemKey="equipment"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Inventory",
IconComponent: isDarkMode ? LightEquipmentSvg : DarkEquipmentSvg,
}}
ContentComponent={EquipmentMobile}
/>
<ComponentCarouselItem
itemKey="spells"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Spells",
IconComponent: isDarkMode ? LightSpellsSvg : DarkSpellsSvg,
}}
ContentComponent={SpellsMobile}
isEnabled={hasSpells}
/>
<ComponentCarouselItem
itemKey="features_traits"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Features & Traits",
IconComponent: isDarkMode ? LightFeaturesSvg : DarkFeaturesSvg,
}}
ContentComponent={FeaturesMobile}
/>
<ComponentCarouselItem
itemKey="proficiencies"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Proficiencies & Training",
IconComponent: isDarkMode
? LightProficienciesSvg
: DarkProficienciesSvg,
}}
ContentComponent={ProficiencyGroupsMobile}
/>
<ComponentCarouselItem
itemKey="description"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Background",
IconComponent: isDarkMode
? LightDescriptionSvg
: DarkDescriptionSvg,
}}
ContentComponent={DescriptionMobile}
isEnabled={!isReadonly}
/>
<ComponentCarouselItem
itemKey="notes"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Notes",
IconComponent: isDarkMode ? LightNotesSvg : DarkNotesSvg,
}}
ContentComponent={NotesMobile}
isEnabled={!isReadonly}
/>
<ComponentCarouselItem
itemKey="extras"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Extras",
IconComponent: isDarkMode ? LightExtrasSvg : DarkExtrasSvg,
}}
ContentComponent={ExtrasMobile}
isEnabled={!isReadonly}
/>
</ComponentCarousel>
);
};
renderSidebar = (): React.ReactNode => {
const { swipedAmount } = this.state;
const { sidebarPosition } = this.props;
return (
<TransitionMotion
styles={[
{
key: "1",
style: {
transform: spring(this.getPositionX(swipedAmount), {
stiffness: 400,
damping: 30,
}),
},
},
]}
>
{(interpolatedStyles) => (
<React.Fragment>
{interpolatedStyles.map((config) => (
<Sidebar
key={config.key}
style={{
...sidebarPosition,
transform: `translateX(${config.style.transform}px)`,
}}
setSwipedAmount={(swipedAmount) =>
this.setState({ swipedAmount })
}
/>
))}
</React.Fragment>
)}
</TransitionMotion>
);
};
render() {
const { decorationInfo } = this.props;
return (
<SheetContext.Consumer>
{({ mobileActiveSectionId }) => (
<div
className={`ct-character-sheet-mobile ${
DecorationUtils.isDarkMode(decorationInfo)
? "ct-character-sheet-mobile--dark-mode"
: ""
}`}
>
<div className="ct-character-sheet-mobile__header">
<CharacterHeaderMobile />
<CombatMobile />
</div>
{this.renderSections(mobileActiveSectionId)}
{this.renderSidebar()}
<ThemeStyles decorationInfo={decorationInfo} />
</div>
)}
</SheetContext.Consumer>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
hasSpells: rulesEngineSelectors.hasSpells(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
envDimensions: appEnvSelectors.getDimensions(state),
};
}
const CharacterSheetMobileContainer = (props) => {
const { mobileActiveSectionId, setMobileActiveSectionId } =
useContext(SheetContext);
const { sidebar } = useSidebar();
const { getSidebarPositioning } = usePositioning();
return (
<CharacterSheetMobile
mobileActiveSectionId={mobileActiveSectionId}
setMobileActiveSectionId={setMobileActiveSectionId}
sidebarInfo={sidebar}
sidebarPosition={getSidebarPositioning()}
{...props}
/>
);
};
export default connect(mapStateToProps)(CharacterSheetMobileContainer);
@@ -0,0 +1,4 @@
import CharacterSheetMobile from "./CharacterSheetMobile";
export default CharacterSheetMobile;
export { CharacterSheetMobile };
@@ -0,0 +1,288 @@
import { spring, TransitionMotion } from "@serprex/react-motion";
import React, { useContext } from "react";
import { connect } from "react-redux";
import {
ThemeStyles,
DarkAbilitiesSvg,
DarkActionsSvg,
DarkEquipmentSvg,
DarkSpellsSvg,
DarkDescriptionSvg,
DarkNotesSvg,
DarkFeaturesSvg,
DarkExtrasSvg,
LightAbilitiesSvg,
LightActionsSvg,
LightEquipmentSvg,
LightSpellsSvg,
LightDescriptionSvg,
LightNotesSvg,
LightFeaturesSvg,
LightExtrasSvg,
} from "@dndbeyond/character-components/es";
import {
DecorationUtils,
DecorationInfo,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { SidebarInfo } from "~/contexts/Sidebar/Sidebar";
import { usePositioning } from "~/hooks/usePositioning";
import { Sidebar } from "~/subApps/sheet/components/Sidebar";
import { SidebarPositionInfo } from "~/subApps/sheet/components/Sidebar/types";
import { SheetContext } from "~/subApps/sheet/contexts/Sheet";
import { MobileSections } from "~/subApps/sheet/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import {
ComponentCarousel,
ComponentCarouselItem,
} from "../../components/ComponentCarousel";
import SectionPlaceholder from "../../components/SectionPlaceholder";
import { SheetAppState } from "../../typings";
import BackdropStyles from "../BackdropStyles";
import CharacterHeaderTablet from "../CharacterHeaderTablet";
import ActionsTablet from "../tablet/ActionsTablet";
import CombatTablet from "../tablet/CombatTablet";
import { DescriptionTablet } from "../tablet/DescriptionTablet";
import EquipmentTablet from "../tablet/EquipmentTablet";
import ExtrasTablet from "../tablet/ExtrasTablet";
import FeaturesTablet from "../tablet/FeaturesTablet";
import MainTablet from "../tablet/MainTablet";
import NotesTablet from "../tablet/NotesTablet";
import SpellsTablet from "../tablet/SpellsTablet";
interface Props {
sidebarPosition: SidebarPositionInfo;
sidebarInfo: SidebarInfo;
envDimensions: AppEnvDimensionsState;
decorationInfo: DecorationInfo;
hasSpells: boolean;
isReadonly: boolean;
mobileActiveSectionId: MobileSections;
setMobileActiveSectionId: (id: MobileSections) => void;
}
interface State {
swipedAmount: number;
}
class CharacterSheetTablet extends React.PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
swipedAmount: 0,
};
}
handleComponentChange = (id: MobileSections): void => {
const { setMobileActiveSectionId } = this.props;
setMobileActiveSectionId(id);
window.scrollTo(0, 0);
};
handleItemChange = (newKey: MobileSections, oldKey: MobileSections): void => {
if (newKey !== oldKey) {
window.scrollTo(0, 0);
}
const { setMobileActiveSectionId } = this.props;
setMobileActiveSectionId(newKey);
};
getPositionX = (swipedAmount: number): number => {
const { sidebarInfo } = this.props;
let position: number =
(sidebarInfo.isVisible ? 0 : sidebarInfo.width) + swipedAmount;
if (swipedAmount > 0) {
return Math.min(sidebarInfo.width, position);
} else {
return Math.max(0, position);
}
};
renderSections = (activeSectionId: MobileSections): React.ReactNode => {
const { envDimensions, hasSpells, isReadonly, decorationInfo } = this.props;
const isDarkMode = DecorationUtils.isDarkMode(decorationInfo);
return (
<ComponentCarousel
activeItemKey={activeSectionId}
onItemChange={this.handleItemChange}
envDimensions={envDimensions}
changingWaitTime={750}
>
<ComponentCarouselItem
itemKey="main"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Abilities, Saves, Senses",
IconComponent: isDarkMode ? LightAbilitiesSvg : DarkAbilitiesSvg,
}}
ContentComponent={MainTablet}
/>
<ComponentCarouselItem
itemKey="actions"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Actions",
IconComponent: isDarkMode ? LightActionsSvg : DarkActionsSvg,
}}
ContentComponent={ActionsTablet}
/>
<ComponentCarouselItem
itemKey="equipment"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Equipment",
IconComponent: isDarkMode ? LightEquipmentSvg : DarkEquipmentSvg,
}}
ContentComponent={EquipmentTablet}
/>
<ComponentCarouselItem
itemKey="spells"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Spells",
IconComponent: isDarkMode ? LightSpellsSvg : DarkSpellsSvg,
}}
ContentComponent={SpellsTablet}
isEnabled={hasSpells}
/>
<ComponentCarouselItem
itemKey="features_traits"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Features & Traits",
IconComponent: isDarkMode ? LightFeaturesSvg : DarkFeaturesSvg,
}}
ContentComponent={FeaturesTablet}
/>
<ComponentCarouselItem
itemKey="description"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Description",
IconComponent: isDarkMode
? LightDescriptionSvg
: DarkDescriptionSvg,
}}
ContentComponent={DescriptionTablet}
isEnabled={!isReadonly}
/>
<ComponentCarouselItem
itemKey="notes"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Notes",
IconComponent: isDarkMode ? LightNotesSvg : DarkNotesSvg,
}}
ContentComponent={NotesTablet}
isEnabled={!isReadonly}
/>
<ComponentCarouselItem
itemKey="extras"
PlaceholderComponent={SectionPlaceholder}
placeholderProps={{
name: "Extras",
IconComponent: isDarkMode ? LightExtrasSvg : DarkExtrasSvg,
}}
ContentComponent={ExtrasTablet}
isEnabled={!isReadonly}
/>
</ComponentCarousel>
);
};
renderSidebar = (): React.ReactNode => {
const { swipedAmount } = this.state;
const { sidebarPosition } = this.props;
return (
<TransitionMotion
styles={[
{
key: "1",
style: {
transform: spring(this.getPositionX(swipedAmount), {
stiffness: 400,
damping: 30,
}),
},
},
]}
>
{(interpolatedStyles) => (
<React.Fragment>
{interpolatedStyles.map((config) => (
<Sidebar
key={config.key}
style={{
...sidebarPosition,
transform: `translateX(${config.style.transform}px)`,
}}
setSwipedAmount={(swipedAmount) =>
this.setState({ swipedAmount })
}
/>
))}
</React.Fragment>
)}
</TransitionMotion>
);
};
render() {
const { decorationInfo } = this.props;
return (
<SheetContext.Consumer>
{({ mobileActiveSectionId }) => (
<div className="ct-character-sheet-tablet">
<CharacterHeaderTablet />
<CombatTablet />
{this.renderSections(mobileActiveSectionId)}
{this.renderSidebar()}
<ThemeStyles decorationInfo={decorationInfo} />
<BackdropStyles
backdrop={DecorationUtils.getBackdropInfo(decorationInfo)}
/>
</div>
)}
</SheetContext.Consumer>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
decorationInfo: rulesEngineSelectors.getDecorationInfo(state),
hasSpells: rulesEngineSelectors.hasSpells(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
envDimensions: appEnvSelectors.getDimensions(state),
};
}
const CharacterSheetTabletContainer = (props) => {
const { mobileActiveSectionId, setMobileActiveSectionId } =
useContext(SheetContext);
const { sidebar } = useSidebar();
const { getSidebarPositioning } = usePositioning();
return (
<CharacterSheetTablet
mobileActiveSectionId={mobileActiveSectionId}
setMobileActiveSectionId={setMobileActiveSectionId}
sidebarInfo={sidebar}
sidebarPosition={getSidebarPositioning()}
{...props}
/>
);
};
export default connect(mapStateToProps)(CharacterSheetTabletContainer);
@@ -0,0 +1,4 @@
import CharacterSheetTablet from "./CharacterSheetTablet";
export default CharacterSheetTablet;
export { CharacterSheetTablet };
@@ -0,0 +1,204 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
ArmorClassBox,
BoxBackground,
SquaredBoxSvg344x95,
SquaredBoxSvg408x95,
} from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
AbilityLookup,
Condition,
DefenseAdjustmentGroup,
SpellCasterInfo,
RuleData,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { InitiativeBox } from "~/subApps/sheet/components/InitiativeBox";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import Subsection from "../../../Shared/components/Subsection";
import { StyleSizeTypeEnum } from "../../../Shared/reducers/appEnv";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import ConditionsSummary from "../../components/ConditionsSummary";
import DefensesSummary from "../../components/DefensesSummary";
import { SheetAppState } from "../../typings";
interface Props {
armorClass: number;
resistances: Array<DefenseAdjustmentGroup>;
immunities: Array<DefenseAdjustmentGroup>;
vulnerabilities: Array<DefenseAdjustmentGroup>;
conditions: Array<Condition>;
abilityLookup: AbilityLookup;
ruleData: RuleData;
spellCasterInfo: SpellCasterInfo;
dimensions: AppEnvDimensionsState;
theme: CharacterTheme;
diceEnabled: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class Combat extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handleArmorClassClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.ARMOR_MANAGE);
};
handleDefensesSummaryClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
this.handleDefensesSummaryShow();
};
handleDefensesSummaryShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.DEFENSE_MANAGE);
};
handleConditionSummaryClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
this.handleConditionSummaryShow();
};
handleConditionSummaryShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.CONDITION_MANAGE);
};
renderDefault = (): React.ReactNode => {
return (
<div className="ct-combat__attacks-default">
Equip weapons or add spells to see your attacks here.
</div>
);
};
render() {
const {
armorClass,
resistances,
vulnerabilities,
immunities,
conditions,
theme,
dimensions,
} = this.props;
let BoxBackgroundComponent: React.ComponentType = SquaredBoxSvg344x95;
if (dimensions.styleSizeType > StyleSizeTypeEnum.DESKTOP) {
BoxBackgroundComponent = SquaredBoxSvg408x95;
}
return (
<Subsection name="Combat">
<div className="ct-combat">
<div className="ct-combat__summary">
<div className="ct-combat__summary-group ct-combat__summary-group--initiative">
<InitiativeBox />
</div>
<div className="ct-combat__summary-group ct-combat__summary-group--ac">
<ArmorClassBox
theme={theme}
armorClass={armorClass}
onClick={this.handleArmorClassClick}
/>
</div>
<section className="ct-combat__summary-group ct-combat__summary-group--statuses">
<div className="ct-combat__statuses">
<BoxBackground
StyleComponent={BoxBackgroundComponent}
theme={theme}
/>
<h2 style={visuallyHidden}>Defenses and Conditions</h2>
<div
className="ct-combat__statuses-group ct-combat__statuses-group--defenses"
onClick={this.handleDefensesSummaryClick}
>
<div
className={`ct-combat__summary-label ${
theme.isDarkMode
? "ct-combat__summary-label--dark-mode"
: ""
}`}
>
Defenses
</div>
<DefensesSummary
theme={theme}
resistances={resistances}
vulnerabilities={vulnerabilities}
immunities={immunities}
onClick={this.handleDefensesSummaryShow}
/>
</div>
<div
className="ct-combat__statuses-group ct-combat__statuses-group--conditions"
style={
theme?.isDarkMode
? {
borderColor: `${theme.themeColor}66`,
}
: undefined
}
onClick={this.handleConditionSummaryClick}
>
<div
className={`ct-combat__summary-label ${
theme.isDarkMode
? "ct-combat__summary-label--dark-mode"
: ""
}`}
>
Conditions
</div>
<ConditionsSummary
theme={theme}
conditions={conditions}
onClick={this.handleConditionSummaryShow}
/>
</div>
</div>
</section>
</div>
</div>
</Subsection>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
armorClass: rulesEngineSelectors.getAcTotal(state),
resistances: rulesEngineSelectors.getActiveGroupedResistances(state),
immunities: rulesEngineSelectors.getActiveGroupedImmunities(state),
vulnerabilities:
rulesEngineSelectors.getActiveGroupedVulnerabilities(state),
conditions: rulesEngineSelectors.getActiveConditions(state),
ruleData: rulesEngineSelectors.getRuleData(state),
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
dimensions: appEnvSelectors.getDimensions(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
};
}
const CombatContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <Combat paneHistoryStart={paneHistoryStart} {...props} />;
};
export default connect(mapStateToProps)(CombatContainer);
@@ -0,0 +1,4 @@
import Combat from "./Combat";
export default Combat;
export { Combat };
@@ -0,0 +1,381 @@
import { visuallyHidden } from "@mui/utils";
import clsx from "clsx";
import React, { useContext } from "react";
import { connect, DispatchProp } from "react-redux";
import {
AlignmentContract,
Background,
CharacterTraits,
Constants,
SizeContract,
rulesEngineSelectors,
CharacterTheme,
CharacterFeaturesManager,
SnippetData,
RuleData,
AbilityLookup,
DataOriginRefData,
FeatManager,
Action,
ActionUtils,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { InfoItem } from "~/components/InfoItem";
import { NumberDisplay } from "~/components/NumberDisplay";
import { TabFilter } from "~/components/TabFilter";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import {
handleActionUseSet,
handleSpellUseSet,
} from "../../../../../handlers/commonHandlers";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import BackgroundDetail from "../../components/BackgroundDetail";
import ContentGroup from "../../components/ContentGroup";
import TraitContent from "../../components/TraitContent";
import { SheetAppState } from "../../typings";
import styles from "./styles.module.css";
const DEFAULT_VALUE = "--";
interface Props extends DispatchProp {
isVertical: boolean;
background: Background | null;
alignment: AlignmentContract | null;
height: string | null;
weight: number | null;
size: SizeContract | null;
faith: string | null;
skin: string | null;
eyes: string | null;
hair: string | null;
age: number | null;
gender: string | null;
traits: CharacterTraits;
isReadonly: boolean;
theme: CharacterTheme;
characterFeaturesManager: CharacterFeaturesManager;
snippetData: SnippetData;
ruleData: RuleData;
abilityLookup: AbilityLookup;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
paneContext: PaneInfo;
}
class Description extends React.PureComponent<Props> {
static defaultProps = {
isVertical: false,
};
handlePhysicalCharacteristicsClick = (): void => {
const {
paneContext: { paneHistoryStart },
isReadonly,
} = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.DESCRIPTION);
}
};
handleBackgroundClick = (): void => {
const {
paneContext: { paneHistoryStart },
} = this.props;
paneHistoryStart(PaneComponentEnum.BACKGROUND);
};
handleFeatClick = (feat: FeatManager): void => {
const {
paneContext: { paneHistoryStart },
} = this.props;
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
handleTraitShow = (key: Constants.TraitTypeEnum): void => {
const {
paneContext: { paneHistoryStart },
isReadonly,
} = this.props;
if (!isReadonly) {
paneHistoryStart(
PaneComponentEnum.TRAIT,
PaneIdentifierUtils.generateTrait(key)
);
}
};
handleActionClick = (action: Action): void => {
const {
paneContext: { paneHistoryStart },
} = this.props;
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryStart(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
handleSpellDetailClick = (spell: Spell): void => {
const {
paneContext: { paneHistoryStart },
} = this.props;
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryStart(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
handleClick = (e: React.MouseEvent, onClick: Function): void => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClick();
};
renderDescriptionItem = (
value: number | string | null,
fallback: string = DEFAULT_VALUE
): React.ReactNode => {
return value === null ? fallback : value;
};
render() {
const {
dispatch,
background,
characterFeaturesManager,
snippetData,
ruleData,
abilityLookup,
dataOriginRefData,
isReadonly,
proficiencyBonus,
theme,
isVertical,
size,
height,
weight,
alignment,
gender,
eyes,
hair,
skin,
age,
faith,
traits,
} = this.props;
const infoItemProps = {
role: "listitem",
inline: isVertical,
};
return (
<section className="ct-description">
<h2 style={visuallyHidden}>Description</h2>
<TabFilter
filters={[
{
label: "Background",
content: (
<ContentGroup header="Background">
<BackgroundDetail
background={background}
onClick={this.handleBackgroundClick}
featuresManager={characterFeaturesManager}
onActionUseSet={(action, uses) =>
handleActionUseSet(action, uses, dispatch)
}
onSpellUseSet={(spell, uses) =>
handleSpellUseSet(spell, uses, dispatch)
}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
</ContentGroup>
),
},
{
label: "Characteristics",
content: (
<>
<ContentGroup header="Characteristics">
<div
className={clsx([
"ct-description__physical",
styles.physical,
])}
role="list"
onClick={(e) =>
this.handleClick(
e,
this.handlePhysicalCharacteristicsClick
)
}
>
<InfoItem label="Alignment" {...infoItemProps}>
{alignment === null ? DEFAULT_VALUE : alignment.name}
</InfoItem>
<InfoItem label="Gender" {...infoItemProps}>
{this.renderDescriptionItem(gender)}
</InfoItem>
<InfoItem label="Eyes" {...infoItemProps}>
{this.renderDescriptionItem(eyes)}
</InfoItem>
<InfoItem label="Size" {...infoItemProps}>
{this.renderDescriptionItem(size ? size.name : null)}
</InfoItem>
<InfoItem label="Height" {...infoItemProps}>
{this.renderDescriptionItem(height)}
</InfoItem>
<InfoItem label="Faith" {...infoItemProps}>
{this.renderDescriptionItem(faith)}
</InfoItem>
<InfoItem label="Hair" {...infoItemProps}>
{this.renderDescriptionItem(hair)}
</InfoItem>
<InfoItem label="Skin" {...infoItemProps}>
{this.renderDescriptionItem(skin)}
</InfoItem>
<InfoItem label="Age" {...infoItemProps}>
{this.renderDescriptionItem(age)}
</InfoItem>
<InfoItem label="Weight" {...infoItemProps}>
{weight === null ? (
DEFAULT_VALUE
) : (
<NumberDisplay type="weightInLb" number={weight} />
)}
</InfoItem>
</div>
</ContentGroup>
<div
className="ct-description__traits"
style={
theme.isDarkMode
? { borderColor: `${theme.themeColor}66` }
: undefined
}
>
<TraitContent
traits={traits}
traitKey={Constants.TraitTypeEnum.PERSONALITY_TRAITS}
label="Personality Traits"
fallback="+ Add Personality Traits"
onClick={this.handleTraitShow}
/>
<TraitContent
traits={traits}
traitKey={Constants.TraitTypeEnum.IDEALS}
label="Ideals"
fallback="+ Add Ideals"
onClick={this.handleTraitShow}
/>
<TraitContent
traits={traits}
traitKey={Constants.TraitTypeEnum.BONDS}
label="Bonds"
fallback="+ Add Bonds"
onClick={this.handleTraitShow}
/>
<TraitContent
traits={traits}
traitKey={Constants.TraitTypeEnum.FLAWS}
label="Flaws"
fallback="+ Add Flaws"
onClick={this.handleTraitShow}
/>
</div>
</>
),
},
...(traits !== null
? [
{
label: "Appearance",
content: (
<ContentGroup header="Appearance">
<TraitContent
traits={traits}
traitKey={Constants.TraitTypeEnum.APPEARANCE}
fallback="+ Add Appearance information"
onClick={this.handleTraitShow}
/>
</ContentGroup>
),
},
]
: []),
]}
/>
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
background: rulesEngineSelectors.getBackgroundInfo(state),
alignment: rulesEngineSelectors.getAlignment(state),
height: rulesEngineSelectors.getHeight(state),
weight: rulesEngineSelectors.getWeight(state),
size: rulesEngineSelectors.getSize(state),
faith: rulesEngineSelectors.getFaith(state),
skin: rulesEngineSelectors.getSkin(state),
eyes: rulesEngineSelectors.getEyes(state),
hair: rulesEngineSelectors.getHair(state),
age: rulesEngineSelectors.getAge(state),
gender: rulesEngineSelectors.getGender(state),
traits: rulesEngineSelectors.getCharacterTraits(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
snippetData: rulesEngineSelectors.getSnippetData(state),
ruleData: rulesEngineSelectors.getRuleData(state),
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
};
}
const DescriptionWrapper = (props) => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const { pane } = useSidebar();
return (
<Description
paneContext={pane}
characterFeaturesManager={characterFeaturesManager}
{...props}
/>
);
};
export default connect(mapStateToProps)(DescriptionWrapper);
@@ -0,0 +1,4 @@
import Description from "./Description";
export default Description;
export { Description };
@@ -0,0 +1,883 @@
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Stack from "@mui/material/Stack";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import { visuallyHidden } from "@mui/utils";
import { useContext, ReactNode, MouseEvent, PureComponent } from "react";
import React from "react";
import { connect } from "react-redux";
import {
DarkBuilderSvg,
FeatureFlagContext,
} from "@dndbeyond/character-components/es";
import {
CharacterCurrencyContract,
CharacterNotes,
CharacterTheme,
Constants,
ContainerManager,
Creature,
CreatureUtils,
InfusionChoice,
InfusionChoiceUtils,
Item,
ItemUtils,
SnippetData,
RuleData,
rulesEngineSelectors,
characterSelectors,
BaseCharClass,
ContainerUtils,
InventoryManager,
FormatUtils,
CampaignUtils,
serviceDataSelectors,
PartyInfo,
CoinManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { Link } from "~/components/Link";
import { NumberDisplay } from "~/components/NumberDisplay";
import { TabFilter } from "~/components/TabFilter";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import CurrencyButton from "../../../CharacterSheet/components/CurrencyButton";
import { ItemSlotManager } from "../../../Shared/components/ItemSlotManager";
import { ThemeButton } from "../../../Shared/components/common/Button";
import { CoinManagerContext } from "../../../Shared/managers/CoinManagerContext";
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ContentGroup from "../../components/ContentGroup";
import EquipmentOverview from "../../components/EquipmentOverview";
import Infusions from "../../components/Infusions";
import InventoryFilter from "../../components/InventoryFilter";
import InventoryTableHeader from "../../components/InventoryTableHeader";
import OtherPossessions from "../../components/OtherPossessions";
import { sheetAppSelectors } from "../../selectors";
import { SheetAppState } from "../../typings";
import Attunement from "../Attunement";
import Inventory from "../Inventory";
interface Props {
showNotes: boolean;
builderUrl: string;
inventory: Array<Item>;
partyInventory: Array<Item>;
creatures: Array<Creature>;
currencies: CharacterCurrencyContract | null;
notes: CharacterNotes;
infusionChoices: Array<InfusionChoice>;
weight: number;
weightSpeedType: Constants.WeightSpeedTypeEnum;
ruleData: RuleData;
snippetData: SnippetData;
isReadonly: boolean;
isMobile: boolean;
theme: CharacterTheme;
proficiencyBonus: number;
classes: Array<BaseCharClass>;
inventoryManager: InventoryManager;
campaignInfo: PartyInfo | null;
coinManager: CoinManager;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
interface StateFilterData {
filteredInventory: Array<Item>;
filteredPartyInventory: Array<Item>;
showAdvancedFilters: boolean;
isFiltering: boolean;
}
interface State {
infusableChoices: Array<InfusionChoice>;
filterData: StateFilterData;
filteredEquippedInventory: Array<Item>;
shouldShowPartyInventory: number;
shouldShowDeleteOnlyInfo: boolean;
}
class Equipment extends PureComponent<Props, State> {
static defaultProps = {
showNotes: true,
};
constructor(props: Props) {
super(props);
this.state = {
infusableChoices: props.infusionChoices.filter((infusionChoice) =>
InfusionChoiceUtils.canInfuse(infusionChoice)
),
filterData: {
filteredInventory: [],
filteredPartyInventory: [],
showAdvancedFilters: false,
isFiltering: false,
},
filteredEquippedInventory: [],
shouldShowPartyInventory: 0, // mui used 0,1,2 for tabs so 0 is off 1 is on...
shouldShowDeleteOnlyInfo: false,
};
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.infusionChoices !== this.props.infusionChoices) {
this.setState({
infusableChoices: this.props.infusionChoices.filter((infusionChoice) =>
InfusionChoiceUtils.canInfuse(infusionChoice)
),
});
}
}
handleCurrencyClick = (
evt: React.MouseEvent | React.KeyboardEvent,
containerDefinitionKey: string
): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(
PaneComponentEnum.CURRENCY,
PaneIdentifierUtils.generateCurrencyContext(containerDefinitionKey)
);
};
handleWeightClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.ENCUMBRANCE);
};
handleCampaignClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
};
handlePossessionsManage = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(
PaneComponentEnum.NOTE_MANAGE,
PaneIdentifierUtils.generateNote(
Constants.NoteKeyEnum.PERSONAL_POSSESSIONS
)
);
}
};
handleManageClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE);
};
handleFilterUpdate = (filterData: StateFilterData): void => {
this.setState({
filterData,
filteredEquippedInventory: [
...filterData.filteredInventory.filter(ItemUtils.isEquipped),
...filterData.filteredPartyInventory.filter(ItemUtils.isEquipped),
],
});
};
handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const { paneHistoryStart } = this.props;
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
handleItemShow = (item: Item): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
handleContainerShow = (container: ContainerManager): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CONTAINER,
PaneIdentifierUtils.generateContainer(container.getDefinitionKey())
);
};
handleCreatureShow = (creature: Creature): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CREATURE,
PaneIdentifierUtils.generateCreature(CreatureUtils.getMappingId(creature))
);
};
shouldRenderInfusions = (): boolean => {
const { infusionChoices } = this.props;
return infusionChoices.length > 0;
};
renderContainer = (
container: ContainerManager,
inventory: Array<Item>
): ReactNode => {
const { shouldShowPartyInventory } = this.state;
const { isReadonly, showNotes, theme, coinManager } = this.props;
const containerMappingId = container.getMappingId();
const containerName = container.getName();
const weightInfo = container.getWeightInfo();
const containerInventory = container.getInventoryItems({
filteredInventory: inventory,
}).items;
const containerItem = container.getContainerItem();
const quantityNode = (
<span className="ct-equipment__container-quantity">{`(${containerInventory.length})`}</span>
);
const nameNode: ReactNode = containerItem ? (
<>
<ItemName item={containerItem.item} /> {quantityNode}
</>
) : (
<>
{containerName} {quantityNode}
</>
);
const headerNode: ReactNode = (
<div
className="ct-equipment__container ct-inventory-item"
onClick={(evt) => {
evt.nativeEvent.stopImmediatePropagation();
this.handleContainerShow(container);
}}
role="button"
data-testid={`${containerItem?.getName() || containerName}-section`
.toLowerCase()
.replace(/\s/g, "-")}
>
<div className="ct-equipment__container-action ct-inventory-item__action">
{containerItem && (
<ItemSlotManager
isUsed={!!containerItem.isEquipped()}
isReadonly={isReadonly}
canUse={
!(
containerItem.isEquipped() &&
!containerItem.isEquippedToCurrentCharacter()
)
}
onSet={(uses) => {
if (uses === 0) {
containerItem.handleUnequip();
}
if (uses === 1) {
containerItem.handleEquip();
}
}}
theme={theme}
useTooltip={false}
/>
)}
</div>
<div className="ct-equipment__container-name ct-inventory-item__name">
{nameNode}
</div>
<div className="ct-equipment__container-weight ct-inventory-item__weight">
<NumberDisplay type="weightInLb" number={weightInfo.applied} />
{weightInfo.capacity > 0 && (
<span className="ct-equipment__container-weight-capacity">
({Math.ceil(weightInfo.total)}/
{FormatUtils.renderWeight(weightInfo.capacity)})
</span>
)}
</div>
{coinManager.canUseCointainers() ? (
<div className="ct-equipment__container-coin">
<CurrencyButton
coin={ContainerUtils.getCoin(container.container)}
isDarkMode={theme.isDarkMode}
shouldShowPartyInventory={shouldShowPartyInventory === 1}
handleCurrencyClick={(evt) =>
this.handleCurrencyClick(
evt,
ContainerUtils.getDefinitionKey(container.container)
)
}
/>
</div>
) : (
containerItem &&
container.isEquipped() &&
!containerItem.isEquippedToCurrentCharacter() && (
<div className="ct-equipment__container-equipped">
Equipped by {containerItem.getEquippedCharacterName()}
</div>
)
)}
</div>
);
return (
<ContentGroup key={containerMappingId} header={headerNode}>
<Inventory
container={container}
inventory={containerInventory}
showNotes={showNotes}
showTableHeader={false}
isReadonly={isReadonly}
theme={theme}
/>
</ContentGroup>
);
};
renderCharacterContainers = (): ReactNode => {
const { filterData } = this.state;
const { inventoryManager } = this.props;
return inventoryManager
.getCharacterContainers()
.map((container) =>
this.renderContainer(container, filterData.filteredInventory)
);
};
renderPartyContainers = (): ReactNode => {
const { filterData } = this.state;
const { inventoryManager } = this.props;
return inventoryManager
.getPartyContainers()
.map((container) =>
this.renderContainer(container, filterData.filteredPartyInventory)
);
};
renderAttunement = (): ReactNode => {
const { isMobile, theme } = this.props;
return (
<ContentGroup header="Attunement">
<Attunement isMobile={isMobile} />
</ContentGroup>
);
};
renderInfusions = (): ReactNode => {
const { infusableChoices } = this.state;
const {
builderUrl,
infusionChoices,
isReadonly,
snippetData,
inventory,
partyInventory,
creatures,
ruleData,
proficiencyBonus,
theme,
} = this.props;
const headerNode: ReactNode = (
<>
Infusions{" "}
{infusableChoices.length !== infusionChoices.length && (
<>
({infusableChoices.length}/{infusionChoices.length} Known Infusions)
</>
)}
</>
);
let extraNode: ReactNode = null;
if (!isReadonly) {
extraNode = (
<Link
href={builderUrl}
className="ct-equipment__builder-link"
useRouter
>
<DarkBuilderSvg />
<span className="ct-equipment__builder-link-text">
Manage Infusions
</span>
</Link>
);
}
return (
<ContentGroup header={headerNode} extra={extraNode}>
<Infusions
theme={theme}
infusionChoices={infusableChoices}
inventory={[...inventory, ...partyInventory]}
creatures={creatures}
snippetData={snippetData}
onCreatureShow={this.handleCreatureShow}
onItemShow={this.handleItemShow}
onInfusionChoiceShow={this.handleInfusionChoiceShow}
ruleData={ruleData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
/>
</ContentGroup>
);
};
renderOtherPossessions = (): ReactNode => {
const { notes, isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ContentGroup header="Other Possessions">
<OtherPossessions
notes={notes}
onClick={this.handlePossessionsManage}
/>
</ContentGroup>
);
};
renderContent = (): ReactNode => {
const {
isReadonly,
inventoryManager,
inventory,
partyInventory,
showNotes,
} = this.props;
const { shouldShowPartyInventory } = this.state;
const headerNode: ReactNode = (
<div
className="ct-equipment__container"
onClick={(evt) => {
evt.nativeEvent.stopImmediatePropagation();
this.handleManageClick();
}}
>
Party Inventory
</div>
);
return (
<div className="ct-equipment__content">
<TabFilter
sharedChildren={<InventoryTableHeader showNotes={showNotes} />}
filters={[
{
label: "All",
content: (
<>
{shouldShowPartyInventory === 0 ? (
<>
{this.renderCharacterContainers()}
{this.renderAttunement()}
{this.shouldRenderInfusions() && this.renderInfusions()}
{this.renderOtherPossessions()}
</>
) : null}
<FeatureFlagContext.Consumer>
{({ imsFlag }) => {
return imsFlag &&
shouldShowPartyInventory === 1 &&
inventoryManager.getPartyContainers().length > 0 ? (
<ContentGroup header={headerNode}>
{this.renderPartyContainers()}
</ContentGroup>
) : null;
}}
</FeatureFlagContext.Consumer>
</>
),
},
...(shouldShowPartyInventory === 0
? inventoryManager
.getCharacterContainers()
.map((characterContainer) => ({
label: characterContainer.getName(),
content: this.renderContainer(
characterContainer,
inventory
),
}))
: []),
...(shouldShowPartyInventory === 1
? inventoryManager.getPartyContainers().map((partyContainer) => ({
label: partyContainer.getName(),
content: this.renderContainer(partyContainer, partyInventory),
}))
: []),
...(shouldShowPartyInventory === 0
? [
{
label: "Attunement",
content: this.renderAttunement(),
},
...(this.shouldRenderInfusions()
? [
{
label: "Infusions",
content: this.renderInfusions(),
},
]
: []),
...(!isReadonly
? [
{
label: "Other Possessions",
content: this.renderOtherPossessions(),
},
]
: []),
]
: []),
]}
showAllTab={false}
/>
</div>
);
};
renderManageButton = (): ReactNode => {
const { isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ThemeButton
style="outline"
size="medium"
onClick={this.handleManageClick}
data-testid="open-manage-equipment"
>
Manage Inventory
</ThemeButton>
);
};
toggleShouldShowPartyInventory = (event, newValue): void => {
this.setState({
shouldShowPartyInventory: newValue,
});
};
handleCloseDeleteOnlyInfo = (event: MouseEvent): void => {
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
this.setState({
shouldShowDeleteOnlyInfo: false,
});
};
handleOpenDeleteOnlyInfo = (event: MouseEvent): void => {
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
this.setState({
shouldShowDeleteOnlyInfo: true,
});
};
renderDeleteOnlyAlertAction = (): ReactNode => {
const { shouldShowDeleteOnlyInfo } = this.state;
const { campaignInfo } = this.props;
return (
<div>
<ThemeButton
style="outline"
size="medium"
onClick={this.handleOpenDeleteOnlyInfo}
>
More Info
</ThemeButton>
<Dialog
open={shouldShowDeleteOnlyInfo}
onClose={this.handleCloseDeleteOnlyInfo}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Delete Only</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Party Inventory sharing isnt currently enabled for this campaign.
Your old items are still here, but you wont be able to add new
items until its turned on again.
</DialogContentText>
</DialogContent>
<DialogActions>
{campaignInfo ? (
<Button
variant="outlined"
color="primary"
component="a"
href={CampaignUtils.getLink(campaignInfo) || "/my-campaigns"}
size="small"
>
go to campaign
</Button>
) : null}
<Button
variant="outlined"
color="primary"
onClick={this.handleCloseDeleteOnlyInfo}
autoFocus
size="small"
>
Close
</Button>
</DialogActions>
</Dialog>
</div>
);
};
renderOverviewFiltersAndContent = (): ReactNode => {
const { filterData, shouldShowPartyInventory } = this.state;
const {
currencies,
weight,
weightSpeedType,
inventory,
ruleData,
theme,
partyInventory,
campaignInfo,
inventoryManager,
coinManager,
} = this.props;
const cointainerFlagEnabled: boolean = coinManager.canUseCointainers();
let coin: CharacterCurrencyContract | null = currencies;
if (shouldShowPartyInventory === 1 && campaignInfo) {
// We need all the party coin if flag is on, or just Party Equipment coin if not
if (cointainerFlagEnabled) {
coin = coinManager.getAllPartyCoin();
} else {
coin = CampaignUtils.getCoin(campaignInfo);
}
} else if (cointainerFlagEnabled) {
// We need to change it to all container currencies
coin = coinManager.getAllCharacterCoin();
}
return (
<>
<div className="ct-equipment__overview">
<EquipmentOverview
shouldShowPartyInventory={shouldShowPartyInventory === 1}
currencies={coin}
weight={weight}
weightSpeedType={weightSpeedType}
onCurrencyClick={(evt) =>
this.handleCurrencyClick(
evt,
shouldShowPartyInventory === 1
? inventoryManager.getPartyEquipmentContainerDefinitionKey() ??
inventoryManager.getCharacterContainerDefinitionKey()
: inventoryManager.getCharacterContainerDefinitionKey()
)
}
onWeightClick={this.handleWeightClick}
onCampaignClick={this.handleCampaignClick}
enableManage={false}
isDarkMode={theme.isDarkMode}
campaignName={
campaignInfo !== null ? CampaignUtils.getName(campaignInfo) : null
}
/>
</div>
<div className="ct-equipment__filter">
<InventoryFilter
partyInventory={partyInventory}
inventory={inventory}
ruleData={ruleData}
onDataUpdate={this.handleFilterUpdate}
callout={this.renderManageButton()}
theme={theme}
/>
</div>
{!filterData.showAdvancedFilters && this.renderContent()}
</>
);
};
renderOptinToInventorySharing = (): ReactNode => {
const { campaignInfo } = this.props;
return (
<>
<Stack justifyContent="center">
<Stack marginBottom="10px" marginTop="40px">
<Typography color="primary" align="center" variant="body2">
Want a hand with that loot?
</Typography>
</Stack>
<Stack marginBottom="30px" alignItems={"center"}>
<Typography
align="center"
variant="body1"
sx={{ maxWidth: "320px" }}
>
Party Inventory makes it easy for your group to keep track of all
their shared loot and coin. Add it to your campaign today with a
subscription to D&D Beyond!
</Typography>
</Stack>
<Stack
spacing={2}
direction={{ xs: "column", sm: "row" }}
justifyContent="center"
>
{campaignInfo ? (
<Button
variant="contained"
color="primary"
component="a"
href={CampaignUtils.getLink(campaignInfo) || "/my-campaigns"}
size="large"
sx={{
marginLeft: { xs: 0 },
marginRight: { xs: 0 },
}}
>
go to campaign
</Button>
) : null}
<Button variant="contained" color="primary" size="large">
view subscription plans
</Button>
</Stack>
<Stack
style={{ display: "flex", justifyContent: "center", marginTop: 51 }}
>
<div className="ct-item-detail__full-image">
<img
className="ct-item-detail__full-image-img"
src="https://www.dndbeyond.com/avatars/thumbnails/7/120/315/315/636284708068284913.jpeg"
alt=""
/>
</div>
</Stack>
</Stack>
</>
);
};
render() {
const { shouldShowPartyInventory } = this.state;
const { campaignInfo, inventoryManager } = this.props;
return (
<section className="ct-equipment">
<h2 style={visuallyHidden}>Inventory</h2>
<FeatureFlagContext.Consumer>
{({ imsFlag }) => {
return imsFlag && campaignInfo !== null ? (
<>
<Tabs
value={shouldShowPartyInventory}
indicatorColor="primary"
textColor="primary"
onChange={this.toggleShouldShowPartyInventory}
aria-label="disabled tabs example"
variant="fullWidth"
>
<Tab label="My Inventory" data-testid="my-inventory" />
<Tab label="Party Inventory" data-testid="party-inventory" />
</Tabs>
{/* TODO: make this go away when last itme is removed... */}
{shouldShowPartyInventory &&
inventoryManager.isSharingTurnedDeleteOnly() ? (
<div role="button" onMouseUp={this.handleOpenDeleteOnlyInfo}>
<Alert
severity="info"
action={this.renderDeleteOnlyAlertAction()}
>
Party Inventory is currently turned off for this campaign.
</Alert>
</div>
) : null}
{shouldShowPartyInventory &&
inventoryManager.isSharingTurnedOff()
? this.renderOptinToInventorySharing()
: this.renderOverviewFiltersAndContent()}
</>
) : (
this.renderOverviewFiltersAndContent()
);
}}
</FeatureFlagContext.Consumer>
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
builderUrl: sheetAppSelectors.getBuilderUrl(state),
inventory: rulesEngineSelectors.getInventory(state),
partyInventory: rulesEngineSelectors.getPartyInventory(state),
creatures: rulesEngineSelectors.getCreatures(state),
weight: rulesEngineSelectors.getTotalCarriedWeight(state),
weightSpeedType: rulesEngineSelectors.getCurrentCarriedWeightType(state),
notes: rulesEngineSelectors.getCharacterNotes(state),
infusionChoices: rulesEngineSelectors.getAvailableInfusionChoices(state),
currencies: rulesEngineSelectors.getCurrencies(state),
ruleData: rulesEngineSelectors.getRuleData(state),
snippetData: rulesEngineSelectors.getSnippetData(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
isMobile: appEnvSelectors.getIsMobile(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
classes: characterSelectors.getClasses(state),
campaignInfo: serviceDataSelectors.getPartyInfo(state),
};
}
const EquipmentContainer = (props) => {
const { inventoryManager } = useContext(InventoryManagerContext);
const { coinManager } = useContext(CoinManagerContext);
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<Equipment
inventoryManager={inventoryManager}
coinManager={coinManager}
paneHistoryStart={paneHistoryStart}
{...props}
/>
);
};
export default connect(mapStateToProps)(EquipmentContainer);
@@ -0,0 +1,4 @@
import Equipment from "./Equipment";
export default Equipment;
export { Equipment };
@@ -0,0 +1,270 @@
import { visuallyHidden } from "@mui/utils";
import React, { useContext } from "react";
import { connect, DispatchProp } from "react-redux";
import {
rulesEngineSelectors,
CharacterTheme,
ExtrasFilterData,
ExtraGroupInfo,
Constants,
ExtrasManager,
ExtraManager,
} from "@dndbeyond/character-rules-engine/es";
import { TabFilter } from "~/components/TabFilter";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { useExtras } from "~/hooks/useExtras";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import ExtraList from "../../../Shared/components/ExtraList";
import { ThemeButton } from "../../../Shared/components/common/Button";
import { ExtrasManagerContext } from "../../../Shared/managers/ExtrasManagerContext";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ContentGroup from "../../components/ContentGroup";
import ExtrasFilter from "../../components/ExtrasFilter";
import { SheetAppState } from "../../typings";
interface Props extends DispatchProp {
extras: Array<ExtraManager>;
extrasManager: ExtrasManager;
isReadonly: boolean;
showNotes: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
interface State {
filterData: ExtrasFilterData;
}
class Extras extends React.PureComponent<Props, State> {
static defaultProps = {
isReadonly: false,
showNotes: true,
};
constructor(props: Props) {
super(props);
this.state = {
filterData: {
filteredExtras: [],
showAdvancedFilters: false,
isFiltering: false,
filterCount: 0,
},
};
}
handleFilterUpdate = (filterData: ExtrasFilterData): void => {
this.setState({
filterData,
});
};
handleManageClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
};
handleExtraShow = (extra: ExtraManager): void => {
const { paneHistoryStart } = this.props;
if (extra.isCreature()) {
paneHistoryStart(
PaneComponentEnum.CREATURE,
PaneIdentifierUtils.generateCreature(extra.getMappingId())
);
} else if (extra.isVehicle()) {
paneHistoryStart(
PaneComponentEnum.VEHICLE,
PaneIdentifierUtils.generateVehicle(extra.getMappingId())
);
}
};
handleExtraStatusChange = (extra: ExtraManager, isActive: boolean): void => {
extra.handleSetActive({ isActive });
};
renderManageButton = (): React.ReactNode => {
const { isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ThemeButton
style="outline"
size="medium"
onClick={this.handleManageClick}
>
Manage Extras
</ThemeButton>
);
};
renderDefault = (): React.ReactNode => {
return (
<div className="ct-extras__empty">
Extras that you add will display here.
</div>
);
};
renderNoResults = (): React.ReactNode => {
return (
<div className="ct-extras__empty">No Extras Match the Current Filter</div>
);
};
renderContent = (): React.ReactNode => {
const { filterData } = this.state;
const { extras, extrasManager, showNotes, isReadonly, theme } = this.props;
if (!extras.length) {
return this.renderDefault();
}
if (filterData.showAdvancedFilters) {
return null;
}
if (!filterData.filteredExtras.length) {
return this.renderNoResults();
}
let groups = extrasManager.getExtrasGroups(filterData.filteredExtras);
const orderedGroupInfos = extrasManager.getGroupInfosForExtras(
filterData.filteredExtras
);
let primaryGroups: Array<ExtraGroupInfo> = [];
let otherGroups: Array<ExtraGroupInfo> = [];
let vehicleGroups: Array<ExtraGroupInfo> = [];
orderedGroupInfos.forEach((groupInfo) => {
if (groupInfo) {
if (groupInfo.isPrimary) {
primaryGroups.push(groupInfo);
} else if (groupInfo.id === Constants.ExtraGroupTypeEnum.VEHICLE) {
vehicleGroups.push(groupInfo);
} else {
otherGroups.push(groupInfo);
}
}
});
return (
<div className="ct-extras__content">
<TabFilter
filters={[
...primaryGroups.map((groupInfo) => ({
label: groupInfo.name || "",
content: (
<ContentGroup header={groupInfo.name}>
<ExtraList
theme={theme}
extras={groups[groupInfo.id]}
showNotes={showNotes}
onShow={this.handleExtraShow}
onStatusChange={this.handleExtraStatusChange}
isReadonly={isReadonly}
/>
</ContentGroup>
),
})),
...(otherGroups.length > 0
? [
{
label: "Other",
content: otherGroups.map((groupInfo) => (
<ContentGroup key={groupInfo.id} header={groupInfo.name}>
<ExtraList
theme={theme}
extras={groups[groupInfo.id]}
showNotes={showNotes}
onShow={this.handleExtraShow}
onStatusChange={this.handleExtraStatusChange}
isReadonly={isReadonly}
/>
</ContentGroup>
)),
},
]
: []),
...(vehicleGroups.length > 0
? [
{
label: "Vehicle",
content: vehicleGroups.map((groupInfo) => (
<ContentGroup key={groupInfo.id} header={groupInfo.name}>
<ExtraList
theme={theme}
extras={groups[groupInfo.id]}
showNotes={showNotes}
onShow={this.handleExtraShow}
onStatusChange={this.handleExtraStatusChange}
isReadonly={isReadonly}
/>
</ContentGroup>
)),
},
]
: []),
]}
/>
</div>
);
};
render() {
const { extras, theme } = this.props;
return (
<section
className={`ct-extras ${
theme.isDarkMode ? "ct-extras--dark-mode" : ""
}`}
>
<h2 style={visuallyHidden}>Extras</h2>
<div className="ct-extras__filter">
<ExtrasFilter
extras={extras}
onDataUpdate={this.handleFilterUpdate}
callout={this.renderManageButton()}
theme={theme}
/>
</div>
{this.renderContent()}
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
function ExtrasContainer(props) {
const { extrasManager } = useContext(ExtrasManagerContext);
const extras = useExtras();
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<Extras
extrasManager={extrasManager}
paneHistoryStart={paneHistoryStart}
extras={extras}
{...props}
/>
);
}
export default connect(mapStateToProps)(ExtrasContainer);
@@ -0,0 +1,4 @@
import Extras from "./Extras";
export default Extras;
export { Extras };
@@ -0,0 +1,366 @@
import { visuallyHidden } from "@mui/utils";
import React, { useContext, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
rulesEngineSelectors,
ClassUtils,
FeatureUtils,
InfusionChoiceUtils,
RacialTrait,
RacialTraitUtils,
FeaturesManager,
FeatManager,
Action,
ActionUtils,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { TabFilter } from "~/components/TabFilter";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { SpeciesDetail } from "~/subApps/sheet/components/SpeciesDetail";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import {
handleActionUseSet,
handleSpellUseSet,
} from "../../../../../handlers/commonHandlers";
import { CharacterFeaturesManagerContext } from "../../../Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import BlessingsDetail from "../../components/BlessingsDetail";
import ClassesDetail from "../../components/ClassesDetail";
import ContentGroup from "../../components/ContentGroup";
import FeatsDetail from "../../components/FeatsDetail";
//TODO we should break this file out into seperate components rather than have them all in one file
const SpeciesTraitsGroup: React.FC<{}> = () => {
const dispatch = useDispatch();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryStart },
} = useSidebar();
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
) as number;
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
const speciesTraits = useSelector(
rulesEngineSelectors.getCurrentLevelRacialTraits
) as RacialTrait[];
const handleFeatClick = (feat: FeatManager): void => {
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const handleActionClick = (action: Action): void => {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryStart(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleSpellDetailClick = (spell: Spell): void => {
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryStart(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
return (
<ContentGroup header="Species Traits">
<SpeciesDetail
onActionUseSet={(action, uses) =>
handleActionUseSet(action, uses, dispatch)
}
onActionClick={(action) => handleActionClick(action)}
onSpellClick={(spell) => handleSpellDetailClick(spell)}
onSpellUseSet={(spell, uses) =>
handleSpellUseSet(spell, uses, dispatch)
}
onFeatureClick={(feature) => {
paneHistoryStart(
PaneComponentEnum.SPECIES_TRAIT_DETAIL,
PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(feature)
)
);
}}
feats={characterFeaturesManager
.getFeats()
.map((manager) => manager.feat)}
isReadonly={isReadonly}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
theme={theme}
speciesTraits={speciesTraits}
onFeatClick={(feat) => handleFeatClick(feat)}
featuresManager={characterFeaturesManager}
/>
</ContentGroup>
);
};
const ClassFeaturesGroup: React.FC<{}> = () => {
const dispatch = useDispatch();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryStart },
} = useSidebar();
const classes = useSelector(rulesEngineSelectors.getClasses);
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);
const handleFeatClick = (feat: FeatManager): void => {
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const handleActionClick = (action: Action): void => {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryStart(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleSpellDetailClick = (spell: Spell): void => {
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryStart(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
return (
<ContentGroup header="Class Features">
<ClassesDetail
classes={classes}
onActionUseSet={(action, uses) =>
handleActionUseSet(action, uses, dispatch)
}
onActionClick={(action) => handleActionClick(action)}
onSpellClick={(spell) => handleSpellDetailClick(spell)}
onSpellUseSet={(spell, uses) =>
handleSpellUseSet(spell, uses, dispatch)
}
onFeatureClick={(feature, charClass) => {
paneHistoryStart(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
FeatureUtils.getId(feature),
ClassUtils.getMappingId(charClass)
)
);
}}
onInfusionChoiceClick={(infusionChoice) => {
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
}}
feats={characterFeaturesManager
.getFeats()
.map((manager) => manager.feat)}
isReadonly={isReadonly}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={(feat) => handleFeatClick(feat)}
featuresManager={characterFeaturesManager}
/>
</ContentGroup>
);
};
const FeatsGroup: React.FC<{}> = () => {
const dispatch = useDispatch();
const { gfsBlessingsUiFlag } = useFeatureFlags();
const {
pane: { paneHistoryStart },
} = useSidebar();
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);
const handleFeatClick = (feat: FeatManager): void => {
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const handleActionClick = (action: Action): void => {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryStart(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleSpellDetailClick = (spell: Spell): void => {
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryStart(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
return (
<ContentGroup header="Feats">
{!isReadonly && (
<div className="ct-features__management-link">
<ThemeButton
onClick={() => paneHistoryStart(PaneComponentEnum.FEATS_MANAGE)}
style="outline"
size="medium"
>
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
</ThemeButton>
</div>
)}
<FeatsDetail
onActionUseSet={(action, uses) =>
handleActionUseSet(action, uses, dispatch)
}
onActionClick={(action) => handleActionClick(action)}
onSpellClick={(spell) => handleSpellDetailClick(spell)}
onSpellUseSet={(spell, uses) =>
handleSpellUseSet(spell, uses, dispatch)
}
onFeatureClick={(feat) => handleFeatClick(feat)}
isReadonly={isReadonly}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
</ContentGroup>
);
};
const BlessingsGroup: React.FC<{}> = () => {
return (
<ContentGroup header="Blessings">
<BlessingsDetail />
</ContentGroup>
);
};
const Features: React.FC<{}> = () => {
const { gfsBlessingsUiFlag } = useFeatureFlags();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const [hasBlessings, setHasBlessings] = useState(false);
useEffect(() => {
async function onUpdate() {
const hasBlessings = gfsBlessingsUiFlag
? await characterFeaturesManager.hasBlessings()
: false;
setHasBlessings(hasBlessings);
}
return FeaturesManager.subscribeToUpdates({ onUpdate });
}, [setHasBlessings]);
return (
<section className="ct-features">
<h2 style={visuallyHidden}>Features and Traits</h2>
<TabFilter
filters={[
{
label: "Class Features",
content: <ClassFeaturesGroup />,
},
{
label: "Species Traits",
content: <SpeciesTraitsGroup />,
},
{
label: "Feats",
content: <FeatsGroup />,
},
...(gfsBlessingsUiFlag && hasBlessings
? [{ label: "Blessings", content: <BlessingsGroup /> }]
: []),
]}
/>
</section>
);
};
export default Features;
@@ -0,0 +1,4 @@
import Features from "./Features";
export default Features;
export { Features };
@@ -0,0 +1,242 @@
import React, { useCallback, useContext, useState } from "react";
import {
CharacterTheme,
ContainerManager,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { ThemeButton } from "../../../Shared/components/common/Button";
import { InventoryManagerContext } from "../../../Shared/managers/InventoryManagerContext";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import InventoryItem from "../../components/InventoryItem";
import { InventoryTableHeader } from "../../components/InventoryTableHeader";
interface Props {
container: ContainerManager;
inventory: Array<ItemManager>;
showNotes?: boolean;
isReadonly: boolean;
theme: CharacterTheme;
showTableHeader?: boolean;
}
interface InventoryActionsProps
extends Pick<Props, "isReadonly" | "container" | "inventory"> {
onContainerClick: (
evt: React.MouseEvent | React.KeyboardEvent,
container: ContainerManager,
showAddItems: boolean
) => void;
showContents: boolean;
onShowContentsClick: (showContents: boolean) => void;
}
const InventoryActions: React.FC<InventoryActionsProps> = ({
isReadonly,
container,
inventory,
onContainerClick,
onShowContentsClick,
showContents,
}) => {
const { inventoryManager } = useContext(InventoryManagerContext);
if (isReadonly) {
return null;
}
const containerName = container.getName();
const classNames: Array<string> = ["ct-inventory__actions"];
if (inventory.length > 0 && showContents) {
classNames.push("ct-inventory__actions--collapsed");
}
return (
<div className={classNames.join(" ")}>
{inventoryManager.canAddToContainer(container.container) ? (
<span
role="button"
className="ct-inventory__action"
onClick={(evt) => onContainerClick(evt, container, true)}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
onContainerClick(evt, container, true);
}
}}
>
{container.isCharacterContainer()
? `+ Add ${containerName}`
: `+ Add items to your ${containerName}`}
</span>
) : (
<span />
)}
{inventory.length > 0 && (
<span
role="button"
className="ct-inventory__action"
onClick={() => onShowContentsClick(!showContents)}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
onShowContentsClick(!showContents);
}
}}
>
{showContents ? "Hide Contents" : "Show Contents"}
</span>
)}
</div>
);
};
const EmptyInventory: React.FC<InventoryActionsProps> = ({
isReadonly,
container,
inventory,
...restProps
}) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
if (isReadonly) {
return (
<div className="ct-inventory__empty">
No equipment has been added for this character.
</div>
);
}
if (container.isCharacterContainer()) {
return (
<div className="ct-inventory__choose">
<div className="ct-inventory__choose-actions">
<div className="ct-inventory__choose-action">
<ThemeButton
block={true}
onClick={() =>
paneHistoryStart(PaneComponentEnum.STARTING_EQUIPMENT)
}
stopPropagation={true}
>
Starting Equipment or Gold
</ThemeButton>
</div>
<div className="ct-inventory__choose-action-sep">or</div>
<div className="ct-inventory__choose-action">
<ThemeButton
block={true}
onClick={() =>
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE)
}
stopPropagation={true}
>
Add Items
</ThemeButton>
</div>
</div>
</div>
);
}
return (
<InventoryActions
inventory={inventory}
container={container}
isReadonly={isReadonly}
onContainerClick={restProps.onContainerClick}
showContents={restProps.showContents}
onShowContentsClick={restProps.onShowContentsClick}
/>
);
};
const Inventory: React.FC<Props> = ({
container,
inventory,
showNotes = true,
isReadonly,
theme,
showTableHeader = true,
}) => {
const [showContents, setShowContents] = useState(true);
const {
pane: { paneHistoryStart },
} = useSidebar();
const handleContainerShow = useCallback(
(
evt: React.MouseEvent | React.KeyboardEvent,
container: ContainerManager,
showAddItems: boolean
): void => {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
paneHistoryStart(
PaneComponentEnum.CONTAINER,
PaneIdentifierUtils.generateContainer(
container.getDefinitionKey(),
showAddItems
)
);
},
[container]
);
const isEmpty: boolean = inventory.length === 0;
return (
<div className="ct-inventory">
{/*<h2 style={visuallyHidden}>Inventory</h2>*/}
{showTableHeader && <InventoryTableHeader showNotes={showNotes} />}
<div className="ct-inventory__items">
{isEmpty ? (
<EmptyInventory
inventory={inventory}
container={container}
isReadonly={isReadonly}
onShowContentsClick={setShowContents}
showContents={showContents}
onContainerClick={handleContainerShow}
/>
) : (
<React.Fragment>
{showContents &&
inventory.map((item) => (
<InventoryItem
key={item.getUniqueKey()}
item={item}
onEquip={item.handleEquip}
onUnequip={item.handleUnequip}
onItemShow={(mappingId) =>
paneHistoryStart(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(mappingId)
)
}
showNotes={showNotes}
theme={theme}
isReadonly={isReadonly}
/>
))}
<InventoryActions
inventory={inventory}
container={container}
isReadonly={isReadonly}
onContainerClick={handleContainerShow}
onShowContentsClick={setShowContents}
showContents={showContents}
/>
</React.Fragment>
)}
</div>
</div>
);
};
export default Inventory;
@@ -0,0 +1,4 @@
import Inventory from "./Inventory";
export default Inventory;
export { Inventory };
@@ -0,0 +1,138 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterNotes,
Constants,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { TabFilter } from "~/components/TabFilter";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ContentGroup from "../../components/ContentGroup";
import { SheetAppState } from "../../typings";
interface NotesProps {
notes: CharacterNotes;
isReadonly: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class Notes extends React.PureComponent<NotesProps> {
handleNotesManage = (
noteType: Constants.NoteKeyEnum,
evt: React.MouseEvent
): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(
PaneComponentEnum.NOTE_MANAGE,
PaneIdentifierUtils.generateNote(noteType)
);
}
};
renderNoteGroup(
label: string,
key: Constants.NoteKeyEnum,
fallback: string = ""
) {
const { notes } = this.props;
let noteData = notes[key];
let hasContent: boolean = !!noteData;
let content: string = !!noteData ? noteData : fallback;
let classNames: Array<string> = ["ct-notes__note"];
if (!hasContent) {
classNames.push("ct-notes__note--no-content");
}
return (
<ContentGroup header={label}>
<div
className={classNames.join(" ")}
onClick={this.handleNotesManage.bind(this, key)}
>
{content}
</div>
</ContentGroup>
);
}
render() {
return (
<section className="ct-notes">
<h2 style={visuallyHidden}>Notes</h2>
<TabFilter
filters={[
{
label: "Orgs",
content: this.renderNoteGroup(
"Organizations",
Constants.NoteKeyEnum.ORGANIZATIONS,
"+ Add Organizations"
),
},
{
label: "Allies",
content: this.renderNoteGroup(
"Allies",
Constants.NoteKeyEnum.ALLIES,
"+ Add Allies"
),
},
{
label: "Enemies",
content: this.renderNoteGroup(
"Enemies",
Constants.NoteKeyEnum.ENEMIES,
"+ Add Enemies"
),
},
{
label: "Backstory",
content: this.renderNoteGroup(
"Backstory",
Constants.NoteKeyEnum.BACKSTORY,
"+ Add Backstory"
),
},
{
label: "Other",
content: this.renderNoteGroup(
"Other",
Constants.NoteKeyEnum.OTHER,
"+ Add Other"
),
},
]}
/>
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
notes: rulesEngineSelectors.getCharacterNotes(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
const NotesContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <Notes {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(NotesContainer);
@@ -0,0 +1,4 @@
import Notes from "./Notes";
export default Notes;
export { Notes };
@@ -0,0 +1,142 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
BeveledBoxSvg517x660,
BeveledBoxSvg623x660,
BoxBackground,
} from "@dndbeyond/character-components/es";
import {
characterSelectors,
CharacterStatusSlug,
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { TabList } from "~/components/TabList";
import Subsection from "../../../Shared/components/Subsection";
import { StyleSizeTypeEnum } from "../../../Shared/reducers/appEnv";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import { SheetAppState } from "../../typings";
import Actions from "../Actions";
import Description from "../Description";
import Equipment from "../Equipment";
import Extras from "../Extras";
import Features from "../Features";
import Notes from "../Notes";
import Spells from "../Spells";
import styles from "./styles.module.css";
const TAB_KEY = {
ACTIONS: "ACTIONS",
SPELLS: "SPELLS",
EQUIPMENT: "EQUIPMENT",
DESCRIPTION: "DESCRIPTION",
NOTES: "NOTES",
FEATURES_TRAITS: "FEATURES_TRAITS",
EXTRAS: "EXTRAS",
};
interface Props extends DispatchProp {
hasSpells: boolean;
dimensions: AppEnvDimensionsState;
theme: CharacterTheme;
isReadonly: boolean;
characterStatus: string | null;
}
class PrimaryBox extends React.PureComponent<Props, {}> {
render() {
const { hasSpells, dimensions, theme, isReadonly, characterStatus } =
this.props;
let isPremade = CharacterStatusSlug.PREMADE === characterStatus;
let BoxBackgroundComponent: React.ComponentType = BeveledBoxSvg517x660;
if (dimensions.styleSizeType > StyleSizeTypeEnum.DESKTOP) {
BoxBackgroundComponent = BeveledBoxSvg623x660;
}
return (
<Subsection name="Primary Box">
<div
className={`ct-primary-box ${
theme.isDarkMode ? "ct-primary-box--dark-mode" : ""
}`}
>
<BoxBackground
StyleComponent={BoxBackgroundComponent}
theme={theme}
/>
<TabList
className={styles.tabList}
tabs={[
{
label: "Actions",
content: <Actions />,
className: "ct-primary-box__tab--actions",
id: TAB_KEY.ACTIONS,
},
hasSpells
? {
label: "Spells",
content: <Spells />,
className: "ct-primary-box__tab--spells",
id: TAB_KEY.SPELLS,
}
: null,
{
label: "Inventory",
content: <Equipment />,
className: "ct-primary-box__tab--equipment",
id: TAB_KEY.EQUIPMENT,
},
{
label: "Features & Traits",
content: <Features />,
className: "ct-primary-box__tab--features",
id: TAB_KEY.FEATURES_TRAITS,
},
!isReadonly || isPremade
? {
label: "Background",
content: <Description theme={theme} />,
className: "ct-primary-box__tab--description",
id: TAB_KEY.DESCRIPTION,
}
: null,
!isReadonly || isPremade
? {
label: "Notes",
content: <Notes />,
className: "ct-primary-box__tab--notes",
id: TAB_KEY.NOTES,
}
: null,
{
label: "Extras",
content: <Extras />,
className: "ct-primary-box__tab--extras",
id: TAB_KEY.EXTRAS,
},
]}
/>
</div>
</Subsection>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
hasSpells: rulesEngineSelectors.hasSpells(state),
dimensions: appEnvSelectors.getDimensions(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
characterStatus: characterSelectors.getStatusSlug(state),
};
}
export default connect(mapStateToProps)(PrimaryBox);
@@ -0,0 +1,4 @@
import PrimaryBox from "./PrimaryBox";
export default PrimaryBox;
export { PrimaryBox };
@@ -0,0 +1,83 @@
import React from "react";
import { connect } from "react-redux";
import { ManageIcon } from "@dndbeyond/character-components/es";
import {
CharacterTheme,
ProficiencyGroup,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import {
Subsection,
SubsectionFooter,
} from "../../../Shared/components/Subsection";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import ProficiencyGroupsBox from "../../components/ProficiencyGroupsBox";
import { SheetAppState } from "../../typings";
interface Props {
dimensions: AppEnvDimensionsState;
theme: CharacterTheme;
proficiencyGroups: Array<ProficiencyGroup>;
isReadonly: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class ProficiencyGroupsDesktop extends React.PureComponent<Props> {
handleManageShow = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.PROFICIENCIES);
}
};
render() {
const { dimensions, theme, proficiencyGroups, isReadonly } = this.props;
return (
<Subsection name="Proficiency Groups">
<ProficiencyGroupsBox
dimensions={dimensions}
theme={theme}
proficiencyGroups={proficiencyGroups}
onClick={this.handleManageShow}
/>
<SubsectionFooter>
<ManageIcon
onClick={this.handleManageShow}
showIcon={!isReadonly}
enableTooltip={!isReadonly}
theme={theme}
>
Proficiencies & Training
</ManageIcon>
</SubsectionFooter>
</Subsection>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
dimensions: appEnvSelectors.getDimensions(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
proficiencyGroups: rulesEngineSelectors.getProficiencyGroups(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
const ProficiencyGroupsDesktopContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return (
<ProficiencyGroupsDesktop {...props} paneHistoryStart={paneHistoryStart} />
);
};
export default connect(mapStateToProps)(ProficiencyGroupsDesktopContainer);
@@ -0,0 +1,4 @@
import ProficiencyGroupsDesktop from "./ProficiencyGroupsDesktop";
export default ProficiencyGroupsDesktop;
export { ProficiencyGroupsDesktop };
@@ -0,0 +1,123 @@
import { visuallyHidden } from "@mui/utils";
import { useDispatch, useSelector } from "react-redux";
import { AbilitySummary } from "@dndbeyond/character-components/es";
import {
AbilityManager,
characterActions,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { useAbilities } from "~/hooks/useAbilities";
import { HitPointsBox } from "~/subApps/sheet/components/HitPointsBox/HitPointsBox";
import { Inspiration } from "~/subApps/sheet/components/Inspiration";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ProficiencyBonusBox from "../../components/ProficiencyBonusBox";
import SpeedBox from "../../components/SpeedBox";
export default function QuickInfo() {
const abilities = useAbilities();
const dispatch = useDispatch();
const {
pane: { paneHistoryStart },
} = useSidebar();
const proficiencyBonus = useSelector(
rulesEngineSelectors.getProficiencyBonus
);
const speeds = useSelector(rulesEngineSelectors.getCurrentCarriedWeightSpeed);
const preferences = useSelector(rulesEngineSelectors.getCharacterPreferences);
const inspiration = useSelector(rulesEngineSelectors.getInspiration);
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const handleProficiencyBonusClick = (): void => {
paneHistoryStart(PaneComponentEnum.PROFICIENCY_BONUS);
};
const handleInspirationLabelClick = (): void => {
paneHistoryStart(PaneComponentEnum.INSPIRATION);
};
const handleSpeedsClick = (): void => {
paneHistoryStart(PaneComponentEnum.SPEED_MANAGE);
};
const handleToggleInspiration = (): void => {
dispatch(characterActions.inspirationSet(!inspiration));
};
const handleAbilitySummaryClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY,
PaneIdentifierUtils.generateAbility(ability)
);
};
let inspirationClasses = ["ct-quick-info__inspiration-status"];
if (inspiration) {
inspirationClasses.push("ct-quick-info__inspiration-status--active");
} else {
inspirationClasses.push("ct-quick-info__inspiration-status--inactive");
}
return (
<div className="ct-quick-info">
<section className="ct-quick-info__abilities">
<h2 style={visuallyHidden}>Ability Scores</h2>
{abilities.map((ability) => (
<div className="ct-quick-info__ability" key={ability.getStatKey()}>
<AbilitySummary
theme={theme}
ability={ability}
preferences={preferences}
onClick={handleAbilitySummaryClick}
diceEnabled={diceEnabled}
rollContext={characterRollContext}
/>
</div>
))}
</section>
<div className="ct-quick-info__box ct-quick-info__box--proficiency">
<ProficiencyBonusBox
theme={theme}
proficiencyBonus={proficiencyBonus}
onClick={handleProficiencyBonusClick}
/>
</div>
<div className="ct-quick-info__box ct-quick-info__box--speed">
<SpeedBox
speeds={speeds}
preferences={preferences}
theme={theme}
ruleData={ruleData}
onClick={handleSpeedsClick}
/>
</div>
<section className="ct-quick-info__inspiration">
<h2 style={visuallyHidden}>Inspiration</h2>
<Inspiration
inspiration={!!inspiration}
onToggle={handleToggleInspiration}
onClick={handleInspirationLabelClick}
isInteractive={!isReadonly}
/>
</section>
<section className="ct-quick-info__health">
<HitPointsBox />
</section>
</div>
);
}
@@ -0,0 +1,4 @@
import QuickInfo from "./QuickInfo";
export default QuickInfo;
export { QuickInfo };
@@ -0,0 +1,82 @@
import { useSelector } from "react-redux";
import { ManageIcon } from "@dndbeyond/character-components/es";
import {
AbilityManager,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { useAbilities } from "~/hooks/useAbilities";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import {
Subsection,
SubsectionFooter,
} from "../../../Shared/components/Subsection";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import SavingThrowsBox from "../../components/SavingThrowsBox";
export default function SavingThrowsDesktop() {
const abilities = useAbilities();
const {
pane: { paneHistoryStart },
} = useSidebar();
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
const savingThrowDiceAdjustments = useSelector(
rulesEngineSelectors.getSavingThrowDiceAdjustments
); // TODO: GFS move to mangers
const situationalBonusSavingThrowsLookup = useSelector(
rulesEngineSelectors.getSituationalBonusSavingThrowsLookup
); // TODO: GFS move to mangers
const deathSaveInfo = useSelector(rulesEngineSelectors.getDeathSaveInfo);
const dimensions = useSelector(appEnvSelectors.getDimensions);
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const handleManageShow = (): void => {
paneHistoryStart(PaneComponentEnum.SAVING_THROWS);
};
const handleAbilityClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY_SAVING_THROW,
PaneIdentifierUtils.generateAbilitySavingThrows(ability.getId())
);
};
return (
<Subsection name="Abilities">
<SavingThrowsBox
ruleData={ruleData}
abilities={abilities}
savingThrowDiceAdjustments={savingThrowDiceAdjustments}
situationalBonusSavingThrowsLookup={situationalBonusSavingThrowsLookup}
deathSaveInfo={deathSaveInfo}
onAbilityClick={handleAbilityClick}
onInfoClick={handleManageShow}
dimensions={dimensions}
theme={theme}
diceEnabled={diceEnabled}
rollContext={characterRollContext}
/>
<SubsectionFooter>
<ManageIcon
onClick={handleManageShow}
tooltip={isReadonly ? "View" : "Manage"}
theme={theme}
>
Saving Throws
</ManageIcon>
</SubsectionFooter>
</Subsection>
);
}
@@ -0,0 +1,4 @@
import SavingThrowsDesktop from "./SavingThrowsDesktop";
export default SavingThrowsDesktop;
export { SavingThrowsDesktop };
@@ -0,0 +1,95 @@
import React from "react";
import { connect } from "react-redux";
import { ManageIcon } from "@dndbeyond/character-components/es";
import {
CharacterTheme,
rulesEngineSelectors,
SenseInfo,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import {
Subsection,
SubsectionFooter,
} from "../../../Shared/components/Subsection";
import { appEnvSelectors } from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import SensesBox from "../../components/SensesBox";
import { SheetAppState } from "../../typings";
interface Props {
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
senses: SenseInfo;
dimensions: AppEnvDimensionsState;
theme: CharacterTheme;
isReadonly: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class SensesDesktop extends React.PureComponent<Props> {
handleManageShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.SENSE_MANAGE);
};
render() {
const {
passiveInsight,
passiveInvestigation,
passivePerception,
senses,
isReadonly,
dimensions,
theme,
} = this.props;
return (
<Subsection name="Senses" className="ct-senses-desktop">
<SensesBox
senses={senses}
passiveInsight={passiveInsight}
passiveInvestigation={passiveInvestigation}
passivePerception={passivePerception}
onClick={this.handleManageShow}
dimensions={dimensions}
theme={theme}
/>
<SubsectionFooter>
<ManageIcon
onClick={this.handleManageShow}
tooltip={isReadonly ? "View" : "Manage"}
theme={theme}
>
Senses
</ManageIcon>
</SubsectionFooter>
</Subsection>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
passivePerception: rulesEngineSelectors.getPassivePerception(state),
passiveInvestigation: rulesEngineSelectors.getPassiveInvestigation(state),
passiveInsight: rulesEngineSelectors.getPassiveInsight(state),
senses: rulesEngineSelectors.getSenseInfo(state),
dimensions: appEnvSelectors.getDimensions(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
const SensesDesktopContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <SensesDesktop {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SensesDesktopContainer);
@@ -0,0 +1,4 @@
import SensesDesktop from "./SensesDesktop";
export default SensesDesktop;
export { SensesDesktop };
@@ -0,0 +1,139 @@
import React from "react";
import { connect } from "react-redux";
import { ManageIcon } from "@dndbeyond/character-components/es";
import {
rulesEngineSelectors,
Skill,
SkillUtils,
ValueLookup,
CharacterTheme,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import Subsection, {
SubsectionFooter,
} from "../../../Shared/components/Subsection";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../Shared/selectors";
import { AppEnvDimensionsState } from "../../../Shared/stores/typings";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import SkillsBox from "../../components/SkillsBox";
import { SheetAppState } from "../../typings";
interface Props {
dimensions: AppEnvDimensionsState;
theme: CharacterTheme;
skills: Array<Skill>;
customSkills: Array<Skill>;
valueLookup: ValueLookup;
isReadonly: boolean;
diceEnabled: boolean;
ruleData: RuleData;
characterRollContext: IRollContext;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class SkillsDesktop extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handleManageShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.SKILLS);
};
handleCustomSkillClick = (skill: Skill): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CUSTOM_SKILL,
PaneIdentifierUtils.generateCustomSkill(SkillUtils.getId(skill))
);
};
handleEmptyClick = (): void => {
this.handleManageShow();
};
handleSkillClick = (skill: Skill): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.SKILL,
PaneIdentifierUtils.generateSkill(SkillUtils.getId(skill))
);
};
render() {
const {
dimensions,
theme,
skills,
valueLookup,
customSkills,
isReadonly,
diceEnabled,
ruleData,
characterRollContext,
} = this.props;
return (
<Subsection name="Skills">
<SkillsBox
skills={skills}
customSkills={customSkills}
valueLookup={valueLookup}
onCustomSkillClick={this.handleCustomSkillClick}
onSkillClick={this.handleSkillClick}
onEmptyClick={this.handleEmptyClick}
dimensions={dimensions}
theme={theme}
diceEnabled={diceEnabled}
ruleData={ruleData}
rollContext={characterRollContext}
/>
<SubsectionFooter>
<ManageIcon
onClick={this.handleManageShow}
tooltip={isReadonly ? "View" : "Manage"}
theme={theme}
>
Skills
</ManageIcon>
</SubsectionFooter>
</Subsection>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
skills: rulesEngineSelectors.getSkills(state),
valueLookup: rulesEngineSelectors.getCharacterValueLookup(state),
customSkills: rulesEngineSelectors.getCustomSkills(state),
dimensions: appEnvSelectors.getDimensions(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
ruleData: rulesEngineSelectors.getRuleData(state),
characterRollContext:
characterRollContextSelectors.getCharacterRollContext(state),
};
}
const SkillsDesktopContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <SkillsDesktop {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SkillsDesktopContainer);
@@ -0,0 +1,4 @@
import SkillsDesktop from "./SkillsDesktop";
export default SkillsDesktop;
export { SkillsDesktop };
@@ -0,0 +1,80 @@
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
ApiRequestHelpers,
rulesEngineSelectors,
characterActions,
SpellSlotContract,
} from "@dndbeyond/character-rules-engine/es";
import { appEnvSelectors } from "../../../Shared/selectors";
import { SheetAppState } from "../../typings";
import SpellSlotManagerGroup from "./SpellSlotManagerGroup";
interface Props extends DispatchProp {
spellSlots: Array<SpellSlotContract>;
pactSlots: Array<SpellSlotContract>;
isReadonly: boolean;
}
class SpellSlotManager extends React.PureComponent<Props> {
handleSlotSet = (level: number, used: number): void => {
const { dispatch } = this.props;
const spellLevelSpellSlotRequestsDataKey =
ApiRequestHelpers.getSpellLevelSpellSlotRequestsDataKey(level);
if (spellLevelSpellSlotRequestsDataKey !== null) {
dispatch(
characterActions.spellLevelSpellSlotsSet({
[spellLevelSpellSlotRequestsDataKey]: used,
})
);
}
};
handlePactSet = (level: number, used: number): void => {
const { dispatch } = this.props;
const spellLevelPactMagicRequestsDataKey =
ApiRequestHelpers.getSpellLevelPactMagicRequestsDataKey(level);
if (spellLevelPactMagicRequestsDataKey !== null) {
dispatch(
characterActions.spellLevelPactMagicSlotsSet({
[spellLevelPactMagicRequestsDataKey]: used,
})
);
}
};
render() {
const { spellSlots, pactSlots, isReadonly } = this.props;
return (
<div className="ct-spell-slot-manager">
<SpellSlotManagerGroup
heading="Spell Slots"
slots={spellSlots}
onSlotSet={this.handleSlotSet}
isReadonly={isReadonly}
/>
<SpellSlotManagerGroup
heading="Pact Magic"
slots={pactSlots}
onSlotSet={this.handlePactSet}
isReadonly={isReadonly}
/>
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
spellSlots: rulesEngineSelectors.getSpellSlots(state),
pactSlots: rulesEngineSelectors.getPactMagicSlots(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
export default connect(mapStateToProps)(SpellSlotManager);
@@ -0,0 +1,77 @@
import React from "react";
import {
Collapsible,
CollapsibleHeaderContent,
} from "@dndbeyond/character-components/es";
import {
FormatUtils,
SpellSlotContract,
} from "@dndbeyond/character-rules-engine/es";
import SpellSlotManagerLevel from "../SpellSlotManagerLevel";
interface Props {
heading: string;
slots: Array<SpellSlotContract>;
onSlotSet?: (level: number, used: number) => void;
isReadonly: boolean;
}
export default class SpellSlotManagerGroup extends React.PureComponent<Props> {
renderCallout = (): React.ReactNode => {
const { slots } = this.props;
return (
<div className="ct-spell-slot-manager__group-summary">
{slots.map((levelSlots) => (
<div
className="ct-spell-slot-manager__group-level"
key={levelSlots.level}
>
<div className="ct-spell-slot-manager__group-level-name">
{FormatUtils.ordinalize(levelSlots.level)}
</div>
<div className="ct-spell-slot-manager__group-level-available">
{levelSlots.available === 0 ? "-" : levelSlots.available}
</div>
</div>
))}
</div>
);
};
render() {
const { slots, onSlotSet, heading, isReadonly } = this.props;
let headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading={heading}
callout={this.renderCallout()}
/>
);
if (!slots.length) {
return null;
}
return (
<Collapsible header={headerNode} className="ct-spell-slot-manager__group">
<div className="ct-spell-slot-manager__levels">
{slots.map((levelSlots) => {
if (levelSlots.available === 0) {
return null;
}
return (
<SpellSlotManagerLevel
key={levelSlots.level}
{...levelSlots}
onSlotSet={onSlotSet}
isInteractive={!isReadonly}
/>
);
})}
</div>
</Collapsible>
);
}
}
@@ -0,0 +1,4 @@
import SpellSlotManagerGroup from "./SpellSlotManagerGroup";
export default SpellSlotManagerGroup;
export { SpellSlotManagerGroup };
@@ -0,0 +1,41 @@
import React from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
import SlotManager from "../../../../Shared/components/SlotManager";
interface Props {
level: number;
available: number;
used: number;
onSlotSet?: (level: number, used: number) => void;
isInteractive: boolean;
}
export default class SpellSlotManagerLevel extends React.PureComponent<Props> {
handleSlotSet = (used: number): void => {
const { onSlotSet, level } = this.props;
if (onSlotSet) {
onSlotSet(level, used);
}
};
render() {
let { level, available, used, isInteractive } = this.props;
return (
<div className="ct-spell-slot-manager__level">
<div className="ct-spell-slot-manager__level-name">
{FormatUtils.ordinalize(level)} Level
</div>
<SlotManager
available={available}
used={used}
onSet={this.handleSlotSet}
size="small"
isInteractive={isInteractive}
/>
</div>
);
}
}
@@ -0,0 +1,4 @@
import SpellSlotManagerLevel from "./SpellSlotManagerLevel";
export default SpellSlotManagerLevel;
export { SpellSlotManagerLevel };
@@ -0,0 +1,6 @@
import SpellSlotManager from "./SpellSlotManager";
import SpellSlotManagerGroup from "./SpellSlotManagerGroup";
import SpellSlotManagerLevel from "./SpellSlotManagerLevel";
export default SpellSlotManager;
export { SpellSlotManager, SpellSlotManagerGroup, SpellSlotManagerLevel };
@@ -0,0 +1,562 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
ConcentrationIcon,
RitualIcon,
} from "@dndbeyond/character-components/es";
import {
AbilityLookup,
ApiRequestHelpers,
characterActions,
CharacterTheme,
ClassUtils,
Constants,
DataOriginRefData,
ExperienceInfo,
FormatUtils,
Hack__BaseCharClass,
HelperUtils,
InventoryLookup,
InventoryManager,
ItemManager,
RuleData,
rulesEngineSelectors,
ScaledSpell,
SpellCasterInfo,
SpellSlotContract,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import { TabFilter } from "~/components/TabFilter";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import SpellsLevel from "../../../Shared/components/SpellsLevel";
import SpellsLevelCasting from "../../../Shared/components/SpellsLevelCasting";
import { ThemeButton } from "../../../Shared/components/common/Button";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../Shared/utils";
import ContentGroup from "../../components/ContentGroup";
import SpellsFilter from "../../components/SpellsFilter";
import { SheetAppState } from "../../typings";
import styles from "./styles.module.css";
const ALL_LEVELS: number = -1;
function filterLevelSpells(
levelSpells: Array<Array<ScaledSpell>>,
testFunc: (spell: ScaledSpell) => boolean
): Array<Array<ScaledSpell>> {
return levelSpells.map((spells) => spells.filter((spell) => testFunc(spell)));
}
function hasFilteredLevelSpells(
levelSpells: Array<Array<ScaledSpell>>,
testFunc: (spell: ScaledSpell) => boolean
): boolean {
return levelSpells.reduce((acc, spells) => {
return (
acc ||
spells.reduce((spellAcc, spell) => {
return spellAcc || testFunc(spell);
}, false)
);
}, false);
}
interface Props extends DispatchProp {
levelSpells: Array<Array<ScaledSpell>>;
spellCasterInfo: SpellCasterInfo;
ruleData: RuleData;
abilityLookup: AbilityLookup;
xpInfo: ExperienceInfo;
isReadonly: boolean;
spellSlots: Array<SpellSlotContract>;
pactMagicSlots: Array<SpellSlotContract>;
showNotes: boolean;
theme: CharacterTheme;
diceEnabled: boolean;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
characterRollContext: IRollContext;
inventoryManager: InventoryManager;
inventoryLookup: InventoryLookup;
partyInventoryLookup: InventoryLookup;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
interface StateFilterData {
filteredLevelSpells: Array<Array<ScaledSpell>>;
showAdvancedFilters: boolean;
isFiltering: boolean;
}
interface State {
filterData: StateFilterData;
ritualLevelSpells: Array<Array<ScaledSpell>>;
concentrationLevelSpells: Array<Array<ScaledSpell>>;
hasRitualSpells: boolean;
hasConcentrationSpells: boolean;
}
class Spells extends React.PureComponent<Props, State> {
static defaultProps = {
showNotes: true,
};
constructor(props) {
super(props);
this.state = {
filterData: {
filteredLevelSpells: [],
showAdvancedFilters: false,
isFiltering: false,
},
ritualLevelSpells: filterLevelSpells(
props.levelSpells,
SpellUtils.isRitual
),
concentrationLevelSpells: filterLevelSpells(
props.levelSpells,
SpellUtils.getConcentration
),
hasRitualSpells: hasFilteredLevelSpells(
props.levelSpells,
SpellUtils.isRitual
),
hasConcentrationSpells: hasFilteredLevelSpells(
props.levelSpells,
SpellUtils.getConcentration
),
};
}
componentDidUpdate(prevProps: Props, prevState: State): void {
if (this.props.levelSpells !== prevProps.levelSpells) {
this.setState({
ritualLevelSpells: filterLevelSpells(
this.props.levelSpells,
SpellUtils.isRitual
),
concentrationLevelSpells: filterLevelSpells(
this.props.levelSpells,
SpellUtils.getConcentration
),
hasRitualSpells: hasFilteredLevelSpells(
this.props.levelSpells,
SpellUtils.isRitual
),
hasConcentrationSpells: hasFilteredLevelSpells(
this.props.levelSpells,
SpellUtils.getConcentration
),
});
}
}
handleSpellSlotSet = (level: number, used: number): void => {
const { dispatch } = this.props;
const spellLevelSpellSlotRequestsDataKey =
ApiRequestHelpers.getSpellLevelSpellSlotRequestsDataKey(level);
if (spellLevelSpellSlotRequestsDataKey !== null) {
dispatch(
characterActions.spellLevelSpellSlotsSet({
[spellLevelSpellSlotRequestsDataKey]: used,
})
);
}
};
handleSpellSlotChange = (level: number, changeAmount: number): void => {
const { spellSlots } = this.props;
const usedAmount = this.getUsedSpellSlotLevelAmount(
level,
changeAmount,
spellSlots
);
if (usedAmount !== null) {
this.handleSpellSlotSet(level, usedAmount);
}
};
handlePactSlotSet = (level: number, used: number): void => {
const { dispatch } = this.props;
const spellLevelPactMagicRequestsDataKey =
ApiRequestHelpers.getSpellLevelPactMagicRequestsDataKey(level);
if (spellLevelPactMagicRequestsDataKey !== null) {
dispatch(
characterActions.spellLevelPactMagicSlotsSet({
[spellLevelPactMagicRequestsDataKey]: used,
})
);
}
};
handlePactSlotChange = (level: number, changeAmount: number): void => {
const { pactMagicSlots } = this.props;
const usedAmount = this.getUsedSpellSlotLevelAmount(
level,
changeAmount,
pactMagicSlots
);
if (usedAmount !== null) {
this.handlePactSlotSet(level, usedAmount);
}
};
getUsedSpellSlotLevelAmount = (
level: number,
changeAmount: number,
spellSlots: Array<SpellSlotContract>
): number | null => {
const foundSlotLevel = spellSlots.find(
(spellSlot) => spellSlot.level === level
);
if (!foundSlotLevel) {
return null;
}
const usedAmount: number = foundSlotLevel.used + changeAmount;
const maxAmount: number = foundSlotLevel.available;
return HelperUtils.clampInt(usedAmount, 0, maxAmount);
};
handleSpellLimitedUseSet = (
mappingId: number,
mappingTypeId: number,
uses: number,
dataOrigin: Constants.DataOriginTypeEnum
): void => {
const { dispatch } = this.props;
dispatch(
characterActions.spellUseSet(mappingId, mappingTypeId, uses, dataOrigin)
);
};
handleItemLimitedUseSet = (
mappingId: number,
mappingTypeId: number,
uses: number
): void => {
let item: ItemManager | null = null;
item = ItemManager.getItem(mappingId);
if (item) {
item.handleItemLimitedUseSet(uses);
}
};
handleSpellDetailShow = (spell: ScaledSpell, castLevel: number): void => {
const { paneHistoryStart } = this.props;
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
const dataOrigin = SpellUtils.getDataOrigin(spell);
const dataOriginType = SpellUtils.getDataOriginType(spell);
switch (dataOriginType) {
case Constants.DataOriginTypeEnum.CLASS:
paneHistoryStart(
PaneComponentEnum.CLASS_SPELL_DETAIL,
PaneIdentifierUtils.generateClassSpell(
ClassUtils.getMappingId(
dataOrigin.primary as Hack__BaseCharClass
),
mappingId,
castLevel
)
);
break;
default:
paneHistoryStart(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId, castLevel)
);
}
}
};
handleManageSpellsOpen = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.SPELL_MANAGE);
};
handleFilterUpdate = (filterData: StateFilterData): void => {
this.setState({
filterData,
});
};
renderEmptySpellLevel = (level: number): React.ReactNode => {
let spellAbbr = FormatUtils.renderSpellLevelAbbreviation(level);
return (
<div className="ct-spells__empty-spell-level">
You do not have any {spellAbbr}-level spells or spells that scale to{" "}
{spellAbbr} level available, but you can cast lower level spells using
your {spellAbbr}-level spell slots.
</div>
);
};
renderManageButton = (): React.ReactNode => {
const { isReadonly } = this.props;
if (isReadonly) {
return null;
}
return (
<ThemeButton
style="outline"
size="medium"
onClick={this.handleManageSpellsOpen}
>
Manage Spells
</ThemeButton>
);
};
renderSpellLevels = (
levelSpells: Array<Array<ScaledSpell>>,
showLevels: Array<number>,
filterRitual: boolean = false,
filterConcentration: boolean = false
): React.ReactNode => {
const { filterData, ritualLevelSpells, concentrationLevelSpells } =
this.state;
const {
spellCasterInfo,
ruleData,
xpInfo,
abilityLookup,
isReadonly,
showNotes,
diceEnabled,
theme,
dataOriginRefData,
proficiencyBonus,
characterRollContext,
} = this.props;
return (
<React.Fragment>
{levelSpells.map((spells, level) => {
if (!showLevels.includes(ALL_LEVELS) && !showLevels.includes(level)) {
return null;
}
let levelHasSlots =
spellCasterInfo.availablePactMagicLevels.includes(level) ||
spellCasterInfo.availableSpellLevels.includes(level);
if (filterRitual) {
spells = ritualLevelSpells[level];
}
if (filterConcentration) {
spells = concentrationLevelSpells[level];
}
if ((filterRitual || filterConcentration) && !spells.length) {
return null;
}
if (!spells.length && !levelHasSlots) {
return null;
}
let filteredSpells = filterData.filteredLevelSpells[level];
if (filteredSpells && filterRitual) {
filteredSpells = filteredSpells.filter((spell) =>
SpellUtils.isRitual(spell)
);
}
if (filteredSpells && filterConcentration) {
filteredSpells = filteredSpells.filter((spell) =>
SpellUtils.getConcentration(spell)
);
}
if (!filteredSpells) {
return null;
}
let extraNode: React.ReactNode = (
<SpellsLevelCasting
level={level}
spellCasterInfo={spellCasterInfo}
onSpellSlotSet={this.handleSpellSlotSet}
onPactSlotSet={this.handlePactSlotSet}
showCastingInfo={false}
isInteractive={!isReadonly}
isDarkMode={theme?.isDarkMode}
/>
);
return (
<ContentGroup
header={FormatUtils.renderSpellLevelName(level)}
extra={extraNode}
key={level}
>
{spells.length ? (
<SpellsLevel
level={level}
spells={filteredSpells}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
xpInfo={xpInfo}
abilityLookup={abilityLookup}
onSpellClick={this.handleSpellDetailShow}
onSpellSlotChange={this.handleSpellSlotChange}
onPactSlotChange={this.handlePactSlotChange}
onSpellLimitedUseSet={this.handleSpellLimitedUseSet}
onItemLimitedUseSet={this.handleItemLimitedUseSet}
isInteractive={!isReadonly}
showNotes={showNotes}
diceEnabled={diceEnabled}
theme={theme}
dataOriginRefData={dataOriginRefData}
proficiencyBonus={proficiencyBonus}
rollContext={characterRollContext}
/>
) : (
this.renderEmptySpellLevel(level)
)}
</ContentGroup>
);
})}
</React.Fragment>
);
};
render() {
const { filterData, hasConcentrationSpells, hasRitualSpells } = this.state;
const { ruleData, abilityLookup, levelSpells, spellCasterInfo, theme } =
this.props;
return (
<section className="ct-spells">
<h2 style={visuallyHidden}>Spells</h2>
<div className="ct-spells__casting">
<SpellsLevelCasting
spellCasterInfo={spellCasterInfo}
showSlots={false}
isDarkMode={theme?.isDarkMode}
/>
</div>
<SpellsFilter
abilityLookup={abilityLookup}
levelSpells={levelSpells}
spellCasterInfo={spellCasterInfo}
ruleData={ruleData}
onDataUpdate={this.handleFilterUpdate}
callout={this.renderManageButton()}
theme={theme}
/>
{!filterData.showAdvancedFilters && (
<div className="ct-spells__content">
<TabFilter
className={styles.tabFilter}
filters={[
...levelSpells?.map((spells, level) => {
let levelHasSlots =
spellCasterInfo.availablePactMagicLevels.includes(level) ||
spellCasterInfo.availableSpellLevels.includes(level);
if (!spells.length && !levelHasSlots)
return { label: "", content: null };
let filterBadgeText: number | null = null;
const filteredSpells = filterData.filteredLevelSpells[level];
if (filterData.isFiltering && filteredSpells.length) {
filterBadgeText = filteredSpells.length;
}
return {
label: `${FormatUtils.renderSpellLevelAbbreviation(level)}`,
badge: filterBadgeText ?? undefined,
content: this.renderSpellLevels(levelSpells, [level]),
};
}),
...(hasConcentrationSpells
? [
{
label: (
<ConcentrationIcon
themeMode={theme?.isDarkMode ? "gray" : "dark"}
/>
),
content: this.renderSpellLevels(
levelSpells,
[ALL_LEVELS],
false,
true
),
},
]
: []),
...(hasRitualSpells
? [
{
label: (
<RitualIcon
themeMode={theme?.isDarkMode ? "gray" : "dark"}
/>
),
content: this.renderSpellLevels(
levelSpells,
[ALL_LEVELS],
true
),
},
]
: []),
]}
/>
</div>
)}
</section>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
levelSpells: rulesEngineSelectors.getLevelSpells(state),
spellCasterInfo: rulesEngineSelectors.getSpellCasterInfo(state),
abilityLookup: rulesEngineSelectors.getAbilityLookup(state),
ruleData: rulesEngineSelectors.getRuleData(state),
xpInfo: rulesEngineSelectors.getExperienceInfo(state),
spellSlots: rulesEngineSelectors.getSpellSlots(state),
pactMagicSlots: rulesEngineSelectors.getPactMagicSlots(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
dataOriginRefData: rulesEngineSelectors.getDataOriginRefData(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
characterRollContext:
characterRollContextSelectors.getCharacterRollContext(state),
};
}
const SpellsContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <Spells {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SpellsContainer);
@@ -0,0 +1,4 @@
import Spells from "./Spells";
export default Spells;
export { Spells };
@@ -0,0 +1,41 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import Actions from "../../Actions";
interface Props extends DispatchProp {
theme: CharacterTheme;
}
class ActionsMobile extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionMobile>
<section className="ct-actions-mobile">
<h2 style={visuallyHidden}>Actions</h2>
<MobileDivider label="Actions" theme={theme} />
<Actions showNotes={false} />
<MobileDivider isEnd={true} theme={theme} />
</section>
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(ActionsMobile);
@@ -0,0 +1,4 @@
import ActionsMobile from "./ActionsMobile";
export default ActionsMobile;
export { ActionsMobile };
@@ -0,0 +1,244 @@
import clsx from "clsx";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterPreferences,
SpeedInfo,
RuleData,
RuleDataUtils,
rulesEngineSelectors,
CharacterTheme,
characterSelectors,
CharacterStatusSlug,
} from "@dndbeyond/character-rules-engine/es";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { NumberDisplay } from "~/components/NumberDisplay";
import { PremadeCharacterEditStatus } from "~/components/PremadeCharacterEditStatus";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { InitiativeBox } from "~/subApps/sheet/components/InitiativeBox";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import { SheetAppState } from "../../../typings";
import styles from "./styles.module.css";
interface Props extends DispatchProp {
armorClass: number;
speeds: SpeedInfo;
proficiencyBonus: number;
preferences: CharacterPreferences;
ruleData: RuleData;
diceEnabled: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
isReadonly: boolean;
characterStatus: CharacterStatusSlug | null;
}
class CombatMobile extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handleProficiencyBonusClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.PROFICIENCY_BONUS);
};
handleSpeedsClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.SPEED_MANAGE);
};
handleDefensesSummaryClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.DEFENSE_MANAGE);
};
handleConditionSummaryClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.CONDITION_MANAGE);
};
handleArmorClassClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.ARMOR_MANAGE);
};
handleInitiativeClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.INITIATIVE);
};
renderProficiency = (): React.ReactNode => {
const { proficiencyBonus } = this.props;
return (
<div
className="ct-combat-mobile__extra ct-combat-mobile__extra--proficiency"
onClick={this.handleProficiencyBonusClick}
>
<div className="ct-combat-mobile__extra-heading">
<div className="ct-combat-mobile__extra-label">Proficiency</div>
</div>
<div className="ct-combat-mobile__extra-value">
<NumberDisplay
type="signed"
number={proficiencyBonus}
size={"large"}
className={clsx([
!this.props.theme.isDarkMode && styles.numberColorOverride,
])}
/>
</div>
<div className="ct-combat-mobile__extra-footer">
<div className="ct-combat-mobile__extra-label">Bonus</div>
</div>
</div>
);
};
renderSpeed = (): React.ReactNode => {
const { preferences, speeds, ruleData, theme } = this.props;
const displaySpeedValue: number =
speeds[
RuleDataUtils.getSpeedMovementKeyById(preferences.primaryMovement)
];
const displaySpeedLabel = RuleDataUtils.getMovementDescription(
preferences.primaryMovement,
ruleData
);
return (
<div
className="ct-combat-mobile__extra ct-combat-mobile__extra--speed"
onClick={this.handleSpeedsClick}
>
<div className="ct-combat-mobile__extra-heading">
<div className="ct-combat-mobile__extra-subheading">
{displaySpeedLabel}
</div>
</div>
<div className="ct-combat-mobile__extra-value">
<NumberDisplay
type="distanceInFt"
number={displaySpeedValue}
size={"large"}
className={clsx([
!this.props.theme.isDarkMode && styles.numberColorOverride,
])}
/>
</div>
<div className="ct-combat-mobile__extra-footer">
<div className="ct-combat-mobile__extra-label">Speed</div>
</div>
</div>
);
};
renderArmorClass = (): React.ReactNode => {
const { armorClass } = this.props;
return (
<div
className="ct-combat-mobile__extra ct-combat-mobile__extra--ac"
onClick={this.handleArmorClassClick}
>
<div className="ct-combat-mobile__extra-heading">
<div className="ct-combat-mobile__extra-label">Armor</div>
</div>
<div
className="ct-combat-mobile__extra-value"
data-testid="armor-class-value"
>
{armorClass}
</div>
<div className="ct-combat-mobile__extra-footer">
<div className="ct-combat-mobile__extra-label">Class</div>
</div>
</div>
);
};
render() {
const { theme, isReadonly, characterStatus } = this.props;
return (
<div
className={`ct-combat-mobile ${
theme.isDarkMode ? "ct-combat-mobile--dark-mode" : ""
}`}
>
<div className="ct-combat-mobile__extras">
{this.renderProficiency()}
{this.renderSpeed()}
<InitiativeBox isMobile={true} />
{this.renderArmorClass()}
<div className="ct-combat-mobile__extra ct-combat-mobile__extra--statuses">
<div className="ct-combat-mobile__ctas">
<div className="ct-combat-mobile__cta">
<div
className="ct-combat-mobile__cta-button"
onClick={this.handleDefensesSummaryClick}
>
Defenses
</div>
</div>
<div className="ct-combat-mobile__cta">
<div
className="ct-combat-mobile__cta-button"
onClick={this.handleConditionSummaryClick}
>
Conditions
</div>
</div>
</div>
</div>
</div>
<PremadeCharacterEditStatus
characterStatus={characterStatus}
isReadonly={isReadonly}
/>
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
armorClass: rulesEngineSelectors.getAcTotal(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
speeds: rulesEngineSelectors.getCurrentCarriedWeightSpeed(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
ruleData: rulesEngineSelectors.getRuleData(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
characterStatus: characterSelectors.getStatusSlug(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
};
}
const CombatMobileContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <CombatMobile {...props} paneHistoryStart={paneHistoryStart} />;
};
CombatMobileContainer.contextType = GameLogContext;
export default connect(mapStateToProps)(CombatMobileContainer);
@@ -0,0 +1,4 @@
import CombatMobile from "./CombatMobile";
export default CombatMobile;
export { CombatMobile };
@@ -0,0 +1,22 @@
import { visuallyHidden } from "@mui/utils";
import { FC } from "react";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import Description from "../../Description";
export const DescriptionMobile: FC = () => {
const { characterTheme } = useCharacterEngine();
return (
<SubsectionMobile className="ct-description-mobile">
<MobileDivider label={"Background"} theme={characterTheme} />
<section>
<h2 style={visuallyHidden}>Background</h2>
<Description theme={characterTheme} isVertical={true} />
</section>
<MobileDivider isEnd={true} theme={characterTheme} />
</SubsectionMobile>
);
};
@@ -0,0 +1,68 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import { Equipment } from "../../Equipment";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class EquipmentMobile extends React.PureComponent<Props> {
handleManageClick = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionMobile>
<MobileDivider
label="Inventory"
onClick={this.handleManageClick}
isReadonly={isReadonly}
theme={theme}
/>
<section className="ct-equipment-mobile">
<h2 style={visuallyHidden}>Inventory</h2>
<Equipment showNotes={false} />
</section>
<MobileDivider isEnd={true} theme={theme} />
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const EquipmentMobileContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <EquipmentMobile paneHistoryStart={paneHistoryStart} {...props} />;
};
export default connect(mapStateToProps)(EquipmentMobileContainer);
@@ -0,0 +1,4 @@
import EquipmentMobile from "./EquipmentMobile";
export default EquipmentMobile;
export { EquipmentMobile };
@@ -0,0 +1,68 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import Extras from "../../Extras";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class ExtrasMobile extends React.PureComponent<Props> {
handleManageOpen = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionMobile>
<MobileDivider
label="Extras"
onClick={this.handleManageOpen}
isReadonly={isReadonly}
theme={theme}
/>
<section className="ct-extras-mobile">
<h2 style={visuallyHidden}>Extras</h2>
<Extras showNotes={false} />
</section>
<MobileDivider isEnd={true} theme={theme} />
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const ExtrasMobileContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <ExtrasMobile paneHistoryStart={paneHistoryStart} {...props} />;
};
export default connect(mapStateToProps)(ExtrasMobileContainer);
@@ -0,0 +1,4 @@
import ExtrasMobile from "./ExtrasMobile";
export default ExtrasMobile;
export { ExtrasMobile };
@@ -0,0 +1,41 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import Features from "../../Features";
interface Props extends DispatchProp {
theme: CharacterTheme;
}
class FeaturesMobile extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionMobile>
<MobileDivider label="Features & Traits" theme={theme} />
<section className="ct-features-mobile">
<h2 style={visuallyHidden}>Features and Traits</h2>
<Features />
</section>
<MobileDivider isEnd={true} theme={theme} />
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(FeaturesMobile);
@@ -0,0 +1,4 @@
import FeaturesMobile from "./FeaturesMobile";
export default FeaturesMobile;
export { FeaturesMobile };
@@ -0,0 +1,176 @@
import { visuallyHidden } from "@mui/utils";
import { useSelector } from "react-redux";
import {
AbilitySummary,
CampaignSummary,
SavingThrowsSummary,
} from "@dndbeyond/character-components/es";
import {
AbilityManager,
DataOrigin,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { useAbilities } from "~/hooks/useAbilities";
import { getDataOriginComponentInfo } from "../../../../../../subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "../../../../../../subApps/sheet/components/Sidebar/types";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../../Shared/utils";
import MobileDivider from "../../../components/MobileDivider";
import SavingThrowsDetails from "../../../components/SavingThrowsDetails";
import Senses from "../../../components/Senses";
import SubsectionMobile from "../../../components/SubsectionMobile";
export default function MainMobile() {
const abilities = useAbilities();
const {
pane: { paneHistoryStart, paneHistoryPush },
} = useSidebar();
const preferences = useSelector(rulesEngineSelectors.getCharacterPreferences);
const savingThrowDiceAdjustments = useSelector(
rulesEngineSelectors.getSavingThrowDiceAdjustments
); // TODO: GFS move to mangers
const situationalBonusSavingThrowsLookup = useSelector(
rulesEngineSelectors.getSituationalBonusSavingThrowsLookup
); // TODO: GFS move to mangers
const passivePerception = useSelector(
rulesEngineSelectors.getPassivePerception
); // TODO: GFS move to mangers
const passiveInvestigation = useSelector(
rulesEngineSelectors.getPassiveInvestigation
); // TODO: GFS move to mangers
const passiveInsight = useSelector(rulesEngineSelectors.getPassiveInsight); // TODO: GFS move to mangers
const senses = useSelector(rulesEngineSelectors.getSenseInfo); // TODO: GFS move to mangers
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
const campaign = useSelector(rulesEngineSelectors.getCampaign);
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
const deathSaveInfo = useSelector(rulesEngineSelectors.getDeathSaveInfo);
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const gameLog = useSelector(appEnvSelectors.getGameLog);
const handleSensesManageShow = (): void => {
paneHistoryStart(PaneComponentEnum.SENSE_MANAGE);
};
const handleSavingThrowsClick = (): void => {
paneHistoryStart(PaneComponentEnum.SAVING_THROWS);
};
const handleAbilitySummaryClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY,
PaneIdentifierUtils.generateAbility(ability)
);
};
const handleAbilityClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY_SAVING_THROW,
PaneIdentifierUtils.generateAbilitySavingThrows(ability.getId())
);
};
const handleDataOriginClick = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const handleCampaignShow = (): void => {
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
};
return (
<SubsectionMobile name="Main">
<div
className={`ct-main-mobile ${
theme.isDarkMode ? "ct-main-mobile--dark-mode" : ""
}`}
>
{campaign && (
<CampaignSummary
campaign={campaign}
onCampaignShow={handleCampaignShow}
className={
theme.isDarkMode
? "ct-main-mobile__campaign-button--dark-mode"
: ""
}
theme={theme}
gameLog={gameLog}
/>
)}
<section className="ct-main-mobile__abilities">
<h2 style={visuallyHidden}>Ability Scores</h2>
{abilities.map((ability) => (
<div className="ct-main-mobile__ability" key={ability.getStatKey()}>
<AbilitySummary
ability={ability}
preferences={preferences}
theme={theme}
onClick={handleAbilitySummaryClick}
diceEnabled={diceEnabled}
rollContext={characterRollContext}
/>
</div>
))}
</section>
<MobileDivider
label="Saving Throws"
onClick={handleSavingThrowsClick}
theme={theme}
/>
<section
className={`ct-main-mobile__saving-throws ${
theme.isDarkMode ? "ct-main-mobile__saving-throws--dark-mode" : ""
}`}
>
<h2 style={visuallyHidden}>Saving Throws</h2>
<SavingThrowsSummary
abilities={abilities}
situationalBonusSavingThrowsLookup={
situationalBonusSavingThrowsLookup
}
onClick={handleAbilityClick}
diceEnabled={diceEnabled}
theme={theme}
rollContext={characterRollContext}
/>
<SavingThrowsDetails
theme={theme}
ruleData={ruleData}
savingThrowDiceAdjustments={savingThrowDiceAdjustments}
onDataOriginClick={handleDataOriginClick}
deathSaveInfo={deathSaveInfo}
/>
</section>
<MobileDivider isEnd={true} theme={theme} />
<MobileDivider
label="Senses"
onClick={handleSensesManageShow}
theme={theme}
/>
<h2 style={visuallyHidden}>Senses</h2>
<Senses
senses={senses}
theme={theme}
passiveInsight={Number(passiveInsight)}
passiveInvestigation={Number(passiveInvestigation)}
passivePerception={Number(passivePerception)}
onClick={handleSensesManageShow}
/>
<MobileDivider isEnd={true} theme={theme} />
</div>
</SubsectionMobile>
);
}
@@ -0,0 +1,4 @@
import MainMobile from "./MainMobile";
export default MainMobile;
export { MainMobile };
@@ -0,0 +1,41 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import Notes from "../../Notes";
interface Props extends DispatchProp {
theme: CharacterTheme;
}
class NotesMobile extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionMobile className="ct-notes-mobile">
<MobileDivider label="Notes" theme={theme} />
<section>
<h2 style={visuallyHidden}>Notes</h2>
<Notes />
</section>
<MobileDivider isEnd={true} theme={theme} />
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(NotesMobile);
@@ -0,0 +1,4 @@
import NotesMobile from "./NotesMobile";
export default NotesMobile;
export { NotesMobile };
@@ -0,0 +1,47 @@
import { visuallyHidden } from "@mui/utils";
import { FC } from "react";
import { useSelector } from "react-redux";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import MobileDivider from "../../../components/MobileDivider";
import ProficiencyGroups from "../../../components/ProficiencyGroups";
import SubsectionMobile from "../../../components/SubsectionMobile";
interface Props {}
export const ProficiencyGroupsMobile: FC<Props> = () => {
const {
pane: { paneHistoryStart },
} = useSidebar();
const { proficiencyGroups, characterTheme } = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const handleManageShow = (): void => {
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.PROFICIENCIES);
}
};
return (
<SubsectionMobile name="Proficiency Groups">
<MobileDivider
label={"Proficiencies & Training"}
onClick={handleManageShow}
isReadonly={isReadonly}
theme={characterTheme}
/>
<section className="ct-proficiency-groups-mobile">
<h2 style={visuallyHidden}>Proficiencies and Training</h2>
<ProficiencyGroups
proficiencyGroups={proficiencyGroups}
onClick={handleManageShow}
/>
</section>
<MobileDivider isEnd={true} theme={characterTheme} />
</SubsectionMobile>
);
};
@@ -0,0 +1,131 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
rulesEngineSelectors,
Skill,
SkillUtils,
ValueLookup,
CharacterTheme,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import { useSidebar } from "~/contexts/Sidebar";
import { PaneInfo } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../../Shared/utils";
import MobileDivider from "../../../components/MobileDivider";
import Skills from "../../../components/Skills";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
interface Props {
skills: Array<Skill>;
customSkills: Array<Skill>;
valueLookup: ValueLookup;
theme: CharacterTheme;
diceEnabled: boolean;
ruleData: RuleData;
characterRollContext: IRollContext;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class SkillsBox extends React.PureComponent<Props> {
handleManageShow = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.SKILLS);
};
handleCustomSkillClick = (skill: Skill): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.CUSTOM_SKILL,
PaneIdentifierUtils.generateCustomSkill(SkillUtils.getId(skill))
);
};
handleEmptyClick = (): void => {
this.handleManageShow();
};
handleSkillClick = (skill: Skill): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(
PaneComponentEnum.SKILL,
PaneIdentifierUtils.generateSkill(SkillUtils.getId(skill))
);
};
render() {
const {
skills,
valueLookup,
customSkills,
theme,
diceEnabled,
ruleData,
characterRollContext,
} = this.props;
return (
<SubsectionMobile name="Skills">
<section
className={`ct-skills-mobile ${
theme.isDarkMode ? "ct-skills-mobile--dark-mode" : ""
}`}
>
<MobileDivider
label="Skills"
onClick={this.handleManageShow}
theme={theme}
/>
<h2 style={visuallyHidden}>Skills</h2>
<Skills
skills={skills}
customSkills={customSkills}
valueLookup={valueLookup}
onCustomSkillClick={this.handleCustomSkillClick}
onSkillClick={this.handleSkillClick}
onEmptyClick={this.handleEmptyClick}
diceEnabled={diceEnabled}
ruleData={ruleData}
theme={theme}
rollContext={characterRollContext}
/>
<MobileDivider isEnd={true} theme={theme} />
</section>
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
skills: rulesEngineSelectors.getSkills(state),
valueLookup: rulesEngineSelectors.getCharacterValueLookup(state),
customSkills: rulesEngineSelectors.getCustomSkills(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
ruleData: rulesEngineSelectors.getRuleData(state),
characterRollContext:
characterRollContextSelectors.getCharacterRollContext(state),
};
}
const SkillsBoxContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <SkillsBox {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SkillsBoxContainer);
@@ -0,0 +1,4 @@
import SkillsMobile from "./SkillsMobile";
export default SkillsMobile;
export { SkillsMobile };
@@ -0,0 +1,68 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import MobileDivider from "../../../components/MobileDivider";
import SubsectionMobile from "../../../components/SubsectionMobile";
import { SheetAppState } from "../../../typings";
import Spells from "../../Spells";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class SpellsMobile extends React.PureComponent<Props> {
handleManageSpellsOpen = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.SPELL_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionMobile>
<MobileDivider
label="Spells"
onClick={this.handleManageSpellsOpen}
isReadonly={isReadonly}
theme={theme}
/>
<section className="ct-spells-mobile">
<h2 style={visuallyHidden}>Spells</h2>
<Spells showNotes={false} />
</section>
<MobileDivider isEnd={true} theme={theme} />
</SubsectionMobile>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const SpellsMobileContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <SpellsMobile {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SpellsMobileContainer);
@@ -0,0 +1,4 @@
import SpellsMobile from "./SpellsMobile";
export default SpellsMobile;
export { SpellsMobile };
@@ -0,0 +1,44 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { appEnvSelectors } from "../../../../Shared/selectors";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Actions from "../../Actions";
interface Props extends DispatchProp {
isReadonly: boolean;
theme: CharacterTheme;
}
class ActionsTablet extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionTablet>
<TabletBox theme={theme} header="Actions" className="ct-actions-tablet">
<section>
<h2 style={visuallyHidden}>Actions</h2>
<Actions />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(ActionsTablet);
@@ -0,0 +1,4 @@
import ActionsTablet from "./ActionsTablet";
export default ActionsTablet;
export { ActionsTablet };
@@ -0,0 +1,160 @@
import React from "react";
import { connect } from "react-redux";
import { ArmorClassBox } from "@dndbeyond/character-components/es";
import {
CharacterPreferences,
SpeedInfo,
RuleData,
rulesEngineSelectors,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { InitiativeBox } from "~/subApps/sheet/components/InitiativeBox";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import ProficiencyBonusBox from "../../../components/ProficiencyBonusBox";
import SpeedBox from "../../../components/SpeedBox";
import { SheetAppState } from "../../../typings";
interface Props {
armorClass: number;
speeds: SpeedInfo;
proficiencyBonus: number;
preferences: CharacterPreferences;
theme: CharacterTheme;
ruleData: RuleData;
isReadonly: boolean;
diceEnabled: boolean;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class CombatTablet extends React.PureComponent<Props> {
static defaultProps = {
diceEnabled: false,
};
handleProficiencyBonusClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.PROFICIENCY_BONUS);
};
handleSpeedsClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.SPEED_MANAGE);
};
handleDefensesSummaryClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.DEFENSE_MANAGE);
};
handleConditionSummaryClick = (evt: React.MouseEvent): void => {
const { paneHistoryStart } = this.props;
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
paneHistoryStart(PaneComponentEnum.CONDITION_MANAGE);
};
handleArmorClassClick = (): void => {
const { paneHistoryStart } = this.props;
paneHistoryStart(PaneComponentEnum.ARMOR_MANAGE);
};
render() {
const {
preferences,
proficiencyBonus,
speeds,
armorClass,
theme,
ruleData,
} = this.props;
return (
<div className="ct-combat-tablet">
<div className="ct-combat-tablet__extras">
<div className="ct-combat-tablet__extra ct-combat-tablet__extra--proficiency">
<ProficiencyBonusBox
theme={theme}
proficiencyBonus={proficiencyBonus}
onClick={this.handleProficiencyBonusClick}
/>
</div>
<div className="ct-combat-tablet__extra ct-combat-tablet__extra--speed">
<SpeedBox
speeds={speeds}
preferences={preferences}
theme={theme}
ruleData={ruleData}
onClick={this.handleSpeedsClick}
/>
</div>
<div className="ct-combat-tablet__extra ct-combat-tablet__extra--initiative">
<InitiativeBox isTablet />
</div>
<div className="ct-combat-tablet__extra ct-combat-tablet__extra--ac">
<ArmorClassBox
theme={theme}
armorClass={armorClass}
onClick={this.handleArmorClassClick}
/>
</div>
<div className="ct-combat-tablet__extra ct-combat-tablet__extra--statuses">
<div className="ct-combat-tablet__ctas">
<div className="ct-combat-tablet__cta">
<div
role="button"
className={`ct-combat-tablet__cta-button ${
theme.isDarkMode
? "ct-combat-tablet__cta-button--dark-mode"
: ""
}`}
onClick={this.handleDefensesSummaryClick}
>
Defenses
</div>
</div>
<div className="ct-combat-tablet__cta">
<div
role="button"
className={`ct-combat-tablet__cta-button ${
theme.isDarkMode
? "ct-combat-tablet__cta-button--dark-mode"
: ""
}`}
onClick={this.handleConditionSummaryClick}
>
Conditions
</div>
</div>
</div>
</div>
</div>
</div>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
armorClass: rulesEngineSelectors.getAcTotal(state),
proficiencyBonus: rulesEngineSelectors.getProficiencyBonus(state),
speeds: rulesEngineSelectors.getCurrentCarriedWeightSpeed(state),
preferences: rulesEngineSelectors.getCharacterPreferences(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
ruleData: rulesEngineSelectors.getRuleData(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
};
}
const CombatTabletContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <CombatTablet {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(CombatTabletContainer);
@@ -0,0 +1,4 @@
import CombatTablet from "./CombatTablet";
export default CombatTablet;
export { CombatTablet };
@@ -0,0 +1,27 @@
import { visuallyHidden } from "@mui/utils";
import { FC } from "react";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import Description from "../../Description";
export const DescriptionTablet: FC = () => {
const { characterTheme } = useCharacterEngine();
return (
<SubsectionTablet>
<TabletBox
header={"Background"}
className="ct-description-tablet"
theme={characterTheme}
>
<section>
<h2 style={visuallyHidden}>Background</h2>
<Description theme={characterTheme} />
</section>
</TabletBox>
</SubsectionTablet>
);
};
@@ -0,0 +1,70 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Equipment from "../../Equipment";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class EquipmentTablet extends React.PureComponent<Props> {
handleManageClick = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.EQUIPMENT_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionTablet>
<TabletBox
header="Inventory"
className="ct-equipment-tablet"
onHeaderClick={this.handleManageClick}
isReadonly={isReadonly}
theme={theme}
>
<section>
<h2 style={visuallyHidden}>Inventory</h2>
<Equipment />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const EquipmentTabletContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <EquipmentTablet {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(EquipmentTabletContainer);
@@ -0,0 +1,4 @@
import EquipmentTablet from "./EquipmentTablet";
export default EquipmentTablet;
export { EquipmentTablet };
@@ -0,0 +1,69 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Extras from "../../Extras";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class ExtrasTablet extends React.PureComponent<Props> {
handleManageOpen = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionTablet>
<TabletBox
header="Extras"
onHeaderClick={this.handleManageOpen}
className="ct-extras-tablet"
isReadonly={isReadonly}
theme={theme}
>
<section>
<h2 style={visuallyHidden}>Extras</h2>
<Extras />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const ExtrasTabletContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <ExtrasTablet {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(ExtrasTabletContainer);
@@ -0,0 +1,4 @@
import ExtrasTablet from "./ExtrasTablet";
export default ExtrasTablet;
export { ExtrasTablet };
@@ -0,0 +1,45 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Features from "../../Features";
interface Props extends DispatchProp {
theme: CharacterTheme;
}
class FeaturesTablet extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionTablet className="ct-features-tablet">
<TabletBox
header="Features & Traits"
className="ct-actions-tablet"
theme={theme}
>
<section>
<h2 style={visuallyHidden}>Features and Traits</h2>
<Features />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(FeaturesTablet);
@@ -0,0 +1,4 @@
import FeaturesTablet from "./FeaturesTablet";
export default FeaturesTablet;
export { FeaturesTablet };
@@ -0,0 +1,248 @@
import { visuallyHidden } from "@mui/utils";
import { useSelector } from "react-redux";
import {
AbilitySummary,
CampaignSummary,
ManageIcon,
} from "@dndbeyond/character-components/es";
import {
AbilityManager,
rulesEngineSelectors,
Skill,
SkillUtils,
} from "@dndbeyond/character-rules-engine/es";
import { useSidebar } from "~/contexts/Sidebar";
import { useAbilities } from "~/hooks/useAbilities";
import { PaneComponentEnum } from "../../../../../../subApps/sheet/components/Sidebar/types";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "../../../../Shared/selectors";
import { PaneIdentifierUtils } from "../../../../Shared/utils";
import ProficiencyGroupsBox from "../../../components/ProficiencyGroupsBox";
import SavingThrowsBox from "../../../components/SavingThrowsBox";
import SensesBox from "../../../components/SensesBox";
import SkillsBox from "../../../components/SkillsBox";
import SubsectionTablet from "../../../components/SubsectionTablet";
export default function MainTablet() {
const abilities = useAbilities();
const {
pane: { paneHistoryStart },
} = useSidebar();
const preferences = useSelector(rulesEngineSelectors.getCharacterPreferences);
const savingThrowDiceAdjustments = useSelector(
rulesEngineSelectors.getSavingThrowDiceAdjustments
);
const situationalBonusSavingThrowsLookup = useSelector(
rulesEngineSelectors.getSituationalBonusSavingThrowsLookup
);
const deathSaveInfo = useSelector(rulesEngineSelectors.getDeathSaveInfo);
const passivePerception = useSelector(
rulesEngineSelectors.getPassivePerception
);
const passiveInvestigation = useSelector(
rulesEngineSelectors.getPassiveInvestigation
);
const passiveInsight = useSelector(rulesEngineSelectors.getPassiveInsight);
const senses = useSelector(rulesEngineSelectors.getSenseInfo);
const campaign = useSelector(rulesEngineSelectors.getCampaign);
const skills = useSelector(rulesEngineSelectors.getSkills);
const valueLookup = useSelector(rulesEngineSelectors.getCharacterValueLookup);
const customSkills = useSelector(rulesEngineSelectors.getCustomSkills);
const proficiencyGroups = useSelector(
rulesEngineSelectors.getProficiencyGroups
);
const dimensions = useSelector(appEnvSelectors.getDimensions);
const theme = useSelector(rulesEngineSelectors.getCharacterTheme);
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const gameLog = useSelector(appEnvSelectors.getGameLog);
const handleSensesManageShow = (): void => {
paneHistoryStart(PaneComponentEnum.SENSE_MANAGE);
};
const handleSavingThrowsClick = (): void => {
paneHistoryStart(PaneComponentEnum.SAVING_THROWS);
};
const handleAbilitySummaryClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY,
PaneIdentifierUtils.generateAbility(ability)
);
};
const handleAbilityClick = (ability: AbilityManager): void => {
paneHistoryStart(
PaneComponentEnum.ABILITY_SAVING_THROW,
PaneIdentifierUtils.generateAbilitySavingThrows(ability.getId())
);
};
const handleProficienciesManageShow = (): void => {
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.PROFICIENCIES);
}
};
const handleCampaignShow = (): void => {
paneHistoryStart(PaneComponentEnum.CAMPAIGN);
};
const handleCustomSkillClick = (skill: Skill): void => {
paneHistoryStart(
PaneComponentEnum.CUSTOM_SKILL,
PaneIdentifierUtils.generateCustomSkill(SkillUtils.getId(skill))
);
};
const handleSkillsShow = (): void => {
paneHistoryStart(PaneComponentEnum.SKILLS);
};
const handleSkillClick = (skill: Skill): void => {
paneHistoryStart(
PaneComponentEnum.SKILL,
PaneIdentifierUtils.generateSkill(SkillUtils.getId(skill))
);
};
return (
<SubsectionTablet name="Main">
<div className="ct-main-tablet">
{campaign && (
<CampaignSummary
campaign={campaign}
onCampaignShow={handleCampaignShow}
className={
theme.isDarkMode
? "ct-main-tablet__campaign-button--dark-mode"
: ""
}
theme={theme}
gameLog={gameLog}
/>
)}
<section className="ct-main-tablet__abilities">
<h2 style={visuallyHidden}>Abilities</h2>
{abilities.map((ability) => (
<div className="ct-main-tablet__ability" key={ability.getStatKey()}>
<AbilitySummary
theme={theme}
ability={ability}
preferences={preferences}
onClick={handleAbilitySummaryClick}
diceEnabled={diceEnabled}
rollContext={characterRollContext}
/>
</div>
))}
</section>
<div className="ct-main-tablet__large-boxes">
<div className="ct-main-tablet__large-boxes-bucket">
<div className="ct-main-tablet__large-box">
<SavingThrowsBox
abilities={abilities}
ruleData={ruleData}
savingThrowDiceAdjustments={savingThrowDiceAdjustments}
situationalBonusSavingThrowsLookup={
situationalBonusSavingThrowsLookup
}
deathSaveInfo={deathSaveInfo}
onAbilityClick={handleAbilityClick}
onClick={handleSavingThrowsClick}
dimensions={dimensions}
theme={theme}
diceEnabled={diceEnabled}
rollContext={characterRollContext}
/>
<div className="ct-main-tablet__large-box-footer">
<ManageIcon
onClick={handleSavingThrowsClick}
enableTooltip={false}
theme={theme}
>
Saving Throws
</ManageIcon>
</div>
</div>
<div className="ct-main-tablet__large-box">
<SensesBox
senses={senses}
passiveInsight={Number(passiveInsight)}
passiveInvestigation={Number(passiveInvestigation)}
passivePerception={Number(passivePerception)}
onClick={handleSensesManageShow}
dimensions={dimensions}
theme={theme}
/>
<div className="ct-main-tablet__large-box-footer">
<ManageIcon
onClick={handleSensesManageShow}
enableTooltip={false}
theme={theme}
>
Senses
</ManageIcon>
</div>
</div>
<div className="ct-main-tablet__large-box">
<ProficiencyGroupsBox
dimensions={dimensions}
theme={theme}
proficiencyGroups={proficiencyGroups}
onClick={handleProficienciesManageShow}
/>
<div className="ct-main-tablet__large-box-footer">
<ManageIcon
onClick={handleProficienciesManageShow}
enableTooltip={false}
showIcon={!isReadonly}
theme={theme}
>
Proficiencies & Training
</ManageIcon>
</div>
</div>
</div>
<div className="ct-main-tablet__large-boxes-bucket">
<div className="ct-main-tablet__large-box">
<SkillsBox
skills={skills}
customSkills={customSkills}
valueLookup={valueLookup}
onCustomSkillClick={handleCustomSkillClick}
onSkillClick={handleSkillClick}
onEmptyClick={handleSkillsShow}
dimensions={dimensions}
theme={theme}
diceEnabled={diceEnabled}
ruleData={ruleData}
rollContext={characterRollContext}
/>
<div className="ct-main-tablet__large-box-footer">
<ManageIcon
onClick={handleSkillsShow}
enableTooltip={false}
theme={theme}
>
Skills
</ManageIcon>
</div>
</div>
</div>
</div>
</div>
</SubsectionTablet>
);
}
@@ -0,0 +1,4 @@
import MainTablet from "./MainTablet";
export default MainTablet;
export { MainTablet };
@@ -0,0 +1,41 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect, DispatchProp } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Notes from "../../Notes";
interface Props extends DispatchProp {
theme: CharacterTheme;
}
class NotesTablet extends React.PureComponent<Props> {
render() {
const { theme } = this.props;
return (
<SubsectionTablet>
<TabletBox header="Notes" className="ct-notes-tablet" theme={theme}>
<section>
<h2 style={visuallyHidden}>Notes</h2>
<Notes />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
export default connect(mapStateToProps)(NotesTablet);
@@ -0,0 +1,4 @@
import NotesTablet from "./NotesTablet";
export default NotesTablet;
export { NotesTablet };
@@ -0,0 +1,69 @@
import { visuallyHidden } from "@mui/utils";
import React from "react";
import { connect } from "react-redux";
import {
CharacterTheme,
rulesEngineSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { PaneInfo, useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import { appEnvSelectors } from "../../../../Shared/selectors";
import SubsectionTablet from "../../../components/SubsectionTablet";
import TabletBox from "../../../components/TabletBox";
import { SheetAppState } from "../../../typings";
import Spells from "../../Spells";
interface Props {
isReadonly: boolean;
theme: CharacterTheme;
paneHistoryStart: PaneInfo["paneHistoryStart"];
}
class SpellsTablet extends React.PureComponent<Props> {
handleManageSpellsOpen = (): void => {
const { paneHistoryStart, isReadonly } = this.props;
if (!isReadonly) {
paneHistoryStart(PaneComponentEnum.SPELL_MANAGE);
}
};
render() {
const { isReadonly, theme } = this.props;
return (
<SubsectionTablet>
<TabletBox
header="Spells"
onHeaderClick={this.handleManageSpellsOpen}
className="ct-spells-tablet"
isReadonly={isReadonly}
theme={theme}
>
<section>
<h2 style={visuallyHidden}>Spells</h2>
<Spells />
</section>
</TabletBox>
</SubsectionTablet>
);
}
}
function mapStateToProps(state: SheetAppState) {
return {
isReadonly: appEnvSelectors.getIsReadonly(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
};
}
const SpellsTabletContainer = (props) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
return <SpellsTablet {...props} paneHistoryStart={paneHistoryStart} />;
};
export default connect(mapStateToProps)(SpellsTabletContainer);
@@ -0,0 +1,4 @@
import SpellsTablet from "./SpellsTablet";
export default SpellsTablet;
export { SpellsTablet };