commit 8df9031d27eeb505a0669b23b71e4aa86b8dbed9 Author: David Kruger Date: Wed May 28 11:50:03 2025 -0700 Grabbed dndbeyond's source code ``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..da2dbb1 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +This is the main javascript logic from the dndbeyond.com character sheets + +The data is downloaded using `sourcemapper` from https://github.com/denandz/sourcemapper + +Given the main JS file URL, the source code can be downloaded and mapped using: +``` +~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js +``` diff --git a/ddb_main/components/AbilityScoreManager/AbilityScoreManager.tsx b/ddb_main/components/AbilityScoreManager/AbilityScoreManager.tsx new file mode 100644 index 0000000..cfbd74f --- /dev/null +++ b/ddb_main/components/AbilityScoreManager/AbilityScoreManager.tsx @@ -0,0 +1,270 @@ +import clsx from "clsx"; +import { HTMLAttributes, ReactNode, useState } from "react"; + +import { + AbilityManager, + AbilityScoreSupplierData, + DataOrigin, + DataOriginUtils, + HelperUtils, +} from "@dndbeyond/character-rules-engine/es"; + +import { NumberDisplay } from "~/components/NumberDisplay"; +import { useSidebar } from "~/contexts/Sidebar"; +import { useCharacterEngine } from "~/hooks/useCharacterEngine"; +import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils"; +import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types"; +import DataOriginName from "~/tools/js/smartComponents/DataOriginName"; +import { AbilityIcon } from "~/tools/js/smartComponents/Icons"; + +import styles from "./styles.module.css"; + +interface Props extends HTMLAttributes { + ability: AbilityManager; + showHeader?: boolean; + isReadonly: boolean; + isBuilder?: boolean; +} + +export function AbilityScoreManager({ + ability, + isReadonly, + showHeader = true, + className = "", + isBuilder = false, + ...props +}: Props) { + const { characterTheme: theme, originRef } = useCharacterEngine(); + const { + pane: { paneHistoryPush }, + } = useSidebar(); + + const [overrideScore, setOverrideScore] = useState( + ability.getOverrideScore() + ); + const [otherBonus, setOtherBonus] = useState(ability.getOtherBonus()); + + const handleOtherBonusBlur = ( + evt: React.FocusEvent + ): void => { + let value: number | null = HelperUtils.parseInputInt(evt.target.value); + setOtherBonus(ability.handleOtherBonusChange(value)); + }; + + const handleOverrideScoreBlur = ( + evt: React.FocusEvent + ): void => { + const value: number | null = HelperUtils.parseInputInt(evt.target.value); + setOverrideScore(ability.handleOverrideScoreChange(value)); + }; + + const handleOtherBonusChange = ( + evt: React.ChangeEvent + ): void => { + setOtherBonus(HelperUtils.parseInputInt(evt.target.value)); + }; + + const handleOverrideScoreChange = ( + evt: React.ChangeEvent + ): void => { + setOverrideScore(HelperUtils.parseInputInt(evt.target.value)); + }; + + const handleDataOriginClick = (dataOrigin: DataOrigin) => { + let component = getDataOriginComponentInfo(dataOrigin); + if (component.type !== PaneComponentEnum.ERROR_404) { + paneHistoryPush(component.type, component.identifiers); + } + }; + + const label = ability.getLabel(); + const modifier = ability.getModifier(); + const baseScore = ability.getBaseScore(); + const statId = ability.getId(); + const totalScore = ability.getTotalScore(); + const speciesBonus = ability.getRacialBonus(); + const classBonuses = ability.getClassBonuses(); + const miscBonus = ability.getMiscBonus(); + const stackingBonus = ability.getStackingBonus(); + const setScore = ability.getSetScore(); + + const allStatsBonusSuppliers = ability.getAllStatBonusSuppliers(); + const statSetScoreSuppliers = ability.getStatSetScoreSuppliers(); + const stackingBonusSuppliers = ability.getStackingBonusSuppliers(); + + const getSupplierData = ( + suppliers: Array + ): ReactNode => { + return ( +
+ {suppliers.map((supplier) => { + const origin = supplier.dataOrigin; + let expandedOrigin: DataOrigin | null = null; + + if (supplier.expandedOriginRef) { + const primaryOrigin = DataOriginUtils.getRefPrimary( + supplier.expandedOriginRef, + originRef + ); + + if (primaryOrigin && primaryOrigin["componentId"] !== null) { + expandedOrigin = primaryOrigin["dataOrigin"]; + } + } + return ( +
+
+ + {expandedOrigin && ( + + {" "} + ( + + ) + + )} +
+
+ ( + {supplier.type === "set" ? ( + supplier.value + ) : ( + + )} + ) +
+
+ ); + })} +
+ ); + }; + + return ( +
+ {showHeader && ( +
+ +
{label}
+
+ )} +
+
+
Total Score
+
+ {totalScore === null ? "--" : totalScore} +
+
+
+
Modifier
+
+ +
+
+
+
Base Score
+
+ {baseScore === null ? "--" : baseScore} +
+
+
0 && styles.hasSuppliers + )} + > +
+
Bonus
+
+
+ +
+
+ {getSupplierData(allStatsBonusSuppliers)} + +
0 && styles.hasSuppliers + )} + > +
+
Set Score
+
+
{setScore}
+
+ {getSupplierData(statSetScoreSuppliers)} + +
0 && styles.hasSuppliers + )} + > +
+
Stacking Bonus
+
+
+ +
+
+ {getSupplierData(stackingBonusSuppliers)} +
+ +
+
+
Other Modifier
+
+ +
+
+
+
Override Score
+
+ +
+
+
+
+ ); +} diff --git a/ddb_main/components/Accordion/Accordion.tsx b/ddb_main/components/Accordion/Accordion.tsx new file mode 100644 index 0000000..9231649 --- /dev/null +++ b/ddb_main/components/Accordion/Accordion.tsx @@ -0,0 +1,148 @@ +import clsx from "clsx"; +import { + FC, + HTMLAttributes, + MouseEvent, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { v4 as uuidv4 } from "uuid"; + +import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg"; + +import styles from "./styles.module.css"; + +export interface AccordionProps extends HTMLAttributes { + variant?: "default" | "text" | "paper"; + summary: ReactNode; + description?: ReactNode; + size?: "small" | "medium"; + forceShow?: boolean; + useTheme?: boolean; + resetOpen?: boolean; + override?: boolean | null; + handleIsOpen?: (id: string, isOpen: boolean) => void; + summaryAction?: ReactNode; + summaryImage?: string; + summaryImageAlt?: string; + summaryImageSize?: number; + summaryMetaItems?: Array; + showAlert?: boolean; +} + +export const Accordion: FC = ({ + className, + summary, + description, + size = "medium", + variant = "default", + children, + forceShow = false, + // style, + useTheme, + resetOpen, + handleIsOpen, + override = null, + id = "", + summaryAction, + summaryImage, + summaryImageAlt, + summaryImageSize = 32, + summaryMetaItems, + showAlert, + ...props +}) => { + const summaryRef = useRef(null); + const contentRef = useRef(null); + const loaded = useRef(false); + const [isOpen, setIsOpen] = useState(forceShow); + const [height, setHeight] = useState(); + + const handleToggle = (e: MouseEvent) => { + e.preventDefault(); + setIsOpen((current) => { + if (handleIsOpen && id) handleIsOpen(id, !current); + return !current; + }); + }; + + useEffect(() => { + const summaryHeight = summaryRef.current?.clientHeight; + const contentHeight = contentRef.current?.getBoundingClientRect().height; + + if (isOpen) { + // If the dialog is open and it's not the first load, set the height to the summary + content + if (summaryHeight && contentHeight && loaded.current === true) + setHeight(summaryHeight + contentHeight); + } else { + // If the dialog is closed, set the height to the summary + if (summaryHeight) setHeight(summaryHeight); + } + loaded.current = true; + }, [isOpen, resetOpen]); + + useEffect(() => { + if (resetOpen && !isOpen) { + setIsOpen(true); + } + }, [resetOpen]); + + useEffect(() => { + if (override !== null) { + setIsOpen(override); + } + }, [override]); + + return ( +
+ + {summaryImage && ( + {summaryImageAlt} + )} +
+
+ {summary} + {summaryMetaItems && ( +
+ {summaryMetaItems.map((metaItem, idx) => ( +
+ {metaItem} +
+ ))} +
+ )} +
+
{summaryAction}
+ +
+ {description &&
{description}
} +
+
+ {children} +
+
+ ); +}; diff --git a/ddb_main/components/Button/Button.tsx b/ddb_main/components/Button/Button.tsx new file mode 100644 index 0000000..d24ca24 --- /dev/null +++ b/ddb_main/components/Button/Button.tsx @@ -0,0 +1,59 @@ +import clsx from "clsx"; +import { FC } from "react"; + +import { + Button as TtuiButton, + ButtonProps as TtuiButtonProps, +} from "@dndbeyond/ttui/components/Button"; + +import { useCharacterTheme } from "~/contexts/CharacterTheme"; +import { ThemeMode } from "~/types"; + +import styles from "./styles.module.css"; + +export interface ButtonProps + extends Omit { + size?: "xx-small" | TtuiButtonProps["size"]; + themed?: boolean; + forceThemeMode?: ThemeMode; + variant?: "builder" | "builder-text" | TtuiButtonProps["variant"]; + color?: "builder-green" | TtuiButtonProps["color"]; +} + +export const Button: FC = ({ + className, + size = "medium", + themed, + variant = "solid", + forceThemeMode, + color, + ...props +}) => { + // Check if the button has a custom size + const isCustomSize = size === "xx-small"; + const isCustomVariant = variant === "builder" || variant === "builder-text"; + const isCustomColor = color === "builder-green"; + const { isDarkMode } = useCharacterTheme(); + + return ( + + ); +}; diff --git a/ddb_main/components/Checkbox/Checkbox.tsx b/ddb_main/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..555ac17 --- /dev/null +++ b/ddb_main/components/Checkbox/Checkbox.tsx @@ -0,0 +1,82 @@ +import clsx from "clsx"; +import { ChangeEvent, FC, useEffect, useState } from "react"; + +import { + Checkbox as TtuiCheckbox, + CheckboxProps as TtuiCheckboxProps, +} from "@dndbeyond/ttui/components/Checkbox"; + +import styles from "./styles.module.css"; + +interface CheckboxProps extends TtuiCheckboxProps { + themed?: boolean; + darkMode?: boolean; + variant?: "default" | "builder" | "sidebar"; + onClick?: (isEnabled: boolean) => void; + onChangePromise?: ( + newIsEnabled: boolean, + accept: () => void, + reject: () => void + ) => void; +} + +export const Checkbox: FC = ({ + className, + themed, + variant = "default", + darkMode, + checked = false, + onChangePromise, + onClick, + disabled, + ...props +}) => { + const [isChecked, setIsChecked] = useState(checked); + + useEffect(() => { + setIsChecked(checked); + }, [checked]); + + const handleChange = (evt: ChangeEvent): void => { + evt.stopPropagation(); + evt.nativeEvent.stopImmediatePropagation(); + + const newCheckedState = !isChecked; + + // promise-based logic + if (onChangePromise) { + onChangePromise( + newCheckedState, + () => { + // Promise accepted, update the state + setIsChecked(newCheckedState); + }, + () => { + // Promise rejected, do nothing + } + ); + } else { + // Update the state immediately if no promise handling - only IF onClick is provided + if (onClick) { + onClick(newCheckedState); + setIsChecked(newCheckedState); + } + } + }; + return ( + + ); +}; diff --git a/ddb_main/components/CollapsibleContent/CollapsibleContent.tsx b/ddb_main/components/CollapsibleContent/CollapsibleContent.tsx new file mode 100644 index 0000000..d06b0e7 --- /dev/null +++ b/ddb_main/components/CollapsibleContent/CollapsibleContent.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, ReactNode, useState } from "react"; + +import { HtmlContent } from "../HtmlContent"; +import styles from "./styles.module.css"; + +interface Props extends Omit, "children"> { + children: ReactNode; + className?: string; + forceShow?: boolean; + heading?: ReactNode; + maxLength?: number; +} + +/** + * A component that will display a "Show More" button if the content is longer + * than the maxLength prop. It will also accept a heading prop to display text + * above the content. + */ +export const CollapsibleContent: FC = ({ + forceShow, + className, + children, + heading, + maxLength = 600, + ...props +}) => { + const [isOpen, setIsOpen] = useState(forceShow); + + const handleToggleClick = (): void => { + setIsOpen((prev) => !prev); + }; + + if (typeof children === "string" && children?.length <= maxLength && !heading) + return ( + + ); + + return ( +
+ {heading &&
{heading}
} + {typeof children === "string" ? ( + + ) : ( +
{children}
+ )} + {!forceShow && ( + + )} +
+ ); +}; diff --git a/ddb_main/components/ConfirmModal/ConfirmModal.tsx b/ddb_main/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 0000000..98d5f13 --- /dev/null +++ b/ddb_main/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,116 @@ +import clsx from "clsx"; +import { FC, useEffect } from "react"; + +import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg"; +import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog"; + +import { Button, ButtonProps } from "../Button"; +import styles from "./styles.module.css"; + +/** + * @heading Text for the title of the modal - defaults to "Confirm" + * @onClose Function to call when the Close button is clicked + * @onConfirm Function to call when the Confirm button is clicked + * @confirmButtonText Provide text to the Confirm button - defaults to "Confirm" + * @closeButtonText Provide text to the Close button - defaults to "Cancel" + * @variant "default" | "remove" | "confirm-only" + * --default: default modal with both Confirm and Close buttons using the "success" button color + * --remove: modal with both Confirm and Close buttons using the "secondary" color for the header and Confirm button + * --confirm-only: modal with only the Confirm button using the "success" button color + * @size "default" | "fit-content" + * --default: default modal size + * --fit-content: modal size adjusts to the content + * @color Passes a ButtonProps color to the confirm button + * @useMobileFullScreen Boolean to set the modal to full screen on mobile breakpoint + */ +export interface ConfirmModalProps extends DialogProps { + heading?: string; + onClose: () => void; + onConfirm: () => void; + confirmButtonText?: string; + closeButtonText?: string; + variant?: "default" | "remove" | "confirm-only"; + size?: "default" | "fit-content"; + color?: ButtonProps["color"]; + useMobileFullScreen?: boolean; +} + +export const ConfirmModal: FC = ({ + children, + className, + heading = "Confirm", + onClose, + onConfirm, + confirmButtonText = "Confirm", + closeButtonText = "Cancel", + open, + variant = "default", + size = "default", + color = "success", + useMobileFullScreen, + ...props +}) => { + const isConfirmOnly = variant === "confirm-only"; + + useEffect(() => { + const body = document.querySelector("body"); + if (!body) return; + + // Prevent scrolling when modal is visible + if (open) { + body.style.overflow = "hidden"; + } else { + body.style.overflow = ""; + } + }, [open]); + + return ( + +
+

{heading}

+ +
+
{children}
+
+ {!isConfirmOnly && ( + + )} + +
+
+ ); +}; diff --git a/ddb_main/components/EditableName/EditableName.tsx b/ddb_main/components/EditableName/EditableName.tsx new file mode 100644 index 0000000..ee0da45 --- /dev/null +++ b/ddb_main/components/EditableName/EditableName.tsx @@ -0,0 +1,40 @@ +import clsx from "clsx"; +import { HTMLAttributes } from "react"; +import { useSelector } from "react-redux"; + +import Pen from "@dndbeyond/fontawesome-cache/svgs/light/pen.svg"; + +import { appEnvSelectors } from "../../tools/js/Shared/selectors"; +import styles from "./styles.module.css"; + +interface EditableNameProps extends HTMLAttributes { + onClick: () => void; +} + +/** + * Component which displays children with an edit button to the right. It is + * used in panes to give custom names to items, etc. + */ +export const EditableName = ({ + children, + className, + onClick, + ...props +}: EditableNameProps) => { + const isReadOnly = useSelector(appEnvSelectors.getIsReadonly); + + return ( +
+
{children}
+ {!isReadOnly && ( + + )} +
+ ); +}; diff --git a/ddb_main/components/FeatureChoice/FeatureChoice.tsx b/ddb_main/components/FeatureChoice/FeatureChoice.tsx new file mode 100644 index 0000000..995a26a --- /dev/null +++ b/ddb_main/components/FeatureChoice/FeatureChoice.tsx @@ -0,0 +1,306 @@ +import { FC, HTMLAttributes } from "react"; + +import { BuilderChoiceTypeEnum } from "~/constants"; +import { useCharacterEngine } from "~/hooks/useCharacterEngine"; +import { useRuleData } from "~/hooks/useRuleData"; +import { useSource } from "~/hooks/useSource"; +import { + DetailChoice, + DetailChoiceFeat, +} from "~/tools/js/Shared/containers/DetailChoice"; +import { TypeScriptUtils } from "~/tools/js/Shared/utils"; +import { + CharClass, + Choice, + ClassDefinitionContract, + ClassFeature, + Feat, + FeatLookup, + FeatureChoiceOption, + HtmlSelectOptionGroup, + SourceData, +} from "~/types"; + +export interface FeatureChoiceProps extends HTMLAttributes { + choice: Choice; + charClass?: CharClass; + feature?: ClassFeature; + featsData: Feat[]; + subclassData?: ClassDefinitionContract[]; + onChoiceChange: ( + choiceId: string, + type: number, + value: any, + parentChoiceId: string | null + ) => void; + collapseDescription?: boolean; +} + +/** + * Component for rendering a features choice(s) - for species traits and class features - using DetailChoiceFeat for a selected feat's choices and DetailChoice for all others. + It is used in both the builder and character sheet sidebar panes. + */ +export const FeatureChoice: FC = ({ + charClass, + choice, + feature, + featsData, + subclassData, + onChoiceChange, + className, + collapseDescription, + ...props +}) => { + const { + choiceUtils, + featUtils, + helperUtils, + prerequisiteUtils, + classUtils, + featLookup, + preferences, + prerequisiteData, + choiceInfo, + ruleData, + entityRestrictionData, + } = useCharacterEngine(); + + const { ruleDataUtils } = useRuleData(); + + const { + getGroupedOptionsBySourceCategory, + getSimpleSourcedDefinitionContracts, + } = useSource(); + + const optionValue = choiceUtils.getOptionValue(choice); + const options = choiceUtils.getOptions(choice); + const type = choiceUtils.getType(choice); + const tagConstraints = choiceUtils.getTagConstraints(choice); + + let availableOptions: Array = []; + let availableGroupedOptions: HtmlSelectOptionGroup[] = []; + let detailChoiceDesc: string | null = null; + let subchoicesNode: React.ReactNode; + + const handleChoiceChange = ( + id: string, + type: number, + subType: number | null, + value: any, + parentChoiceId: string | null + ): void => { + onChoiceChange(id, type, value, parentChoiceId); + }; + + const getSubclassData = (): ClassDefinitionContract[] => { + if (!subclassData || !charClass) { + return []; + } + + let data: ClassDefinitionContract[] = [...subclassData]; + + let existingSubclass = classUtils.getSubclass(charClass); + if ( + existingSubclass !== null && + !data.some( + (classDefinition) => + existingSubclass !== null && + classDefinition.id === existingSubclass.id + ) + ) { + data.push(existingSubclass); + } + + return data; + }; + + const getAvailableFeatChoices = ( + existingFeatId: number | null, + featData: Feat[], + featLookup: FeatLookup + ): Feat[] => { + let data: Feat[] = [...featData]; + + if (existingFeatId !== null) { + let existingFeat = helperUtils.lookupDataOrFallback( + featLookup, + existingFeatId + ); + if ( + existingFeat !== null && + !data.some((feat) => featUtils.getId(feat) === existingFeatId) + ) { + data.push(existingFeat); + } + } + + return data; + }; + + const getSubclassSources = ( + subclass: ClassDefinitionContract + ): SourceData[] => { + if (subclass.sources === null) { + return []; + } + + return subclass.sources + .map((sourceMapping) => + helperUtils.lookupDataOrFallback( + ruleDataUtils.getSourceDataLookup(ruleData), + sourceMapping.sourceId + ) + ) + .filter(TypeScriptUtils.isNotNullOrUndefined); + }; + + switch (type) { + case BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION: + const availableFeats = getAvailableFeatChoices( + optionValue, + featsData, + featLookup + ); + const repeatableFeatTracker = new Set(); + + // Add selected feat to repeatable tracker if repeatable + const selectedFeat = optionValue ? featLookup[optionValue] : null; + if (selectedFeat && featUtils.isRepeatable(selectedFeat)) { + const parentId = featUtils.getRepeatableGroupId(selectedFeat); + if (parentId) { + repeatableFeatTracker.add(parentId); + } + } + + const filteredFeats = availableFeats.filter((feat) => { + const featId = featUtils.getId(feat); + const isRepeatable = featUtils.isRepeatable(feat); + + // If the feat is the currently selected, always include it + if (featId === optionValue) { + return true; + } + + // Always exclude all previous selected feats + if (featLookup[featId]) { + return false; + } + + // If the Feat does not meet the tag constraints, should they exist, exclude it + const tagCategories = featUtils.getCategories(feat); + if ( + tagConstraints && + !featUtils.doesSatisfyTagConstraints(tagCategories, tagConstraints) + ) { + return false; + } + + // Handle prerequisites when enforcing feat rules + if ( + preferences.enforceFeatRules && + !prerequisiteUtils.validatePrerequisiteGrouping( + featUtils.getPrerequisites(feat), + prerequisiteData + ) + ) { + return false; + } + + // Special handling for repeatable feats, there can be only one + if (isRepeatable) { + const parentId = featUtils.getRepeatableGroupId(feat); + + // If a feat from this repeatable group exist exclude all others + if (repeatableFeatTracker.has(parentId)) { + return false; + } + + repeatableFeatTracker.add(parentId); + } + + // If none of the exclusions above are met, include the feat + return true; + }); + + //Group available feats by source category + availableGroupedOptions = getGroupedOptionsBySourceCategory( + filteredFeats + .map((feat) => featUtils.getDefinition(feat)) + .filter(TypeScriptUtils.isNotNullOrUndefined) + ); + + if (selectedFeat && optionValue !== null) { + detailChoiceDesc = featUtils.getDescription(selectedFeat); + subchoicesNode = ; + } + break; + + case BuilderChoiceTypeEnum.SUB_CLASS_OPTION: + const subclassData = getSubclassData(); + + //Group available subclasses by source category + availableGroupedOptions = getGroupedOptionsBySourceCategory(subclassData); + + const chosenSubclass = subclassData.find( + (subclass) => subclass.id === optionValue + ); + if (chosenSubclass) { + detailChoiceDesc = ""; + let sources = getSubclassSources(chosenSubclass); + sources.forEach((source) => { + if (source.sourceCategory && source.sourceCategory.isToggleable) { + detailChoiceDesc += source.sourceCategory.description + ? source.sourceCategory.description + : ""; + } + }); + } + break; + case BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION: + // Map over the options to mock parts of a SpellDefinitionContract. + const spellOptions = getSimpleSourcedDefinitionContracts(options); + availableGroupedOptions = getGroupedOptionsBySourceCategory( + spellOptions, + optionValue, + entityRestrictionData + ); + + // If there is a chosen spell, set detailChoiceDesc to its description. + const chosenSpell = spellOptions.find( + (spell) => spell.id === optionValue + ); + + if (chosenSpell) { + detailChoiceDesc = chosenSpell.description ?? ""; + } + + break; + + default: + availableOptions = options.map((option) => ({ + ...option, + value: option.id, + })); + } + + return ( +
+ 0 + ? availableGroupedOptions + : availableOptions + } + onChange={handleChoiceChange} + description={detailChoiceDesc || ""} + choiceInfo={choiceInfo} + classId={charClass && classUtils.getId(charClass)} + showBackgroundProficiencyOptions={true} + collapseDescription={collapseDescription} + /> + {subchoicesNode} +
+ ); +}; diff --git a/ddb_main/components/FilterGroup/FilterGroup.tsx b/ddb_main/components/FilterGroup/FilterGroup.tsx new file mode 100644 index 0000000..17fc152 --- /dev/null +++ b/ddb_main/components/FilterGroup/FilterGroup.tsx @@ -0,0 +1,214 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, ReactNode } from "react"; + +import { useFeatureFlags } from "~/contexts/FeatureFlag"; +import { Search } from "~/subApps/builder/components/Search"; +import Checkbox from "~/tools/js/smartComponents/Checkbox"; + +import { Accordion } from "../Accordion"; +import { Button } from "../Button"; +import styles from "./styles.module.css"; + +export interface FilterButtonData { + label: ReactNode; + type: number | string; + className?: string; + sortOrder?: number; +} +export interface FilterCheckboxData { + label: string; + type: string; + initiallyEnabled: boolean; + onChange: () => void; + className?: string; +} +export interface FilterGroupProps extends HTMLAttributes { + filterQuery: string; + onQueryChange: (value: string) => void; + searchPlaceholder?: string; + filterButtonData: Array; + activeFilterButtonTypes: Array; + onFilterButtonClick: (filterType: number | string) => void; + filterCheckboxData?: Array; + sourceCategoryButtonData: Array; + onSourceCategoryClick: (categoryId: number) => void; + activeFilterSourceCategories: Array; + themed?: boolean; + buttonGroupLabel: string; + buttonSize?: "x-small" | "xx-small"; + filterStyle?: "builder"; + shouldOpenFilterButtons?: boolean; + shouldOpenSourceCategoryButtons?: boolean; + onSourceCategoriesCollapse?: () => void; + onFilterButtonsCollapse?: () => void; +} + +export const FilterGroup: FC = ({ + filterQuery, + searchPlaceholder = "Search", + filterButtonData, + activeFilterButtonTypes, + onQueryChange, + onFilterButtonClick, + filterCheckboxData, + sourceCategoryButtonData, + onSourceCategoryClick, + activeFilterSourceCategories, + themed, + buttonGroupLabel, + buttonSize = "xx-small", + filterStyle, + shouldOpenFilterButtons, + shouldOpenSourceCategoryButtons, + onSourceCategoriesCollapse, + onFilterButtonsCollapse, + className, + ...props +}) => { + const handleQueryChange = (event: React.ChangeEvent) => { + onQueryChange(event.target.value); + }; + + const handleSourceCategoryClick = (evt: React.MouseEvent, id: number) => { + evt.stopPropagation(); + evt.nativeEvent.stopImmediatePropagation(); + onSourceCategoryClick(id); + }; + + const handleFilterButtonClick = ( + evt: React.MouseEvent, + type: string | number + ) => { + evt.stopPropagation(); + evt.nativeEvent.stopImmediatePropagation(); + onFilterButtonClick(type); + }; + + return ( +
+
+ + +
+ + {/* FILTER BUTTONS */} +
+ {buttonGroupLabel}
} + className={styles.accordion} + forceShow={shouldOpenFilterButtons} + onClick={onFilterButtonsCollapse} + > +
+ {filterButtonData.map((button) => { + return ( +
+ +
+ ); + })} +
+ {filterCheckboxData && filterCheckboxData.length > 0 && ( +
+ {filterCheckboxData.map((checkbox) => { + return ( +
+ +
+ ); + })} +
+ )} + +
+ + {/* SOURCE CATEGORY FILTERS */} + +
+ Filter By Source Category
+ } + className={styles.accordion} + forceShow={shouldOpenSourceCategoryButtons} + onClick={onSourceCategoriesCollapse} + > +
+ {sourceCategoryButtonData.map((button) => { + return ( +
+ +
+ ); + })} +
+ + + + ); +}; diff --git a/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx b/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx new file mode 100644 index 0000000..90c3000 --- /dev/null +++ b/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx @@ -0,0 +1,38 @@ +import { FC } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine"; + +import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg"; + +import { Accordion, AccordionProps } from "../Accordion"; +import { HtmlContent } from "../HtmlContent"; +import styles from "./styles.module.css"; + +export interface HelperTextAccordionProps + extends Omit { + builderHelperText: BuilderHelperTextInfoContract[]; +} + +export const HelperTextAccordion: FC = ({ + builderHelperText, + ...props +}) => { + return ( + <> + {builderHelperText.map((helperText, idx) => ( + + {helperText.label} + + } + > + + + ))} + + ); +}; diff --git a/ddb_main/components/HtmlContent/HtmlContent.tsx b/ddb_main/components/HtmlContent/HtmlContent.tsx new file mode 100644 index 0000000..1c2e5a5 --- /dev/null +++ b/ddb_main/components/HtmlContent/HtmlContent.tsx @@ -0,0 +1,25 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; + +import { FormatUtils } from "@dndbeyond/character-rules-engine/es"; + +interface HtmlContentProps extends HTMLAttributes { + html: string; + className?: string; + withoutTooltips?: boolean; +} + +export const HtmlContent: FC = ({ + html, + className = "", + withoutTooltips, + ...props +}) => ( +
+); diff --git a/ddb_main/components/InfoItem/InfoItem.tsx b/ddb_main/components/InfoItem/InfoItem.tsx new file mode 100644 index 0000000..e02b782 --- /dev/null +++ b/ddb_main/components/InfoItem/InfoItem.tsx @@ -0,0 +1,35 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; + +import { InfoItem as TtuiInfoItem } from "@dndbeyond/ttui/components/InfoItem"; + +import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick"; + +import styles from "./styles.module.css"; + +interface InfoItemProps extends HTMLAttributes { + label: string; + color?: "primary"; // Not needed here, but fixes ts error + inline?: boolean; +} + +/** + * This component is an attribute for a given item, spell, or other entity. + * The InfoItemList component is a customized version of the InfoItem component from + * the @dndbeyond/ttui library. + **/ +export const InfoItem: FC = ({ + className, + onClick, + ...props +}) => { + const handleClick = useUnpropagatedClick(onClick); + + return ( + + ); +}; diff --git a/ddb_main/components/ItemFilter/ItemFilter.tsx b/ddb_main/components/ItemFilter/ItemFilter.tsx new file mode 100644 index 0000000..dda2a8d --- /dev/null +++ b/ddb_main/components/ItemFilter/ItemFilter.tsx @@ -0,0 +1,152 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; + +import { useFiltersContext } from "~/contexts/Filters"; +import { SimpleSourceCategoryContract } from "~/types"; + +import { FilterGroup } from "../FilterGroup"; +import { + FilterButtonData, + FilterCheckboxData, +} from "../FilterGroup/FilterGroup"; +import styles from "./styles.module.css"; + +export interface ItemFilterProps extends HTMLAttributes { + filterQuery: string; + onQueryChange: (value: string) => void; + filterTypes: Array; + onFilterButtonClick: (type: string) => void; + onCheckboxChange: (type: string) => void; + sourceCategories: Array; + onSourceCategoryClick: (categoryId: number) => void; + filterSourceCategories: Array; + filterProficient: boolean; + filterBasic: boolean; + filterMagic: boolean; + filterContainer: boolean; + themed?: boolean; + buttonSize?: "x-small" | "xx-small"; + filterStyle?: "builder"; +} + +export const ItemFilter: FC = ({ + filterQuery, + onQueryChange, + filterTypes, + onFilterButtonClick, + onCheckboxChange, + sourceCategories, + onSourceCategoryClick, + filterSourceCategories, + filterProficient, + filterBasic, + filterMagic, + filterContainer, + themed, + buttonSize = "xx-small", + filterStyle, + className, + ...props +}) => { + const { + showItemTypes, + showItemSourceCategories, + setShowItemSourceCategories, + setShowItemTypes, + } = useFiltersContext(); + + const itemTypes: Array = [ + "Armor", + "Potion", + "Ring", + "Rod", + "Scroll", + "Staff", + "Wand", + "Weapon", + "Wondrous item", + "Other Gear", + ]; + + const toggleTypes: Array = [ + "Proficient", + "Common", + "Magical", + "Container", + ]; + + const isChecked = (type: string): boolean => { + switch (type) { + case "Proficient": + return filterProficient; + case "Common": + return filterBasic; + case "Magical": + return filterMagic; + case "Container": + return filterContainer; + default: + return false; + } + }; + + const filterCheckboxData: Array = toggleTypes.map( + (type) => { + return { + label: type, + type: type, + onChange: () => onCheckboxChange(type), + initiallyEnabled: isChecked(type), + }; + } + ); + + const filterButtons: Array = itemTypes.map((itemType) => { + return { + type: itemType, + label: itemType === "Wondrous item" ? "Wondrous" : itemType, + className: clsx([ + styles.filterButton, + buttonSize === "xx-small" && styles.filterButtonSmall, + ]), + }; + }); + + const sourcesData: Array = sourceCategories.map( + (sourceCategory) => { + return { + label: sourceCategory.name, + type: sourceCategory.id, + className: styles.sourceCategoryButton, + sortOrder: sourceCategory.sortOrder, + }; + } + ); + + return ( + setShowItemTypes(!showItemTypes)} + onSourceCategoriesCollapse={() => + setShowItemSourceCategories(!showItemSourceCategories) + } + className={className} + {...props} + /> + ); +}; diff --git a/ddb_main/components/ItemName/ItemName.tsx b/ddb_main/components/ItemName/ItemName.tsx new file mode 100644 index 0000000..885ba1b --- /dev/null +++ b/ddb_main/components/ItemName/ItemName.tsx @@ -0,0 +1,109 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import { + Item, + Constants, + ItemUtils, +} from "@dndbeyond/character-rules-engine/es"; + +import { AttunementIcon } from "~/tools/js/smartComponents/Icons"; + +import { LegacyBadge } from "../LegacyBadge"; +import { Tooltip } from "../Tooltip"; +import styles from "./styles.module.css"; + +/** + * Component to display the name of an item with an attunement icon and a color + * to identify the rarity of the object. It is used in the equipment tab on the + * character sheet as well as the manage inventory pane. + */ +interface ItemNameProps extends HTMLAttributes { + item: Item; + className?: string; + onClick?: () => void; + showAttunement?: boolean; + showLegacy?: boolean; + showLegacyBadge?: boolean; +} + +export const ItemName: FC = ({ + showAttunement = true, + showLegacy = false, + showLegacyBadge, + className, + onClick, + item, + ...props +}) => { + const handleClick = (e: React.MouseEvent): void => { + if (onClick) { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + onClick(); + } + }; + + const getDisplayName = () => { + const name = ItemUtils.getName(item); + if (!name) return ItemUtils.getDefinitionName(item); + if (!name) return "Item"; + return name; + }; + + const getItemRarity = () => { + switch (ItemUtils.getRarity(item)) { + case Constants.ItemRarityNameEnum.ARTIFACT: + return "artifact"; + case Constants.ItemRarityNameEnum.LEGENDARY: + return "legendary"; + case Constants.ItemRarityNameEnum.VERY_RARE: + return "veryrare"; + case Constants.ItemRarityNameEnum.RARE: + return "rare"; + case Constants.ItemRarityNameEnum.UNCOMMON: + return "uncommon"; + case Constants.ItemRarityNameEnum.COMMON: + default: + return "common"; + } + }; + + const tooltipId = `itemName-${uuidv4()}`; + + return ( + <> + + {getDisplayName()} + {item.isCustomized && ( + <> + + * + + + + )} + {showAttunement && item.isAttuned && ( + + + + )} + {showLegacy && ItemUtils.isLegacy(item) && ( + (Legacy) + )} + + {showLegacyBadge && ItemUtils.isLegacy(item) && ( + + )} + + ); +}; diff --git a/ddb_main/components/Layout/Layout.tsx b/ddb_main/components/Layout/Layout.tsx new file mode 100644 index 0000000..f701ef3 --- /dev/null +++ b/ddb_main/components/Layout/Layout.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; +import { useMatch } from "react-router-dom"; + +import { Footer } from "@dndbeyond/ttui/components/Footer"; +import { MegaMenu } from "@dndbeyond/ttui/components/MegaMenu"; +import { Sitebar } from "@dndbeyond/ttui/components/Sitebar"; + +import config from "~/config"; +import useUser from "~/hooks/useUser"; + +import styles from "./styles.module.css"; + +const BASE_PATHNAME = config.basePathname; + +interface LayoutProps extends HTMLAttributes {} + +export const Layout: FC = ({ children, ...props }) => { + const isDev = process.env.NODE_ENV === "development"; + const user = useUser(); + const matchSheet = useMatch(`${BASE_PATHNAME}/:characterId/`); + + // Don't show the navigation in production + if (!isDev) return <>{children}; + + return ( +
+ {/* TODO: fetch navItems */} +
+ + {/* TODO: fetch sources */} + +
+
{children}
+ {!matchSheet && ( +
+
+
+ )} +
+ ); +}; diff --git a/ddb_main/components/LegacyBadge/LegacyBadge.tsx b/ddb_main/components/LegacyBadge/LegacyBadge.tsx new file mode 100644 index 0000000..4538ec4 --- /dev/null +++ b/ddb_main/components/LegacyBadge/LegacyBadge.tsx @@ -0,0 +1,41 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import { LabelChip } from "@dndbeyond/ttui/components/LabelChip"; + +import { Link } from "../Link"; +import styles from "./styles.module.css"; + +interface LegacyBadgeProps extends HTMLAttributes { + variant?: "margin-left"; +} + +export const LegacyBadge: FC = ({ + variant, + className, + ...props +}) => ( + + This doesn't reflect the latest rules and lore.{" "} + {}} //TODO - can fix this after we refactor Link to not stop propgation inside the component + > + Learn More + + + } + tooltipClickable + {...props} + > + Legacy + +); diff --git a/ddb_main/components/Link/Link.tsx b/ddb_main/components/Link/Link.tsx new file mode 100644 index 0000000..786fef9 --- /dev/null +++ b/ddb_main/components/Link/Link.tsx @@ -0,0 +1,52 @@ +import clsx from "clsx"; +import { AnchorHTMLAttributes, FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; + +interface LinkProps + extends Omit, "onClick"> { + onClick?: Function; + useTheme?: boolean; + useRouter?: boolean; +} + +export const Link: FC = ({ + children, + className, + href, + onClick, + useTheme, + useRouter, + ...props +}) => { + //TODO - refactor to handle stop propagation on the oustide of this component (about 7 or so files to change) + const handleClick = (e: React.MouseEvent): void => { + if (onClick) { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + onClick(); + } + }; + + if (useRouter) + return ( + + {children} + + ); + + return ( + + {children} + + ); +}; diff --git a/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx b/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx new file mode 100644 index 0000000..4e3a684 --- /dev/null +++ b/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx @@ -0,0 +1,65 @@ +import { HTMLAttributes, useEffect, useRef } from "react"; + +import { Button } from "@dndbeyond/ttui/components/Button"; + +import { MaxCharactersMessageText } from "../MaxCharactersMessageText"; +import styles from "./styles.module.css"; + +interface MaxCharactersDialogProps extends HTMLAttributes { + open: boolean; + onClose: () => void; + useMyCharactersLink?: boolean; +} + +export const MaxCharactersDialog: React.FC = ({ + open, + onClose, + useMyCharactersLink, + ...props +}) => { + const dialogRef = useRef(null); + + const handleClickBackdrop = (e) => { + const rect = dialogRef.current?.getBoundingClientRect(); + const isClickInDialog = + rect && + rect.top <= e.clientY && + e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && + e.clientX <= rect.left + rect.width; + + if (!isClickInDialog) (dialogRef.current as any)?.close(); + }; + + useEffect(() => { + if (open) { + // Hacky workaround since showModal() isn't + // supported by TypeScript <4.8.3 + (dialogRef.current as any)?.showModal(); + } else { + // Hacky workaround since close() isn't + // supported by TypeScript <4.8.3 + (dialogRef.current as any)?.close(); + } + }, [onClose, open]); + + return ( + +
+

Your character slots are full

+
+ +
+ + +
+
+ ); +}; diff --git a/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx b/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx new file mode 100644 index 0000000..9258b6a --- /dev/null +++ b/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx @@ -0,0 +1,47 @@ +import { FC, HTMLAttributes } from "react"; + +import styles from "./styles.module.css"; + +interface MaxCharactersMessageTextProps extends HTMLAttributes { + includeTitle?: boolean; + useLinks?: boolean; + useMyCharactersLink?: boolean; +} + +export const MaxCharactersMessageText: FC = ({ + includeTitle, + useLinks, + useMyCharactersLink, + ...props +}) => { + const subscribe = useLinks ? ( + + Subscribe + + ) : ( + "Subscribe" + ); + + const myCharacters = + useMyCharactersLink || useLinks ? ( + + My Characters + + ) : ( + "My Characters" + ); + + return ( +
+ {includeTitle && ( +

+ Oops! Your character slots are full. +

+ )} +

+ You're quite the adventurer! {subscribe} to unlock additional + slots, or return to {myCharacters} and delete a character to make room. +

+
+ ); +}; diff --git a/ddb_main/components/NotificationSystem/NotificationSystem.tsx b/ddb_main/components/NotificationSystem/NotificationSystem.tsx new file mode 100644 index 0000000..11aab20 --- /dev/null +++ b/ddb_main/components/NotificationSystem/NotificationSystem.tsx @@ -0,0 +1,117 @@ +import clsx from "clsx"; +import { + forwardRef, + HTMLAttributes, + useEffect, + useImperativeHandle, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg"; +import { Toast } from "@dndbeyond/ttui/components/Toast"; + +import { Notification } from "~/tools/js/Shared/stores/typings"; + +import styles from "./styles.module.css"; + +export interface NotificationSystemHandlers { + addNotification: (notification: Notification) => void; + removeNotification: (notification: Notification) => void; +} + +const timeoutDuration = 5000; + +interface NotificationSystemProps + extends Omit, "open" | "contentEditable"> {} + +/** + * NotificationSystem is a component that manages the display of notifications + * in toast messages which appear in the character sheet. + */ +export const NotificationSystem = forwardRef< + HTMLDialogElement, + NotificationSystemProps +>(({ className, ...props }, ref) => { + const [isOpen, setIsOpen] = useState(true); + const [notifications, setNotifications] = useState>([]); + const [notificationTimeoutHandle, setNotificationTimeoutHandle] = useState< + number | undefined + >(); + const [portal, setPortal] = useState(null); + + const addNotification = (notification) => { + if (notifications.filter((n) => n.uid === notification.uid).length === 0) { + setNotifications([...notifications, notification]); + } + clearTimeout(notificationTimeoutHandle); + (function openNotification() { + if (!isOpen) { + setIsOpen(true); + } else { + setNotificationTimeoutHandle(() => { + const handle = window.setTimeout(() => { + openNotification(); + }, timeoutDuration + 100); + return handle; + }); + } + })(); + }; + + const removeNotification = (notification) => { + notifications[0].onRemove?.(); + setNotifications(notifications.filter((n) => n.uid !== notification.uid)); + }; + + useImperativeHandle( + ref, + (): NotificationSystemHandlers => ({ + addNotification, + removeNotification, + }) + ); + + const handleClose = () => { + setIsOpen(false); + removeNotification(notifications[0]); + }; + + useEffect(() => { + const portalEl = document.createElement("div"); + portalEl.classList.add("ct-notification__portal"); + portalEl.style.zIndex = "100000"; + portalEl.style.position = "fixed"; + document.body.appendChild(portalEl); + setPortal(portalEl); + return () => { + document.body.removeChild(portalEl); + }; + }, []); + + return notifications[0] && portal + ? createPortal( + + +

{notifications[0].title}

+

{notifications[0].message}

+
, + portal + ) + : null; +}); diff --git a/ddb_main/components/NumberDisplay/NumberDisplay.tsx b/ddb_main/components/NumberDisplay/NumberDisplay.tsx new file mode 100644 index 0000000..d7751da --- /dev/null +++ b/ddb_main/components/NumberDisplay/NumberDisplay.tsx @@ -0,0 +1,95 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, ReactNode } from "react"; + +import { Constants } from "@dndbeyond/character-rules-engine/es"; + +import { NumberDisplayType } from "~/types"; + +import styles from "./styles.module.css"; + +const { FEET_IN_MILES } = Constants; + +export interface NumberDisplayProps extends HTMLAttributes { + number: number | null; + type: NumberDisplayType; + isModified?: boolean; + size?: "large"; + numberFallback?: ReactNode; +} + +export const NumberDisplay: FC = ({ + number, + type, + isModified = false, + size, + numberFallback = "--", + className, + ...props +}) => { + let label = ""; + let sign = ""; + switch (type) { + case "weightInLb": + label = "lb."; + number = number && Math.round((number + Number.EPSILON) * 100) / 100; + break; + case "distanceInFt": + if (number && number % FEET_IN_MILES === 0) { + number = number / FEET_IN_MILES; + label = `mile${Math.abs(number) === 1 ? "" : "s"}`; + } else { + label = "ft."; + } + break; + case "signed": + if (number != null) { + sign = number >= 0 ? "+" : "-"; + number = Math.abs(number); + } + break; + default: + break; + } + + return ( + + {type === "signed" && ( + /* NOTE: aria-label needs to go on an interactable element, not a span. + This will need to be refactored onto the containing button. + */ + + {sign} + + )} + {number === null ? numberFallback : number} + {type !== "signed" && ( + + {label} + + )} + + ); +}; diff --git a/ddb_main/components/Popover/Popover.tsx b/ddb_main/components/Popover/Popover.tsx new file mode 100644 index 0000000..fcac944 --- /dev/null +++ b/ddb_main/components/Popover/Popover.tsx @@ -0,0 +1,121 @@ +import clsx from "clsx"; +import React, { + FC, + ReactElement, + ReactNode, + cloneElement, + useEffect, +} from "react"; + +import { Dialog } from "@dndbeyond/ttui/components/Dialog"; +import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible"; + +import { useCharacterTheme } from "~/contexts/CharacterTheme"; + +import styles from "./styles.module.css"; + +/** + * This is a popover component to handle dialogs that are dependent on a location from a trigger element. + * It uses the useIsVisible hook to handle the visibility of the dialog. The position of the dialog + * is determined by the position prop, which can be set to topLeft, topRight, left, right, bottomLeft, or bottomRight. + * The maxWidth prop can be used to set the maximum width of the dialog. The trigger prop is the element that will trigger + * the dialog to open.The popover component also handles the positioning of the dialog based on the trigger element. The + * handleToggle function toggles the visibility of the dialog, and the handleClose function closes the dialog. The popover + * component also adds an event listener to the cancel button in the dialog to close the dialog when clicked. + * + * In order for a cancel button to work, it must have a `data-cancel` attribute. + **/ +interface PopoverProps { + className?: string; + children?: ReactNode; + position?: + | "topLeft" + | "topRight" + | "left" + | "right" + | "bottomLeft" + | "bottomRight" + | "bottom"; + trigger: ReactElement; + maxWidth?: number; + variant?: "default" | "menu"; + "data-testid"?: string; +} + +export const Popover: FC = ({ + children, + className, + maxWidth = 250, + position = "bottomRight", + trigger, + variant = "default", + ...props +}) => { + const { ref: popoverRef, isVisible, setIsVisible } = useIsVisible(false); + const { isDarkMode } = useCharacterTheme(); + + const handleToggle = (e: MouseEvent) => { + e?.stopPropagation(); + setIsVisible(!isVisible); + }; + + const handleClose = (e: MouseEvent) => { + e?.stopPropagation(); + setIsVisible(false); + }; + + // Make a copy of the trigger element with an added `onClick` event listener + const triggerWithHandlers = cloneElement(trigger, { + onClick: (e: MouseEvent) => handleToggle(e), + }); + + useEffect(() => { + // Check to see if the popover has a cancel button based on `data-cancel` attribute + const cancelButton = popoverRef.current?.querySelector("[data-cancel]"); + + // If there is a cancel button, add an event listener to close the popover + if (cancelButton) { + cancelButton.addEventListener("click", handleClose); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [popoverRef.current]); + + const mapChildrenWithProps = () => { + // Check if children is a ReactElement + if (React.isValidElement(children)) { + // Create a copy of the ReactElement and attach handleClose function to it + return cloneElement(children as ReactElement, { + handleClose, + }); + } + // If not just return the children - means its a primitive type + else { + return children; + } + }; + + return ( +
+ {triggerWithHandlers} + handleClose(e as any)} //have to do any because of type mismatch for events + onClick={(e) => e.stopPropagation()} + {...props} + > + {mapChildrenWithProps()} + +
+ ); +}; diff --git a/ddb_main/components/PopoverContent/PopoverContent.tsx b/ddb_main/components/PopoverContent/PopoverContent.tsx new file mode 100644 index 0000000..6925bb7 --- /dev/null +++ b/ddb_main/components/PopoverContent/PopoverContent.tsx @@ -0,0 +1,54 @@ +import { FC, ReactNode } from "react"; + +import { Button } from "../Button"; +import styles from "./styles.module.css"; + +interface PopoverContentProps { + title?: string; + content: string; + confirmText?: ReactNode; + onConfirm?: () => void; + withCancel?: boolean; + handleClose?: () => void; +} + +export const PopoverContent: FC = ({ + title, + content, + confirmText = "Confirm", + onConfirm, + withCancel, + handleClose, +}) => ( + <> + {title &&
{title}
} +
{content}
+
+ {withCancel && ( + + )} + {onConfirm && ( + + )} +
+ +); diff --git a/ddb_main/components/PremadeCharacterEditStatus/PremadeCharacterEditStatus.tsx b/ddb_main/components/PremadeCharacterEditStatus/PremadeCharacterEditStatus.tsx new file mode 100644 index 0000000..b8a0fb9 --- /dev/null +++ b/ddb_main/components/PremadeCharacterEditStatus/PremadeCharacterEditStatus.tsx @@ -0,0 +1,32 @@ +import clsx from "clsx"; + +import { CharacterStatusSlug } from "@dndbeyond/character-rules-engine/es"; + +import styles from "./styles.module.css"; + +interface PremadeCharacterEditStatusProps { + characterStatus: CharacterStatusSlug | null; + isReadonly: boolean; + isBuilderView?: boolean; +} + +export const PremadeCharacterEditStatus = ({ + characterStatus, + isReadonly, + isBuilderView, +}: PremadeCharacterEditStatusProps) => { + return ( + <> + {characterStatus === CharacterStatusSlug.PREMADE && !isReadonly && ( +
+ Premade EDIT mode ENABLED +
+ )} + + ); +}; diff --git a/ddb_main/components/Reference/Reference.tsx b/ddb_main/components/Reference/Reference.tsx new file mode 100644 index 0000000..055b696 --- /dev/null +++ b/ddb_main/components/Reference/Reference.tsx @@ -0,0 +1,56 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import { Tooltip } from "../Tooltip"; +import styles from "./styles.module.css"; + +export interface ReferenceProps extends HTMLAttributes { + name: string | null; + tooltip?: string | null; + page?: number | null; + chapter?: number | null; + isDarkMode?: boolean; +} + +/** + * This component acts as a reference to a specific page or chapter in a book + * where the information can be found. + */ + +export const Reference: FC = ({ + chapter, + className, + isDarkMode, + name, + page, + tooltip, + ...props +}) => { + const id = uuidv4(); + const reference: string[] = []; + if (chapter) reference.push(`ch. ${chapter}`); + if (page) reference.push(`pg. ${page}`); + + return ( + <> +

+ {name} + {reference.length > 0 && ( + , {reference.join(", ")} + )} +

+ + + ); +}; diff --git a/ddb_main/components/SpellFilter/SpellFilter.tsx b/ddb_main/components/SpellFilter/SpellFilter.tsx new file mode 100644 index 0000000..2a1a10e --- /dev/null +++ b/ddb_main/components/SpellFilter/SpellFilter.tsx @@ -0,0 +1,129 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, useEffect, useState } from "react"; + +import { useFiltersContext } from "~/contexts/Filters"; +import { useCharacterEngine } from "~/hooks/useCharacterEngine"; +import { SimpleSourceCategoryContract, Spell } from "~/types"; + +import { FilterGroup } from "../FilterGroup"; +import { FilterButtonData } from "../FilterGroup/FilterGroup"; +import styles from "./styles.module.css"; + +export interface SpellFilterProps extends HTMLAttributes { + spells: Array; + filterQuery: string; + filterLevels: Array; + onQueryChange: (value: string) => void; + onLevelFilterClick: (level: number) => void; + sourceCategories: Array; + onSourceCategoryClick: (categoryId: number) => void; + filterSourceCategories: Array; + themed?: boolean; + buttonSize?: "x-small" | "xx-small"; + filterStyle?: "builder"; +} + +export const SpellFilter: FC = ({ + spells, + filterQuery, + filterLevels, + onQueryChange, + onLevelFilterClick, + sourceCategories, + onSourceCategoryClick, + filterSourceCategories, + themed, + buttonSize = "xx-small", + filterStyle, + className, + ...props +}) => { + const { ruleData, spellUtils, ruleDataUtils, formatUtils } = + useCharacterEngine(); + const { + showSpellLevels, + showSpellSourceCategories, + setShowSpellLevels, + setShowSpellSourceCategories, + } = useFiltersContext(); + + const [spellLevels, setSpellLevels] = useState>([]); + + useEffect(() => { + const activeSpellLevelCounts: Array = []; + const spellsLevels: Array = []; + const maxSpellLevel = ruleDataUtils.getMaxSpellLevel(ruleData); + + for (let i = 0; i <= maxSpellLevel; i++) { + activeSpellLevelCounts.push(0); + spellsLevels.push(i); + } + + spells.forEach((spell) => { + activeSpellLevelCounts[spellUtils.getLevel(spell)] += 1; + }); + + setSpellLevels( + spellsLevels.filter((level) => activeSpellLevelCounts[level] !== 0) + ); + }, [spells]); + + const filterButtons: Array = spellLevels.map((level) => { + let buttonLabel: React.ReactNode; + if (level === 0) { + buttonLabel = formatUtils.renderSpellLevelAbbreviation(0); + } else { + buttonLabel = ( +
+ {level} + + {formatUtils.getOrdinalSuffix(level)} + +
+ ); + } + return { type: level, label: buttonLabel, className: styles.filterButton }; + }); + + const sourcesData: Array = sourceCategories.map( + (sourceCategory) => { + return { + label: sourceCategory.name, + type: sourceCategory.id, + className: styles.sourceCategoryButton, + sortOrder: sourceCategory.sortOrder, + }; + } + ); + + return ( + setShowSpellLevels(!showSpellLevels)} + onSourceCategoriesCollapse={() => + setShowSpellSourceCategories(!showSpellSourceCategories) + } + className={className} + {...props} + /> + ); +}; diff --git a/ddb_main/components/SpellName/SpellName.tsx b/ddb_main/components/SpellName/SpellName.tsx new file mode 100644 index 0000000..93f7755 --- /dev/null +++ b/ddb_main/components/SpellName/SpellName.tsx @@ -0,0 +1,126 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; + +import { Tooltip } from "@dndbeyond/character-common-components/es"; +import { + SpellUtils, + EntityUtils, + FormatUtils, + BaseSpell, + DataOriginRefData, + characterEnvSelectors, +} from "@dndbeyond/character-rules-engine/es"; + +import { useCharacterTheme } from "~/contexts/CharacterTheme"; +import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick"; + +import { ConcentrationIcon, RitualIcon } from "../../tools/js/smartComponents/"; +import { LegacyBadge } from "../LegacyBadge"; +import styles from "./styles.module.css"; + +interface Props extends HTMLAttributes { + onClick?: () => void; + spell: BaseSpell; + showSpellLevel?: boolean; + showIcons?: boolean; + showExpandedType?: boolean; + showLegacy?: boolean; + showLegacyBadge?: boolean; + dataOriginRefData?: DataOriginRefData | null; +} + +export const SpellName: FC = ({ + onClick, + spell, + showSpellLevel = true, + showExpandedType = false, + showIcons = true, + dataOriginRefData = null, + showLegacy = false, + showLegacyBadge = false, + className, + ...props +}) => { + const [expandedInfoText, setExpandedInfoText] = useState(""); + const location = useSelector(characterEnvSelectors.getContext); + const { isDarkMode } = useCharacterTheme(); + + const getIconTheme = () => { + if (location === "BUILDER") return "dark"; + return isDarkMode ? "gray" : "dark"; + }; + + const handleClick = useUnpropagatedClick(onClick); + + useEffect(() => { + if (dataOriginRefData) { + let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell); + if (expandedDataOriginRef === null) { + setExpandedInfoText(""); + } else { + setExpandedInfoText( + EntityUtils.getDataOriginRefName( + expandedDataOriginRef, + dataOriginRefData + ) + ); + } + } + }, []); + + return ( + <> + + {showExpandedType && expandedInfoText !== "" && ( + + + + + )} + {SpellUtils.getName(spell)} + {SpellUtils.isCustomized(spell) && ( + + * + + )} + {showIcons && SpellUtils.getConcentration(spell) && ( + + )} + {showIcons && SpellUtils.isRitual(spell) && ( + + )} + + {showSpellLevel && ( + + ({FormatUtils.renderSpellLevelShortName(SpellUtils.getLevel(spell))} + ) + + )} + {showLegacy && SpellUtils.isLegacy(spell) && ( + • Legacy + )} + + {showLegacyBadge && SpellUtils.isLegacy(spell) && ( + + )} + + ); +}; diff --git a/ddb_main/components/SummaryList/SummaryList.tsx b/ddb_main/components/SummaryList/SummaryList.tsx new file mode 100644 index 0000000..9dbc552 --- /dev/null +++ b/ddb_main/components/SummaryList/SummaryList.tsx @@ -0,0 +1,26 @@ +import { FC, HTMLAttributes } from "react"; + +import styles from "./styles.module.css"; + +export interface SummaryListProps extends HTMLAttributes { + title: string; + list: Array; +} + +export const SummaryList: FC = ({ + title, + list, + ...props +}) => { + if (!list.length) { + return null; + } + + return ( +
+

+ {title}: {list.join(", ")} +

+
+ ); +}; diff --git a/ddb_main/components/Swipeable/Swipeable.tsx b/ddb_main/components/Swipeable/Swipeable.tsx new file mode 100644 index 0000000..65a70e1 --- /dev/null +++ b/ddb_main/components/Swipeable/Swipeable.tsx @@ -0,0 +1,11 @@ +import { useSwipeable } from "react-swipeable"; + +export const Swipeable = ({ + handlerFunctions, + deltaOverride = 75, + ...props +}) => { + const handlers = useSwipeable({ ...handlerFunctions, delta: deltaOverride }); + + return
; +}; diff --git a/ddb_main/components/TabFilter/TabFilter.tsx b/ddb_main/components/TabFilter/TabFilter.tsx new file mode 100644 index 0000000..3367aa5 --- /dev/null +++ b/ddb_main/components/TabFilter/TabFilter.tsx @@ -0,0 +1,98 @@ +import clsx from "clsx"; +import { FC, Fragment, HTMLAttributes, ReactNode, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; + +import { useCharacterTheme } from "~/contexts/CharacterTheme"; + +import styles from "./styles.module.css"; + +export interface TabFilterProps extends HTMLAttributes { + filters: + | { + label: ReactNode; + content: ReactNode; + badge?: ReactNode; + }[]; + showAllTab?: boolean; + sharedChildren?: ReactNode; +} + +export const TabFilter: FC = ({ + children, + className, + filters, + sharedChildren, + showAllTab = true, + ...props +}) => { + const [activeFilter, setActiveFilter] = useState(0); + const { isDarkMode } = useCharacterTheme(); + const activeIndex = showAllTab ? activeFilter - 1 : activeFilter; + const nonEmptyFilters = filters.filter((f) => f.label !== ""); + + const getDefaultLabel = (label: ReactNode) => { + if (typeof label === "string") return label; + return uuidv4(); + }; + + const getAllContent = () => { + return ( + nonEmptyFilters + // Remove optional tabs with duplicate content + .filter( + (f) => + // Don't render tabs with icons such as ritual or concentration spells + typeof f.label === "string" && + // Don't render tabs with the same content as the Attack or Limited Use tab + !getDefaultLabel(f.label).match(/(Attack|Limited Use)/) + ) + // Return the content of the remaining tabs to be rendered + .map((f) => ( + {f.content} + )) + ); + }; + + return ( +
+
+ {showAllTab && ( + + )} + {nonEmptyFilters.map((filter, i) => { + const index = showAllTab ? i + 1 : i; + return ( + + ); + })} +
+
+ {sharedChildren} + {!showAllTab || activeFilter !== 0 + ? nonEmptyFilters[activeIndex]?.content + : getAllContent()} +
+
+ ); +}; diff --git a/ddb_main/components/TabList/TabList.tsx b/ddb_main/components/TabList/TabList.tsx new file mode 100644 index 0000000..100f05a --- /dev/null +++ b/ddb_main/components/TabList/TabList.tsx @@ -0,0 +1,107 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes, ReactNode, useState } from "react"; + +import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg"; + +import { Popover } from "../Popover"; +import styles from "./styles.module.css"; + +interface TabItem { + label: ReactNode; + content: ReactNode; + className?: string; + id: string; +} + +interface TabListProps extends HTMLAttributes { + tabs: Array; + variant?: "default" | "collapse" | "toggle"; + defaultActiveId?: string; + maxNavItemsShow?: number; +} + +export const TabList: FC = ({ + tabs, + defaultActiveId, + maxNavItemsShow = 7, + variant = "default", + ...props +}) => { + const [activeTab, setActiveTab] = useState( + defaultActiveId ? defaultActiveId : tabs[0]?.id + ); + const [isTabVisible, setIsTabVisible] = useState(activeTab !== ""); + + const handleTabClick = (id: string) => { + if (id === activeTab && variant === "collapse") { + setIsTabVisible(!isTabVisible); + } + + if (id === activeTab && variant === "toggle") { + setActiveTab(""); + } + + if (id !== activeTab) { + setActiveTab(id); + setIsTabVisible(true); + } + }; + const tabsWithContent = tabs.filter((tab) => tab); + const visibleTabs = tabsWithContent.slice(0, maxNavItemsShow); + const hiddenTabs = tabsWithContent.slice(maxNavItemsShow); + const selectedTab = tabs.find((tab) => tab?.id === activeTab); + + return ( +
+ + {/* List of visible tabs */} + {visibleTabs.map((tab: TabItem) => ( +
  • + +
  • + ))} + + {/* Toggle and menu for hidden tabs */} + {hiddenTabs.length > 0 && ( +
  • + More} + > + + {hiddenTabs.map((tab: TabItem) => ( +
  • + +
  • + ))} +
    +
    +
  • + )} +
    + + {/* Content of the active tab */} +
    + {isTabVisible && activeTab !== "" && selectedTab?.content} +
    +
    + ); +}; diff --git a/ddb_main/components/TagGroup/TagGroup.tsx b/ddb_main/components/TagGroup/TagGroup.tsx new file mode 100644 index 0000000..e058dde --- /dev/null +++ b/ddb_main/components/TagGroup/TagGroup.tsx @@ -0,0 +1,30 @@ +import clsx from "clsx"; +import { FC, HTMLAttributes } from "react"; + +import styles from "./styles.module.css"; + +export interface TagGroupProps extends HTMLAttributes { + label: string; + tags: Array; +} + +export const TagGroup: FC = ({ + label, + tags, + className, + ...props +}) => { + if (!tags.length) return null; + return ( +
    +
    {label}:
    +
    + {tags.map((tag) => ( +
    + {tag} +
    + ))} +
    +
    + ); +}; diff --git a/ddb_main/components/Textarea/Textarea.tsx b/ddb_main/components/Textarea/Textarea.tsx new file mode 100644 index 0000000..54d33c8 --- /dev/null +++ b/ddb_main/components/Textarea/Textarea.tsx @@ -0,0 +1,75 @@ +import clsx from "clsx"; +import { + forwardRef, + HTMLAttributes, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +import styles from "./styles.module.css"; + +export interface TextareaProps + extends Omit, "children"> { + value?: string; + onInputBlur: (textValue: string) => void; + onInputKeyUp: () => void; +} + +/** + * A component to be used as a text area with auto-resizing capabilities. This + * is used in the panes for notes and traits and needs to support text entry as + * well as programmatic updates. + */ +export const Textarea = forwardRef( + ( + { className, value, onInputBlur, onInputKeyUp, placeholder, ...props }, + forwardedRef + ) => { + const [currentValue, setCurrentValue] = useState(value); + + const ref = useRef(null); + // Combine the local ref with the forwarded ref + useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement); + + const handleChange = (e) => { + const dataSet = e.target.parentNode.dataset; + dataSet.value = e.target.value; + setCurrentValue(e.target.value); + }; + + const handleBlur = (e) => onInputBlur(e.target.value); + const handleKeyUp = () => onInputKeyUp(); + + useEffect(() => { + if (ref.current) { + const wrapper = ref.current as HTMLDivElement; + const textarea = wrapper.children[0] as HTMLTextAreaElement; + // Set the data-value attribute to the value + wrapper.dataset.value = value; + // Set the textarea value to the value + textarea.value = value || ""; + setCurrentValue(value); + } + }, [value]); + + return ( +
    +