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 {} export const Home: FC = ({ 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 = []; 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 = []; 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 = ClassUtils.getUpdateEnableOptionalClassFeaturesSpellListIdsToRemove( classes, newIsEnabled ); const hasSpellsToRemove = spellListIds.some((id) => classSpellListSpellsLookup.hasOwnProperty(id) ); if (!hasSpellsToRemove) { handlePreferenceChange("enableOptionalClassFeatures", newIsEnabled); accept(); } else { createModal({ content: (

Are you sure you want to disable{" "} Optional Class Features for this character?

After doing so, the following spells provided by these features will be removed from your character:

), 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 = RaceUtils.getUpdateEnableOptionalOriginsSpellListIdsToRemove( species, newIsEnabled ); const hasSpellsToRemove = spellListIds.some((id) => classSpellListSpellsLookup.hasOwnProperty(id) ); if (!hasSpellsToRemove) { handlePreferenceChange("enableOptionalOrigins", newIsEnabled); accept(); } else { createModal({ content: (

Are you sure you want to disable{" "} Customized Origins for this character?

After doing so, the following spells provided by these features will be removed from your character:

), 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 = (

Are you sure you want to change your advancement method to XP progression?

You will begin with the base XP value for your current level.

); } else if ( newIdValue === Constants.PreferenceProgressionTypeEnum.MILESTONE ) { heading = "Milestone Advancement"; content = (

Are you sure you want to change your advancement method to Milestone progression?

Your current XP values will be lost.

); } 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 = (

Are you sure you want to change your hit points to the fixed value?

Any rolled hit point totals will be lost.

); } else if (newIdValue === Constants.PreferenceHitPointTypeEnum.MANUAL) { heading = "Manual Hit Points"; content = (

Are you sure you want to change your hit points manual entry?

After doing so, use Manage HP in the Class section to enter your rolled values.

); } 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 = []; let partneredSourceCheckboxes: Array = []; let allPartneredSources: Array = []; 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 ( <> handlePreferenceChange("useHomebrewContent", e.target.checked), sortOrder: 0, }, ]} showAccordion={false} /> handlePartneredSourceChangeAll( allPartneredSources, e.target.checked ) } /> ); }; return (

Character Preferences

{renderSourceToggles()} {!isVttView && ( )} handlePreferenceChange("enforceFeatRules", e.target.checked), }, { label: "Multiclass Requirements", initiallyEnabled: enforceMulticlassRules, onChange: (e) => handlePreferenceChange( "enforceMulticlassRules", e.target.checked ), }, ]} /> handlePreferenceChange("showScaledSpells", e.target.checked), }, ]} /> 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"} /> handlePreferenceChange("ignoreCoinWeight", e.target.checked), }, ]} /> 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" /> handleIntPreferenceChange("privacyType", e.target.value) } />

Version: {config.version}

); };