600 lines
18 KiB
TypeScript
600 lines
18 KiB
TypeScript
import clsx from "clsx";
|
|
import { FC, HTMLAttributes, ReactNode } from "react";
|
|
import { useSelector } from "react-redux";
|
|
import { useSearchParams } from "react-router-dom";
|
|
|
|
import {
|
|
characterActions,
|
|
CharacterUtils,
|
|
ClassUtils,
|
|
Constants,
|
|
HelperUtils,
|
|
RaceUtils,
|
|
RuleDataUtils,
|
|
} from "@dndbeyond/character-rules-engine";
|
|
|
|
import { CheckboxGroup, CheckboxInfo } from "~/components/CheckboxGroup";
|
|
import { PrivacyTypeRadio } from "~/components/PrivacyTypeRadio";
|
|
import { SourceCategoryDescription } from "~/constants";
|
|
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
|
import { useDispatch } from "~/hooks/useDispatch";
|
|
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
|
|
import { appEnvActions } from "~/tools/js/Shared/actions";
|
|
import SimpleClassSpellList from "~/tools/js/Shared/components/SimpleClassSpellList";
|
|
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
|
import config from "~/tools/js/config";
|
|
|
|
import { SelectField } from "../../components/SelectField";
|
|
import pageStyles from "../../styles/page.module.css";
|
|
import { PremadeForm } from "./PremadeForm";
|
|
import styles from "./styles.module.css";
|
|
|
|
export interface HomeProps extends HTMLAttributes<HTMLDivElement> {}
|
|
|
|
export const Home: FC<HomeProps> = ({ className, ...props }) => {
|
|
const dispatch = useDispatch();
|
|
const {
|
|
preferences,
|
|
ruleData,
|
|
activeSourceCategories,
|
|
classes,
|
|
race: species,
|
|
classSpellListSpellsLookup,
|
|
} = useCharacterEngine();
|
|
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
|
const { createModal } = useModalManager();
|
|
const [searchParams] = useSearchParams();
|
|
const isVttView = searchParams.get("view") === "vtt";
|
|
|
|
const {
|
|
useHomebrewContent,
|
|
encumbranceType,
|
|
hitPointType,
|
|
progressionType,
|
|
abilityScoreDisplayType,
|
|
privacyType,
|
|
ignoreCoinWeight,
|
|
enforceFeatRules,
|
|
enforceMulticlassRules,
|
|
showScaledSpells,
|
|
enableOptionalOrigins,
|
|
enableOptionalClassFeatures,
|
|
} = preferences;
|
|
|
|
const handleSourceCategoryChange = (
|
|
sourceId: number,
|
|
isActive: boolean
|
|
): void => {
|
|
let newSourceCats: Array<any> = [];
|
|
if (isActive) {
|
|
newSourceCats = [...activeSourceCategories, sourceId];
|
|
} else {
|
|
newSourceCats = activeSourceCategories.filter((id) => id !== sourceId);
|
|
}
|
|
|
|
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
|
|
};
|
|
|
|
const handlePartneredSourceChangeAll = (
|
|
sourceIds: number[],
|
|
isActive: boolean
|
|
): void => {
|
|
let newSourceCats: Array<any> = [];
|
|
if (isActive) {
|
|
newSourceCats = [...activeSourceCategories, ...sourceIds];
|
|
} else {
|
|
newSourceCats = activeSourceCategories.filter(
|
|
(id) => !sourceIds.includes(id)
|
|
);
|
|
}
|
|
|
|
dispatch(characterActions.activeSourceCategoriesSet(newSourceCats));
|
|
};
|
|
|
|
const handlePreferenceChange = (prefKey: string, value: boolean): void => {
|
|
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
|
|
if (typedPrefKey !== null) {
|
|
dispatch(characterActions.preferenceChoose(typedPrefKey, value));
|
|
}
|
|
};
|
|
|
|
const handleIntPreferenceChange = (prefKey: string, value: string): void => {
|
|
const typedPrefKey = CharacterUtils.getPreferenceKey(prefKey);
|
|
if (typedPrefKey !== null) {
|
|
dispatch(
|
|
characterActions.preferenceChoose(
|
|
typedPrefKey,
|
|
HelperUtils.parseInputInt(value)
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDiceToggle = (): void => {
|
|
const newDiceEnabledSetting: boolean = !diceEnabled;
|
|
|
|
try {
|
|
localStorage.setItem("dice-enabled", newDiceEnabledSetting.toString());
|
|
} catch (e) {}
|
|
|
|
dispatch(
|
|
appEnvActions.dataSet({
|
|
diceEnabled: newDiceEnabledSetting,
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleOptionalClassFeaturesPreferenceChange = (
|
|
newIsEnabled: boolean,
|
|
accept: () => void,
|
|
reject: () => void
|
|
): void => {
|
|
const spellListIds: Array<number> =
|
|
ClassUtils.getUpdateEnableOptionalClassFeaturesSpellListIdsToRemove(
|
|
classes,
|
|
newIsEnabled
|
|
);
|
|
|
|
const hasSpellsToRemove = spellListIds.some((id) =>
|
|
classSpellListSpellsLookup.hasOwnProperty(id)
|
|
);
|
|
|
|
if (!hasSpellsToRemove) {
|
|
handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
|
|
accept();
|
|
} else {
|
|
createModal({
|
|
content: (
|
|
<div>
|
|
<p>
|
|
Are you sure you want to disable{" "}
|
|
<strong>Optional Class Features</strong> for this character?
|
|
</p>
|
|
<p>
|
|
After doing so, the following spells provided by these features
|
|
will be removed from your character:
|
|
</p>
|
|
<SimpleClassSpellList
|
|
spellListIds={spellListIds}
|
|
classSpellListSpellsLookup={classSpellListSpellsLookup}
|
|
/>
|
|
</div>
|
|
),
|
|
props: {
|
|
heading: "Optional Class Features",
|
|
size: "fit-content",
|
|
variant: "remove",
|
|
onConfirm: () => {
|
|
handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled);
|
|
accept();
|
|
},
|
|
onClose: () => {
|
|
reject();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleOptionalOriginsPreferenceChange = (
|
|
newIsEnabled: boolean,
|
|
accept: () => void,
|
|
reject: () => void
|
|
): void => {
|
|
if (!species) {
|
|
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
|
accept();
|
|
|
|
return;
|
|
}
|
|
|
|
const spellListIds: Array<number> =
|
|
RaceUtils.getUpdateEnableOptionalOriginsSpellListIdsToRemove(
|
|
species,
|
|
newIsEnabled
|
|
);
|
|
|
|
const hasSpellsToRemove = spellListIds.some((id) =>
|
|
classSpellListSpellsLookup.hasOwnProperty(id)
|
|
);
|
|
|
|
if (!hasSpellsToRemove) {
|
|
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
|
accept();
|
|
} else {
|
|
createModal({
|
|
content: (
|
|
<div>
|
|
<p>
|
|
Are you sure you want to disable{" "}
|
|
<strong>Customized Origins</strong> for this character?
|
|
</p>
|
|
<p>
|
|
After doing so, the following spells provided by these features
|
|
will be removed from your character:
|
|
</p>
|
|
<SimpleClassSpellList
|
|
spellListIds={spellListIds}
|
|
classSpellListSpellsLookup={classSpellListSpellsLookup}
|
|
/>
|
|
</div>
|
|
),
|
|
props: {
|
|
heading: "Customized Origin Features",
|
|
size: "fit-content",
|
|
variant: "remove",
|
|
onConfirm: () => {
|
|
handlePreferenceChange("enableOptionalOrigins", newIsEnabled);
|
|
accept();
|
|
},
|
|
onClose: () => {
|
|
reject();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleProgressionPreferenceChangePromise = (
|
|
newValue: string,
|
|
oldValue: string,
|
|
accept: () => void,
|
|
reject: () => void
|
|
): void => {
|
|
const prefKey = CharacterUtils.getPreferenceKey("progressionType");
|
|
const newIdValue = HelperUtils.parseInputInt(newValue);
|
|
|
|
let content: ReactNode;
|
|
let heading: string | null = null;
|
|
if (newIdValue === Constants.PreferenceProgressionTypeEnum.XP) {
|
|
heading = "XP Advancement";
|
|
content = (
|
|
<div>
|
|
<p>
|
|
Are you sure you want to change your advancement method to XP
|
|
progression?
|
|
</p>
|
|
<p>You will begin with the base XP value for your current level.</p>
|
|
</div>
|
|
);
|
|
} else if (
|
|
newIdValue === Constants.PreferenceProgressionTypeEnum.MILESTONE
|
|
) {
|
|
heading = "Milestone Advancement";
|
|
content = (
|
|
<div>
|
|
<p>
|
|
Are you sure you want to change your advancement method to Milestone
|
|
progression?
|
|
</p>
|
|
<p>Your current XP values will be lost.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (prefKey !== null && heading) {
|
|
createModal({
|
|
content,
|
|
props: {
|
|
heading,
|
|
size: "fit-content",
|
|
onConfirm: () => {
|
|
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
|
|
accept();
|
|
},
|
|
onClose: () => {
|
|
reject();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleHitPointPreferenceChangePromise = (
|
|
newValue: string,
|
|
oldValue: string,
|
|
accept: () => void,
|
|
reject: () => void
|
|
): void => {
|
|
const prefKey = CharacterUtils.getPreferenceKey("hitPointType");
|
|
const newIdValue = HelperUtils.parseInputInt(newValue);
|
|
|
|
let content: ReactNode;
|
|
let heading: string | null = null;
|
|
if (newIdValue === Constants.PreferenceHitPointTypeEnum.FIXED) {
|
|
heading = "Fixed Hit Points";
|
|
content = (
|
|
<div>
|
|
<p>
|
|
Are you sure you want to change your hit points to the fixed value?
|
|
</p>
|
|
<p>Any rolled hit point totals will be lost.</p>
|
|
</div>
|
|
);
|
|
} else if (newIdValue === Constants.PreferenceHitPointTypeEnum.MANUAL) {
|
|
heading = "Manual Hit Points";
|
|
content = (
|
|
<div>
|
|
<p>Are you sure you want to change your hit points manual entry?</p>
|
|
<p>
|
|
After doing so, use Manage HP in the Class section to enter your
|
|
rolled values.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (prefKey !== null && heading) {
|
|
createModal({
|
|
content,
|
|
props: {
|
|
heading,
|
|
size: "fit-content",
|
|
onConfirm: () => {
|
|
dispatch(characterActions.preferenceChoose(prefKey, newIdValue));
|
|
accept();
|
|
},
|
|
onClose: () => {
|
|
reject();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const renderSourceToggles = () => {
|
|
let sourceToggles: Array<CheckboxInfo> = [];
|
|
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
|
|
let allPartneredSources: Array<number> = [];
|
|
|
|
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
|
|
if (!sourceCategory.isToggleable) {
|
|
return null;
|
|
}
|
|
|
|
const checkbox: CheckboxInfo = {
|
|
label: `${sourceCategory.name}`,
|
|
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
|
|
onChange: (e) =>
|
|
handleSourceCategoryChange(sourceCategory.id, e.target.checked),
|
|
sortOrder: sourceCategory.sortOrder,
|
|
description: sourceCategory.description ?? "",
|
|
};
|
|
|
|
if (sourceCategory.isPartneredContent) {
|
|
delete checkbox.description; // remove description from partnered content
|
|
partneredSourceCheckboxes.push(checkbox);
|
|
allPartneredSources.push(sourceCategory.id);
|
|
} else {
|
|
sourceToggles.push(checkbox);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Sources"
|
|
description={SourceCategoryDescription.official}
|
|
name="sources"
|
|
checkboxes={[
|
|
...sourceToggles,
|
|
{
|
|
label: "Homebrew",
|
|
description: SourceCategoryDescription.homebrew,
|
|
initiallyEnabled: useHomebrewContent,
|
|
onChange: (e) =>
|
|
handlePreferenceChange("useHomebrewContent", e.target.checked),
|
|
sortOrder: 0,
|
|
},
|
|
]}
|
|
showAccordion={false}
|
|
/>
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Partnered Content"
|
|
description={SourceCategoryDescription.partnered}
|
|
name="partneredContent"
|
|
checkboxes={partneredSourceCheckboxes}
|
|
accordionSummary="Choose Partners"
|
|
showAccordion={true}
|
|
toggleAllLabel="Enable All Partnered Content"
|
|
onToggleAll={(e) =>
|
|
handlePartneredSourceChangeAll(
|
|
allPartneredSources,
|
|
e.target.checked
|
|
)
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={clsx([pageStyles.page, styles.home, className])} {...props}>
|
|
<PremadeForm />
|
|
<h2 className={pageStyles.title}>Character Preferences</h2>
|
|
<div className="home-manage-preferences">
|
|
{renderSourceToggles()}
|
|
{!isVttView && (
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Dice Rolling"
|
|
description="Enables digital dice rolling for all characters on this browser"
|
|
name="dice-rolling"
|
|
checkboxes={[
|
|
{
|
|
label: "Enable Dice Rolling",
|
|
initiallyEnabled: diceEnabled,
|
|
onChange: handleDiceToggle,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Optional Features"
|
|
name="optional-features"
|
|
description="Allow or restrict optional features for this character"
|
|
checkboxes={[
|
|
{
|
|
label: "Optional Class Features",
|
|
initiallyEnabled: enableOptionalClassFeatures,
|
|
onChangeConfirm: handleOptionalClassFeaturesPreferenceChange,
|
|
},
|
|
{
|
|
label: "Customize Your Origin",
|
|
initiallyEnabled: enableOptionalOrigins,
|
|
onChangeConfirm: handleOptionalOriginsPreferenceChange,
|
|
},
|
|
]}
|
|
/>
|
|
<SelectField
|
|
label="Advancement Type"
|
|
description="Story-based character progression / XP-based character progression"
|
|
onChangeConfirm={handleProgressionPreferenceChangePromise}
|
|
hidePlaceholderOption={true}
|
|
options={[
|
|
{
|
|
label: "Milestone",
|
|
value: Constants.PreferenceProgressionTypeEnum.MILESTONE,
|
|
},
|
|
{ label: "XP", value: Constants.PreferenceProgressionTypeEnum.XP },
|
|
]}
|
|
value={"" + progressionType}
|
|
name={"advancementType"}
|
|
/>
|
|
<SelectField
|
|
label="Hit Point Type"
|
|
description="When leveling up, increase hit points by the fixed value for your chosen class or manually enter a rolled value"
|
|
onChangeConfirm={handleHitPointPreferenceChangePromise}
|
|
hidePlaceholderOption={true}
|
|
options={[
|
|
{
|
|
label: "Fixed",
|
|
value: Constants.PreferenceHitPointTypeEnum.FIXED,
|
|
},
|
|
{
|
|
label: "Manual",
|
|
value: Constants.PreferenceHitPointTypeEnum.MANUAL,
|
|
},
|
|
]}
|
|
value={"" + hitPointType}
|
|
name={"hitPointType"}
|
|
/>
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Use Prerequisites"
|
|
description="Allow or restrict choices based on rule prerequisites for the following for this character"
|
|
name="use-prerequisites"
|
|
checkboxes={[
|
|
{
|
|
label: "Feats",
|
|
initiallyEnabled: enforceFeatRules,
|
|
onChange: (e) =>
|
|
handlePreferenceChange("enforceFeatRules", e.target.checked),
|
|
},
|
|
{
|
|
label: "Multiclass Requirements",
|
|
initiallyEnabled: enforceMulticlassRules,
|
|
onChange: (e) =>
|
|
handlePreferenceChange(
|
|
"enforceMulticlassRules",
|
|
e.target.checked
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Show Level-Scaled Spells"
|
|
description="Display and highlight available spells to cast with higher level spell slots"
|
|
name="show-scaled-spells"
|
|
checkboxes={[
|
|
{
|
|
label: "Show Level-Scaled Spells",
|
|
initiallyEnabled: showScaledSpells,
|
|
onChange: (e) =>
|
|
handlePreferenceChange("showScaledSpells", e.target.checked),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<SelectField
|
|
label="Encumbrance Type"
|
|
description="Use the standard encumbrance rules / Disable the encumbrance display / Use the more detailed rules for encumbrance"
|
|
onChange={(value: string) =>
|
|
handleIntPreferenceChange("encumbranceType", value)
|
|
}
|
|
hidePlaceholderOption={true}
|
|
options={[
|
|
{
|
|
label: "Use Encumbrance",
|
|
value: Constants.PreferenceEncumbranceTypeEnum.ENCUMBRANCE,
|
|
},
|
|
{
|
|
label: "No Encumbrance",
|
|
value: Constants.PreferenceEncumbranceTypeEnum.NONE,
|
|
},
|
|
{
|
|
label: "Variant Encumbrance",
|
|
value: Constants.PreferenceEncumbranceTypeEnum.VARIANT,
|
|
},
|
|
]}
|
|
value={"" + encumbranceType}
|
|
name={"encumbranceType"}
|
|
/>
|
|
<CheckboxGroup
|
|
className={styles.checkboxGroup}
|
|
title="Ignore Coin Weight"
|
|
description="Coins do not count against your total weight carried (50 coins weigh 1 lb.)"
|
|
name="ignore-coin-weight"
|
|
checkboxes={[
|
|
{
|
|
label: "Ignore Coin Weight",
|
|
initiallyEnabled: ignoreCoinWeight,
|
|
onChange: (e) =>
|
|
handlePreferenceChange("ignoreCoinWeight", e.target.checked),
|
|
},
|
|
]}
|
|
/>
|
|
<SelectField
|
|
label="Ability Score/Modifier Display"
|
|
description="Reverse the arrangement of ability modifiers and scores"
|
|
onChange={(value: string) =>
|
|
handleIntPreferenceChange("abilityScoreDisplayType", value)
|
|
}
|
|
hidePlaceholderOption={true}
|
|
options={[
|
|
{
|
|
label: "Modifiers Top",
|
|
value:
|
|
Constants.PreferenceAbilityScoreDisplayTypeEnum.MODIFIERS_TOP,
|
|
},
|
|
{
|
|
label: "Scores Top",
|
|
value: Constants.PreferenceAbilityScoreDisplayTypeEnum.SCORES_TOP,
|
|
},
|
|
]}
|
|
value={"" + abilityScoreDisplayType}
|
|
name="abilityScoreDisplayType"
|
|
/>
|
|
|
|
<PrivacyTypeRadio
|
|
className={styles.privacyTypeRadio}
|
|
initialValue={privacyType}
|
|
onChange={(e) =>
|
|
handleIntPreferenceChange("privacyType", e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<hr className={pageStyles.divider} />
|
|
<div className={styles.versionInfo}>
|
|
<p>
|
|
Version: <span>{config.version}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|