New source found from dndbeyond.com
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
FocusEvent,
|
||||
ChangeEvent,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
PremadeInfo,
|
||||
PremadeInfoStatus,
|
||||
RuleDataUtils,
|
||||
SimpleSourcedDefinitionContract,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { RadioGroup } from "~/components/RadioGroup";
|
||||
import { Select } from "~/components/Select";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { useSource } from "~/hooks/useSource";
|
||||
import { InputField } from "~/subApps/builder/components/InputField";
|
||||
import { useModalManager } from "~/subApps/builder/contexts/ModalManager";
|
||||
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import pageStyles from "../../../styles/page.module.css";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface PremadeFormProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const PremadeForm: FC<PremadeFormProps> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { premadeInfo, characterId, ruleData } = useCharacterEngine();
|
||||
const userRoles = useSelector(appEnvSelectors.getUserRoles);
|
||||
const { getGroupedOptionsBySourceCategory } = useSource();
|
||||
const { createModal } = useModalManager();
|
||||
|
||||
const [premadeState, setPremadeState] = useState<PremadeInfo | null>(
|
||||
premadeInfo
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPremadeState(premadeInfo);
|
||||
}, [premadeInfo]);
|
||||
|
||||
const canAccessPremadeForm =
|
||||
userRoles?.includes(UserRoles.LOREKEEPER) ||
|
||||
userRoles?.includes(UserRoles.ADMIN);
|
||||
|
||||
const inputAttributes = {
|
||||
disabled: !premadeState,
|
||||
} as HTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const sourceOptions: Array<SimpleSourcedDefinitionContract> =
|
||||
RuleDataUtils.getSourceData(ruleData).map((sourceData) => {
|
||||
return {
|
||||
id: sourceData.id,
|
||||
name: sourceData.description,
|
||||
sources: [
|
||||
{
|
||||
pageNumber: null,
|
||||
sourceId: sourceData.id,
|
||||
sourceType: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const premadePrimarySource = premadeState?.sources?.find(
|
||||
(source) => source.isPrimary
|
||||
);
|
||||
|
||||
const handleAddPremadeInfo = (): void => {
|
||||
const defaultData: PremadeInfo = {
|
||||
characterId: characterId,
|
||||
publishStatus: PremadeInfoStatus.DRAFT,
|
||||
sources: [],
|
||||
definition: {
|
||||
longDescription: null,
|
||||
shortDescription: null,
|
||||
imageUrl: null,
|
||||
imageAltText: null,
|
||||
mobileImageUrl: null,
|
||||
mobileImageAccessibility: null,
|
||||
themeColor: null,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(characterActions.premadeInfoAdd(defaultData));
|
||||
};
|
||||
|
||||
const handleDeletePremadeInfo = (): void => {
|
||||
dispatch(characterActions.premadeInfoDelete(characterId));
|
||||
};
|
||||
|
||||
const handlePremadeInfoChanged = (premadeInfo: PremadeInfo): void => {
|
||||
setPremadeState(premadeInfo);
|
||||
dispatch(characterActions.premadeInfoUpdate(premadeInfo));
|
||||
};
|
||||
|
||||
const handlePremadePublishStatusChange = (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
newValue: string,
|
||||
oldValue: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
if (premadeState) {
|
||||
const content = (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to change the publish status from{" "}
|
||||
<strong>{oldValue}</strong> to <strong>{newValue}</strong>?
|
||||
</p>
|
||||
{newValue === PremadeInfoStatus.PUBLISHED && (
|
||||
<p>
|
||||
<strong>Warning:</strong> Publishing this character will make it
|
||||
publically visible to users on D&D Beyond!! Proceed with Caution!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
createModal({
|
||||
content,
|
||||
props: {
|
||||
heading: "Confirm Premade Status Change",
|
||||
size: "fit-content",
|
||||
onConfirm: () => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
publishStatus: newValue as PremadeInfoStatus,
|
||||
});
|
||||
accept();
|
||||
},
|
||||
onClose: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isReadyToPublish = (): boolean => {
|
||||
if (!premadeState) return false;
|
||||
if (premadeState.publishStatus === PremadeInfoStatus.PUBLISHED) return true;
|
||||
// check that all fields are filled out before enabling publish
|
||||
return (
|
||||
premadeState.sources.length >= 1 &&
|
||||
premadeState.definition.longDescription !== null &&
|
||||
premadeState.definition.imageUrl !== null &&
|
||||
premadeState.definition.mobileImageUrl !== null &&
|
||||
premadeState.definition.shortDescription !== null &&
|
||||
premadeState.definition.themeColor !== null
|
||||
);
|
||||
};
|
||||
|
||||
const notReadyToPublishDescription = (): string => {
|
||||
if (!premadeState) return "";
|
||||
|
||||
if (premadeState.sources.length === 0) {
|
||||
if (premadeState.publishStatus !== PremadeInfoStatus.PUBLISHED) {
|
||||
return "You must select a source before you can publish this character.";
|
||||
} else {
|
||||
return "This premade was published before sources were required. A source should be added!";
|
||||
}
|
||||
}
|
||||
|
||||
const missingFields: string[] = [];
|
||||
if (!premadeState.definition.longDescription) {
|
||||
missingFields.push("Character Description");
|
||||
}
|
||||
if (!premadeState.definition.imageUrl) {
|
||||
missingFields.push("Image Url");
|
||||
}
|
||||
if (!premadeState.definition.mobileImageUrl) {
|
||||
missingFields.push("Mobile Image Url");
|
||||
}
|
||||
if (!premadeState.definition.shortDescription) {
|
||||
missingFields.push("Mobile Description");
|
||||
}
|
||||
if (!premadeState.definition.themeColor) {
|
||||
missingFields.push("Mobile Card Theme Color");
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return `The following fields are required to publish: ${missingFields.join(
|
||||
", "
|
||||
)}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
return canAccessPremadeForm ? (
|
||||
<div className={clsx([styles.premadeForm, className])} {...props}>
|
||||
<h2 className={pageStyles.title}>
|
||||
Premade Character Preferences - Lorekeepers Only
|
||||
</h2>
|
||||
<div className={styles.form}>
|
||||
<div>
|
||||
<label className={styles.label} id="toggle-premade-character">
|
||||
Premade Character
|
||||
</label>
|
||||
<p className={styles.toggleDescription}>
|
||||
Toggle on to make this a premade character. You cannot disable this
|
||||
if the premade status is Published.
|
||||
</p>
|
||||
<Toggle
|
||||
onClick={(isEnabled: boolean) => {
|
||||
isEnabled ? handleAddPremadeInfo() : handleDeletePremadeInfo();
|
||||
}}
|
||||
checked={!!premadeState}
|
||||
color="secondary"
|
||||
aria-labelledby="toggle-premade-character"
|
||||
disabled={
|
||||
premadeState?.publishStatus === PremadeInfoStatus.PUBLISHED
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{premadeState && (
|
||||
<>
|
||||
<RadioGroup
|
||||
name="premadeStatus"
|
||||
title="Publish Status"
|
||||
description="Mark this character as published when you are ready for it to be publically visible."
|
||||
initialValue={premadeState.publishStatus}
|
||||
options={[
|
||||
{ label: "Draft", value: PremadeInfoStatus.DRAFT },
|
||||
{
|
||||
label: "Published",
|
||||
value: PremadeInfoStatus.PUBLISHED,
|
||||
description: notReadyToPublishDescription(),
|
||||
disabled: !isReadyToPublish(),
|
||||
},
|
||||
{ label: "Archived", value: PremadeInfoStatus.ARCHIVED },
|
||||
]}
|
||||
disabled={!premadeState}
|
||||
onChangeConfirm={handlePremadePublishStatusChange}
|
||||
/>
|
||||
<div>
|
||||
<label className={styles.label} htmlFor="premade-source-mapping">
|
||||
Source
|
||||
</label>
|
||||
<Select
|
||||
id="premade-source-mapping"
|
||||
name="premade-source-mapping"
|
||||
options={getGroupedOptionsBySourceCategory(sourceOptions)}
|
||||
value={premadePrimarySource?.sourceId || null}
|
||||
onChange={(value) => {
|
||||
const sourceData = RuleDataUtils.getSourceDataInfo(
|
||||
Number(value),
|
||||
ruleData
|
||||
);
|
||||
const sources = sourceData
|
||||
? [
|
||||
{
|
||||
sourceId: Number(value),
|
||||
sourceName: sourceData.description || "Unknown",
|
||||
displayOrder: sourceData.premadeDisplayOrder || 1,
|
||||
isPrimary: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
sources,
|
||||
});
|
||||
}}
|
||||
placeholder={"-- Choose a Source --"}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
label="Character Description"
|
||||
initialValue={premadeState.definition.longDescription}
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
longDescription: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
label="Image Url"
|
||||
initialValue={premadeState.definition.imageUrl}
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
imageUrl: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
label="Image Alt Text"
|
||||
initialValue={premadeState.definition.imageAltText}
|
||||
maxLength={150}
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
imageAltText: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<h3 className={styles.mobileHeader}>Mobile Specific Fields</h3>
|
||||
<InputField
|
||||
label="Mobile Description"
|
||||
initialValue={premadeState.definition.shortDescription}
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
shortDescription: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
label="Mobile Image Url"
|
||||
initialValue={premadeState.definition.mobileImageUrl}
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
mobileImageUrl: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
label="Mobile Alt Text"
|
||||
initialValue={premadeState.definition.mobileImageAccessibility}
|
||||
inputProps={inputAttributes}
|
||||
maxLength={150}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
mobileImageAccessibility: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<InputField
|
||||
className={styles.colorPicker}
|
||||
label="Mobile Card Theme Color"
|
||||
type="color"
|
||||
initialValue={premadeState.definition.themeColor || "#8A9BA8"} // default color provided by Mobile team
|
||||
inputProps={inputAttributes}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
handlePremadeInfoChanged({
|
||||
...premadeState,
|
||||
definition: {
|
||||
...premadeState.definition,
|
||||
themeColor: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
Reference in New Issue
Block a user