From 15c52adc4342656e2402e95c34c198e0a3ac384f Mon Sep 17 00:00:00 2001 From: Patric Stout Date: Fri, 17 May 2024 21:11:03 +0200 Subject: [PATCH] refactor: reworked all providers, so they are more well defined in their actions This basically touches every file in this repository; so while at it, also reworked how the storybooks work (you can now select different fits to test with), and did a bunch of cleanup where I saw it. There should be no functional difference, just mostly providers (and hooks) that are named differently. --- .eslintrc.js | 2 +- .storybook/fits.ts | 26 +- .storybook/helpers.tsx | 44 ++ package.json | 2 + .../CalculationDetail.stories.tsx | 48 +- .../CalculationDetail/CalculationDetail.tsx | 57 +- .../CharacterSelection.module.css} | 0 .../CharacterSelection.stories.tsx | 30 + .../CharacterSelection/CharacterSelection.tsx | 46 ++ src/components/CharacterSelection/index.ts | 1 + src/components/DroneBay/DroneBay.stories.tsx | 52 +- src/components/DroneBay/DroneBay.tsx | 93 ++-- .../EsiCharacterSelection.stories.tsx | 30 - .../EsiCharacterSelection.tsx | 43 -- src/components/EsiCharacterSelection/index.ts | 1 - .../FitButtonBar/ClipboardButton.tsx | 45 +- .../FitButtonBar/FitButtonBar.stories.tsx | 56 +- src/components/FitButtonBar/RenameButton.tsx | 22 +- src/components/FitButtonBar/SaveButton.tsx | 24 +- src/components/FitButtonBar/ShareButton.tsx | 6 +- .../HardwareListing.stories.tsx | 54 +- .../HardwareListing/HardwareListing.tsx | 202 +++---- .../HullListing/HullListing.stories.tsx | 59 +- src/components/HullListing/HullListing.tsx | 140 +++-- src/components/Icon/Icon.stories.tsx | 1 - .../ModalDialog/ModalDialog.stories.tsx | 1 - .../ShipAttribute/ShipAttribute.stories.tsx | 43 +- .../ShipAttribute/ShipAttribute.tsx | 78 +-- src/components/ShipFit/FitLink.tsx | 27 +- src/components/ShipFit/Hull.tsx | 13 +- src/components/ShipFit/HullDraggable.tsx | 20 +- src/components/ShipFit/ShipFit.stories.tsx | 49 +- src/components/ShipFit/ShipFit.tsx | 101 ++-- src/components/ShipFit/Slot.tsx | 189 ++++--- src/components/ShipFit/Usage.tsx | 25 +- .../ShipFitExtended.module.css | 11 + .../ShipFitExtended.stories.tsx | 104 +++- .../ShipFitExtended/ShipFitExtended.tsx | 24 +- .../ShipStatistics/RechargeRate.tsx | 2 +- .../ShipStatistics/ShipStatistics.stories.tsx | 53 +- .../ShipStatistics/ShipStatistics.tsx | 39 +- .../TreeListing/TreeListing.stories.tsx | 8 - src/components/index.ts | 12 - src/hooks/Clipboard.tsx | 29 +- .../EveShipFitHash/EveShipFitHash.stories.tsx | 31 -- src/hooks/EveShipFitHash/index.ts | 1 - .../EveShipFitLink/EveShipFitLink.stories.tsx | 40 -- src/hooks/EveShipFitLink/EveShipFitLink.tsx | 94 ---- src/hooks/EveShipFitLink/index.ts | 1 - src/hooks/ExportEft/ExportEft.stories.tsx | 34 ++ src/hooks/ExportEft/ExportEft.tsx | 104 ++++ src/hooks/ExportEft/index.ts | 1 + .../ExportEveShipFitHash.stories.tsx | 34 ++ .../ExportEveShipFitHash.tsx | 74 +++ src/hooks/ExportEveShipFitHash/index.ts | 1 + src/hooks/FormatAsEft/FormatAsEft.stories.tsx | 39 -- src/hooks/FormatAsEft/FormatAsEft.tsx | 94 ---- src/hooks/FormatAsEft/index.ts | 1 - .../FormatEftToEsi/FormatEftToEsi.stories.tsx | 31 -- src/hooks/FormatEftToEsi/index.ts | 1 - src/hooks/ImportEft/ImportEft.stories.tsx | 34 ++ .../ImportEft.tsx} | 51 +- src/hooks/ImportEft/index.ts | 1 + .../ImportEveShipFitHash.stories.tsx | 34 ++ .../ImportEveShipFitHash.tsx} | 77 +-- src/hooks/ImportEveShipFitHash/index.ts | 1 + src/hooks/LocalStorage.tsx | 9 +- src/hooks/index.ts | 6 - src/index.ts | 30 +- .../Characters/CharactersContext.tsx | 25 + .../DefaultCharactersProvider.stories.tsx | 51 ++ .../DefaultCharactersProvider.tsx | 61 ++ .../DefaultCharactersProvider/index.ts | 1 + .../EsiCharactersProvider.stories.tsx | 66 +++ .../EsiCharactersProvider.tsx | 235 ++++++++ .../EsiGetAccessToken.tsx} | 0 .../EsiCharactersProvider/EsiGetFittings.tsx} | 4 +- .../EsiCharactersProvider/EsiGetSkills.tsx} | 4 +- .../EsiCharactersProvider/EsiLogin.tsx | 49 ++ .../Characters/EsiCharactersProvider/index.ts | 1 + src/providers/Characters/index.ts | 3 + .../CurrentCharacterProvider.stories.tsx | 57 ++ .../CurrentCharacterProvider.tsx | 87 +++ .../CurrentCharacterProvider/index.ts | 2 + .../CurrentFitProvider.stories.tsx | 47 ++ .../CurrentFitProvider/CurrentFitProvider.tsx | 62 +++ src/providers/CurrentFitProvider/index.ts | 2 + .../DogmaEngineProvider.stories.tsx | 66 ++- .../DogmaEngineProvider.tsx | 74 ++- src/providers/DogmaEngineProvider/index.ts | 2 +- .../EsiProvider/EsiProvider.stories.tsx | 51 -- src/providers/EsiProvider/EsiProvider.tsx | 363 ------------ src/providers/EsiProvider/index.ts | 2 - .../EveDataProvider.stories.tsx | 34 +- .../EveDataProvider/EveDataProvider.tsx | 122 ++-- src/providers/EveDataProvider/index.ts | 3 +- .../FitManagerProvider.stories.tsx | 43 ++ .../FitManagerProvider/FitManagerProvider.tsx | 422 ++++++++++++++ src/providers/FitManagerProvider/index.ts | 1 + .../LocalFitProvider.stories.tsx | 43 -- .../LocalFitProvider/LocalFitProvider.tsx | 55 -- src/providers/LocalFitProvider/index.ts | 2 - .../LocalFitsProvider.stories.tsx | 39 ++ .../LocalFitsProvider/LocalFitsProvider.tsx | 52 ++ src/providers/LocalFitsProvider/index.ts | 1 + .../ShipSnapshotProvider.stories.tsx | 63 --- .../ShipSnapshotProvider.tsx | 523 ------------------ src/providers/ShipSnapshotProvider/index.ts | 8 - .../StatisticsProvider.stories.tsx | 92 +++ .../StatisticsProvider/StatisticsProvider.tsx | 130 +++++ src/providers/StatisticsProvider/index.ts | 7 + src/providers/index.ts | 5 - tsconfig.json | 2 +- 113 files changed, 3093 insertions(+), 2605 deletions(-) create mode 100644 .storybook/helpers.tsx rename src/components/{EsiCharacterSelection/EsiCharacterSelection.module.css => CharacterSelection/CharacterSelection.module.css} (100%) create mode 100644 src/components/CharacterSelection/CharacterSelection.stories.tsx create mode 100644 src/components/CharacterSelection/CharacterSelection.tsx create mode 100644 src/components/CharacterSelection/index.ts delete mode 100644 src/components/EsiCharacterSelection/EsiCharacterSelection.stories.tsx delete mode 100644 src/components/EsiCharacterSelection/EsiCharacterSelection.tsx delete mode 100644 src/components/EsiCharacterSelection/index.ts delete mode 100644 src/hooks/EveShipFitHash/EveShipFitHash.stories.tsx delete mode 100644 src/hooks/EveShipFitHash/index.ts delete mode 100644 src/hooks/EveShipFitLink/EveShipFitLink.stories.tsx delete mode 100644 src/hooks/EveShipFitLink/EveShipFitLink.tsx delete mode 100644 src/hooks/EveShipFitLink/index.ts create mode 100644 src/hooks/ExportEft/ExportEft.stories.tsx create mode 100644 src/hooks/ExportEft/ExportEft.tsx create mode 100644 src/hooks/ExportEft/index.ts create mode 100644 src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.stories.tsx create mode 100644 src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.tsx create mode 100644 src/hooks/ExportEveShipFitHash/index.ts delete mode 100644 src/hooks/FormatAsEft/FormatAsEft.stories.tsx delete mode 100644 src/hooks/FormatAsEft/FormatAsEft.tsx delete mode 100644 src/hooks/FormatAsEft/index.ts delete mode 100644 src/hooks/FormatEftToEsi/FormatEftToEsi.stories.tsx delete mode 100644 src/hooks/FormatEftToEsi/index.ts create mode 100644 src/hooks/ImportEft/ImportEft.stories.tsx rename src/hooks/{FormatEftToEsi/FormatEftToEsi.tsx => ImportEft/ImportEft.tsx} (75%) create mode 100644 src/hooks/ImportEft/index.ts create mode 100644 src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.stories.tsx rename src/hooks/{EveShipFitHash/EveShipFitHash.tsx => ImportEveShipFitHash/ImportEveShipFitHash.tsx} (70%) create mode 100644 src/hooks/ImportEveShipFitHash/index.ts create mode 100644 src/providers/Characters/CharactersContext.tsx create mode 100644 src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.stories.tsx create mode 100644 src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.tsx create mode 100644 src/providers/Characters/DefaultCharactersProvider/index.ts create mode 100644 src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.stories.tsx create mode 100644 src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.tsx rename src/providers/{EsiProvider/EsiAccessToken.tsx => Characters/EsiCharactersProvider/EsiGetAccessToken.tsx} (100%) rename src/providers/{EsiProvider/EsiFittings.tsx => Characters/EsiCharactersProvider/EsiGetFittings.tsx} (80%) rename src/providers/{EsiProvider/EsiSkills.tsx => Characters/EsiCharactersProvider/EsiGetSkills.tsx} (85%) create mode 100644 src/providers/Characters/EsiCharactersProvider/EsiLogin.tsx create mode 100644 src/providers/Characters/EsiCharactersProvider/index.ts create mode 100644 src/providers/Characters/index.ts create mode 100644 src/providers/CurrentCharacterProvider/CurrentCharacterProvider.stories.tsx create mode 100644 src/providers/CurrentCharacterProvider/CurrentCharacterProvider.tsx create mode 100644 src/providers/CurrentCharacterProvider/index.ts create mode 100644 src/providers/CurrentFitProvider/CurrentFitProvider.stories.tsx create mode 100644 src/providers/CurrentFitProvider/CurrentFitProvider.tsx create mode 100644 src/providers/CurrentFitProvider/index.ts delete mode 100644 src/providers/EsiProvider/EsiProvider.stories.tsx delete mode 100644 src/providers/EsiProvider/EsiProvider.tsx delete mode 100644 src/providers/EsiProvider/index.ts create mode 100644 src/providers/FitManagerProvider/FitManagerProvider.stories.tsx create mode 100644 src/providers/FitManagerProvider/FitManagerProvider.tsx create mode 100644 src/providers/FitManagerProvider/index.ts delete mode 100644 src/providers/LocalFitProvider/LocalFitProvider.stories.tsx delete mode 100644 src/providers/LocalFitProvider/LocalFitProvider.tsx delete mode 100644 src/providers/LocalFitProvider/index.ts create mode 100644 src/providers/LocalFitsProvider/LocalFitsProvider.stories.tsx create mode 100644 src/providers/LocalFitsProvider/LocalFitsProvider.tsx create mode 100644 src/providers/LocalFitsProvider/index.ts delete mode 100644 src/providers/ShipSnapshotProvider/ShipSnapshotProvider.stories.tsx delete mode 100644 src/providers/ShipSnapshotProvider/ShipSnapshotProvider.tsx delete mode 100644 src/providers/ShipSnapshotProvider/index.ts create mode 100644 src/providers/StatisticsProvider/StatisticsProvider.stories.tsx create mode 100644 src/providers/StatisticsProvider/StatisticsProvider.tsx create mode 100644 src/providers/StatisticsProvider/index.ts diff --git a/.eslintrc.js b/.eslintrc.js index 4bd4f2e..ae0a88f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -39,7 +39,7 @@ module.exports = { { // The files listed below are part of the build process, so they will be using packages that are listed // under devDependences and/or peerDependencies, so we need to be lenient with the import/no-extraneous-dependencies - files: [".storybook/**/*.ts", ".eslintrc.js", "rollup.config.mjs"], + files: [".storybook/**/*.ts", ".storybook/**/*.tsx", ".eslintrc.js", "rollup.config.mjs", "**/*.stories.tsx"], rules: { "import/no-extraneous-dependencies": ["error", { peerDependencies: true, devDependencies: true }], }, diff --git a/.storybook/fits.ts b/.storybook/fits.ts index e71a3ec..380eb84 100644 --- a/.storybook/fits.ts +++ b/.storybook/fits.ts @@ -1,4 +1,8 @@ -export const eftFit = `[Loki,Loki basic PVE] +import { EsfFit } from "@/providers"; +import { InputType } from "@storybook/types"; + +export const eftFits = { + Loki: `[Loki,Loki basic PVE] Caldari Navy Ballistic Control System Caldari Navy Ballistic Control System Caldari Navy Ballistic Control System @@ -28,12 +32,15 @@ Loki Offensive - Launcher Efficiency Configuration Loki Propulsion - Wake Limiter Hammerhead II x1 -`; +`, +}; -export const hashFit = - "fit:v2:H4sIAAAAAAAACmWQMZJDMQhD+38WChBgwx32Ijtb5P5d4ONkdpLOTwhbFjKT6efx90uXk+pmJqE+I9YKEueblC0WyXpDvABrZy2OTTR8UTIPwtp4UIQApNz3C/5DkiRYbwA3RA70TowLINl+8qHMJoYBIxPgTLwnHAOb4FtKOlkuxJeSn0p95lORLwVVQ+i6n9FKI77nTbXqbntPpqgrKoWVEDXN2pNtY01tWBM8rcAjTj9OtaJ6aBV5+qHdM345owlT0hPutE6T0AEAAA=="; +export const hashFits = { + Loki: "fit:v2:H4sIAAAAAAAACmWQMZJDMQhD+38WChBgwx32Ijtb5P5d4ONkdpLOTwhbFjKT6efx90uXk+pmJqE+I9YKEueblC0WyXpDvABrZy2OTTR8UTIPwtp4UIQApNz3C/5DkiRYbwA3RA70TowLINl+8qHMJoYBIxPgTLwnHAOb4FtKOlkuxJeSn0p95lORLwVVQ+i6n9FKI77nTbXqbntPpqgrKoWVEDXN2pNtY01tWBM8rcAjTj9OtaJ6aBV5+qHdM345owlT0hPutE6T0AEAAA==", +}; export const fullFits = [ + null, { name: "Tengu", ship_type_id: 29984, @@ -148,8 +155,8 @@ export const fullFits = [ ], }, { - ship_type_id: 35833, name: "Killmail 117621358", + ship_type_id: 35833, description: "", items: [ { flag: 5, type_id: 37821, quantity: 6 }, @@ -195,3 +202,12 @@ export const fullFits = [ ]; export const fullFit = fullFits[2]; + +export const fitArgType: InputType = { + control: "select", + options: fullFits.map((fit: EsfFit | null) => fit?.name ?? "(empty)"), + mapping: fullFits.reduce((acc: Record, fit: EsfFit | null) => { + acc[fit?.name ?? "(empty)"] = fit; + return acc; + }, {}), +}; diff --git a/.storybook/helpers.tsx b/.storybook/helpers.tsx new file mode 100644 index 0000000..b448f6c --- /dev/null +++ b/.storybook/helpers.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { StoryFn } from "@storybook/react"; + +import { ModalDialogAnchor } from "@/components/ModalDialog"; +import { EveDataProvider } from "@/providers/EveDataProvider"; +import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; +import { CurrentFitProvider, EsfFit, useCurrentFit } from "@/providers/CurrentFitProvider"; +import { LocalFitsProvider } from "@/providers/LocalFitsProvider"; +import { DefaultCharactersProvider, EsiCharactersProvider } from "@/providers/Characters"; +import { CurrentCharacterProvider } from "@/providers/CurrentCharacterProvider"; +import { StatisticsProvider } from "@/providers/StatisticsProvider"; +import { FitManagerProvider } from "@/providers/FitManagerProvider"; + +export const withDecoratorFull = (Story: StoryFn) => ( + + + + + + + + + + + + + + + + + + + + +); + +export const useFitSelection = (fit: EsfFit | null) => { + const currentFit = useCurrentFit(); + const setFit = currentFit.setFit; + + React.useEffect(() => { + setFit(fit); + }, [setFit, fit]); +}; diff --git a/package.json b/package.json index a29a34e..489d7df 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "@storybook/addon-links": "^8", "@storybook/addon-webpack5-compiler-babel": "^3", "@storybook/blocks": "^8", + "@storybook/preview-api": "^8", "@storybook/react": "^8", "@storybook/react-webpack5": "^8", "@storybook/test": "^8", + "@storybook/types": "^8", "@types/react": "^18", "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^7", diff --git a/src/components/CalculationDetail/CalculationDetail.stories.tsx b/src/components/CalculationDetail/CalculationDetail.stories.tsx index addb89b..7508630 100644 --- a/src/components/CalculationDetail/CalculationDetail.stories.tsx +++ b/src/components/CalculationDetail/CalculationDetail.stories.tsx @@ -1,47 +1,35 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { CalculationDetail } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { component: CalculationDetail, tags: ["autodocs"], - title: "Component/CalculationDetail", }; export default meta; -type Story = StoryObj; - -const useShipSnapshotProvider: Decorator<{ - source: "Ship" | "Char" | "Structure" | "Target" | { Item?: number; Cargo?: number }; -}> = (Story, context) => { - return ( - - - - - - - - - - ); -}; +type Story = StoryObj; export const Default: Story = { + argTypes: { + fit: fitArgType, + }, args: { + fit: null, source: "Ship", }, - decorators: [useShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + decorators: [withDecoratorFull], + render: ({ fit, ...args }) => { + useFitSelection(fit); + + return ; }, }; diff --git a/src/components/CalculationDetail/CalculationDetail.tsx b/src/components/CalculationDetail/CalculationDetail.tsx index 3bb7a7e..08b9d8b 100644 --- a/src/components/CalculationDetail/CalculationDetail.tsx +++ b/src/components/CalculationDetail/CalculationDetail.tsx @@ -1,13 +1,9 @@ import clsx from "clsx"; import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { - ShipSnapshotContext, - ShipSnapshotItemAttribute, - ShipSnapshotItemAttributeEffect, -} from "@/providers/ShipSnapshotProvider"; import { Icon } from "@/components/Icon"; +import { useEveData } from "@/providers/EveDataProvider"; +import { StatisticsItemAttribute, StatisticsItemAttributeEffect, useStatistics } from "@/providers/StatisticsProvider"; import styles from "./CalculationDetail.module.css"; @@ -46,11 +42,13 @@ function stateToInteger(state: string): number { } } -const Effect = (props: { effect: ShipSnapshotItemAttributeEffect }) => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); +const Effect = (props: { effect: StatisticsItemAttributeEffect }) => { + const eveData = useEveData(); + const statistics = useStatistics(); - const eveAttribute = eveData.dogmaAttributes?.[props.effect.source_attribute_id]; + if (eveData === null || statistics === null) return <>; + + const eveAttribute = eveData.dogmaAttributes[props.effect.source_attribute_id]; let sourceName = "Unknown"; let attribute = undefined; @@ -59,22 +57,22 @@ const Effect = (props: { effect: ShipSnapshotItemAttributeEffect }) => { switch (props.effect.source) { case "Ship": sourceName = "Ship"; - attribute = shipSnapshot.hull?.attributes.get(props.effect.source_attribute_id); + attribute = statistics.hull.attributes.get(props.effect.source_attribute_id); break; case "Char": sourceName = "Character"; - attribute = shipSnapshot.char?.attributes.get(props.effect.source_attribute_id); + attribute = statistics.char.attributes.get(props.effect.source_attribute_id); break; case "Structure": sourceName = "Structure"; - attribute = shipSnapshot.structure?.attributes.get(props.effect.source_attribute_id); + attribute = statistics.structure.attributes.get(props.effect.source_attribute_id); break; case "Target": sourceName = "Target"; - attribute = shipSnapshot.target?.attributes.get(props.effect.source_attribute_id); + attribute = statistics.target.attributes.get(props.effect.source_attribute_id); break; default: @@ -83,13 +81,13 @@ const Effect = (props: { effect: ShipSnapshotItemAttributeEffect }) => { /* Lookup the source of the effect. */ if (props.effect.source.Item !== undefined) { - item = shipSnapshot.items?.[props.effect.source.Item]; + item = statistics.items[props.effect.source.Item]; sourceType = "Item"; } else if (props.effect.source.Skill !== undefined) { - item = shipSnapshot.skills?.[props.effect.source.Skill]; + item = statistics.skills[props.effect.source.Skill]; sourceType = "Skill"; } else if (props.effect.source.Charge !== undefined) { - item = shipSnapshot.items?.[props.effect.source.Charge].charge; + item = statistics.items[props.effect.source.Charge].charge; sourceType = "Charge"; } @@ -125,11 +123,13 @@ const Effect = (props: { effect: ShipSnapshotItemAttributeEffect }) => { ); }; -const CalculationDetailMeta = (props: { attributeId: number; attribute: ShipSnapshotItemAttribute }) => { +const CalculationDetailMeta = (props: { attributeId: number; attribute: StatisticsItemAttribute }) => { const [expanded, setExpanded] = React.useState(false); - const eveData = React.useContext(EveDataContext); + const eveData = useEveData(); + + if (eveData === null) return <>; - const eveAttribute = eveData.dogmaAttributes?.[props.attributeId]; + const eveAttribute = eveData.dogmaAttributes[props.attributeId]; const sortedEffects = Object.values(props.attribute.effects).sort((a, b) => { const aIndex = Object.keys(EffectOperatorOrder).indexOf(a.operator); const bIndex = Object.keys(EffectOperatorOrder).indexOf(b.operator); @@ -172,25 +172,26 @@ const CalculationDetailMeta = (props: { attributeId: number; attribute: ShipSnap export const CalculationDetail = (props: { source: "Ship" | "Char" | "Structure" | "Target" | { Item?: number; Charge?: number }; }) => { - const shipSnapshot = React.useContext(ShipSnapshotContext); + const statistics = useStatistics(); + if (statistics === null) return <>; - let attributes: [number, ShipSnapshotItemAttribute][] = []; + let attributes: [number, StatisticsItemAttribute][] = []; if (props.source === "Ship") { - attributes = [...(shipSnapshot.hull?.attributes.entries() || [])]; + attributes = [...(statistics.hull.attributes.entries() ?? [])]; } else if (props.source === "Char") { - attributes = [...(shipSnapshot.char?.attributes.entries() || [])]; + attributes = [...(statistics.char.attributes.entries() ?? [])]; } else if (props.source === "Structure") { - attributes = [...(shipSnapshot.structure?.attributes.entries() || [])]; + attributes = [...(statistics.structure.attributes.entries() ?? [])]; } else if (props.source === "Target") { - attributes = [...(shipSnapshot.target?.attributes.entries() || [])]; + attributes = [...(statistics.target.attributes.entries() ?? [])]; } else if (props.source.Item !== undefined) { - const item = shipSnapshot.items?.[props.source.Item]; + const item = statistics.items[props.source.Item]; if (item !== undefined) { attributes = [...item.attributes.entries()]; } } else if (props.source.Charge !== undefined) { - const item = shipSnapshot.items?.[props.source.Charge].charge; + const item = statistics.items[props.source.Charge].charge; if (item !== undefined) { attributes = [...item.attributes.entries()]; } diff --git a/src/components/EsiCharacterSelection/EsiCharacterSelection.module.css b/src/components/CharacterSelection/CharacterSelection.module.css similarity index 100% rename from src/components/EsiCharacterSelection/EsiCharacterSelection.module.css rename to src/components/CharacterSelection/CharacterSelection.module.css diff --git a/src/components/CharacterSelection/CharacterSelection.stories.tsx b/src/components/CharacterSelection/CharacterSelection.stories.tsx new file mode 100644 index 0000000..90933e4 --- /dev/null +++ b/src/components/CharacterSelection/CharacterSelection.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { EveDataProvider } from "@/providers/EveDataProvider"; +import { DefaultCharactersProvider, EsiCharactersProvider } from "@/providers/Characters"; +import { CurrentCharacterProvider } from "@/providers/CurrentCharacterProvider"; + +import { CharacterSelection } from "./"; + +const meta: Meta = { + component: CharacterSelection, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + + + + + + ), +}; diff --git a/src/components/CharacterSelection/CharacterSelection.tsx b/src/components/CharacterSelection/CharacterSelection.tsx new file mode 100644 index 0000000..fbfc54d --- /dev/null +++ b/src/components/CharacterSelection/CharacterSelection.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import { useCharacters, useEsiCharacters } from "@/providers/Characters"; +import { useCurrentCharacter } from "@/providers/CurrentCharacterProvider"; + +import styles from "./CharacterSelection.module.css"; + +/** + * Character selection for EsiProvider. + * + * It shows both a dropdown for all the characters that the EsiProvider knows, + * and a button to add another character. + */ +export const CharacterSelection = () => { + const characters = useCharacters(); + const currentCharacter = useCurrentCharacter(); + const esiCharactersProvider = useEsiCharacters(); + + const isExpired = currentCharacter.character?.expired ?? false; + + return ( +
+ + {isExpired && ( + + )} + {!isExpired && ( + + )} +
+ ); +}; diff --git a/src/components/CharacterSelection/index.ts b/src/components/CharacterSelection/index.ts new file mode 100644 index 0000000..8acc783 --- /dev/null +++ b/src/components/CharacterSelection/index.ts @@ -0,0 +1 @@ +export { CharacterSelection } from "./CharacterSelection"; diff --git a/src/components/DroneBay/DroneBay.stories.tsx b/src/components/DroneBay/DroneBay.stories.tsx index 89ca410..f4a2ee6 100644 --- a/src/components/DroneBay/DroneBay.stories.tsx +++ b/src/components/DroneBay/DroneBay.stories.tsx @@ -1,47 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { DroneBay } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: DroneBay, tags: ["autodocs"], - title: "Component/DroneBay", }; export default meta; -type Story = StoryObj; - -const useShipSnapshotProvider: Decorator> = (Story, context) => { - return ( - - - - -
- -
-
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { + argTypes: { + fit: fitArgType, + }, args: { + fit: null, width: 730, }, - decorators: [useShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/DroneBay/DroneBay.tsx b/src/components/DroneBay/DroneBay.tsx index c142af9..c0b3267 100644 --- a/src/components/DroneBay/DroneBay.tsx +++ b/src/components/DroneBay/DroneBay.tsx @@ -1,26 +1,19 @@ import clsx from "clsx"; import React from "react"; -import { ShipSnapshotContext, ShipSnapshotItem } from "@/providers/ShipSnapshotProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; import { CharAttribute, ShipAttribute } from "@/components/ShipAttribute"; +import { useFitManager } from "@/providers/FitManagerProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { StatisticsItem, useStatistics } from "@/providers/StatisticsProvider"; import styles from "./DroneBay.module.css"; -const DroneBayEntrySelected = ({ - drone, - index, - isOpen, -}: { - drone: ShipSnapshotItem; - index: number; - isOpen: boolean; -}) => { - const snapshot = React.useContext(ShipSnapshotContext); +const DroneBayEntrySelected = ({ drone, index, isOpen }: { drone: StatisticsItem; index: number; isOpen: boolean }) => { + const fitManager = useFitManager(); const onClick = React.useCallback(() => { - snapshot.toggleDrones(drone.type_id, index + 1); - }, [snapshot, drone, index]); + fitManager.activateDrones(drone.type_id, index + 1); + }, [fitManager, drone, index]); return (
{ - const eveData = React.useContext(EveDataContext); - const snapshot = React.useContext(ShipSnapshotContext); - - const attributeDroneBandwidthUsedTotal = eveData.attributeMapping?.droneBandwidthUsedTotal || 0; - const attributeDroneActive = eveData.attributeMapping?.droneActive || 0; - const attributeDroneBandwidthUsed = eveData.attributeMapping?.droneBandwidthUsed || 0; - const attributeDroneBandwidth = eveData.attributeMapping?.droneBandwidth || 0; - const attributeMaxActiveDrones = eveData.attributeMapping?.maxActiveDrones || 0; - - const bandwidthUsed = snapshot.hull?.attributes?.get(attributeDroneBandwidthUsedTotal)?.value ?? 0; - const bandwidthAvailable = snapshot.hull?.attributes?.get(attributeDroneBandwidth)?.value ?? 0; - const dronesActive = snapshot.hull?.attributes?.get(attributeDroneActive)?.value ?? 0; - const maxDronesActive = snapshot.char?.attributes?.get(attributeMaxActiveDrones)?.value ?? 0; - const droneBandwidth = drones[0].attributes?.get(attributeDroneBandwidthUsed)?.value ?? 0; +const DroneBayEntry = ({ name, drones }: { name: string; drones: StatisticsItem[] }) => { + const eveData = useEveData(); + const statistics = useStatistics(); + const fitManager = useFitManager(); + + const onRemove = React.useCallback(() => { + fitManager.removeDrones(drones[0].type_id); + }, [fitManager, drones]); + + if (eveData === null || statistics === null) return <>; + + const attributeDroneBandwidthUsedTotal = eveData.attributeMapping.droneBandwidthUsedTotal ?? 0; + const attributeDroneActive = eveData.attributeMapping.droneActive ?? 0; + const attributeDroneBandwidthUsed = eveData.attributeMapping.droneBandwidthUsed ?? 0; + const attributeDroneBandwidth = eveData.attributeMapping.droneBandwidth ?? 0; + const attributeMaxActiveDrones = eveData.attributeMapping.maxActiveDrones ?? 0; + + const bandwidthUsed = statistics.hull.attributes.get(attributeDroneBandwidthUsedTotal)?.value ?? 0; + const bandwidthAvailable = statistics.hull.attributes.get(attributeDroneBandwidth)?.value ?? 0; + const dronesActive = statistics.hull.attributes.get(attributeDroneActive)?.value ?? 0; + const maxDronesActive = statistics.char.attributes.get(attributeMaxActiveDrones)?.value ?? 0; + const droneBandwidth = drones[0].attributes.get(attributeDroneBandwidthUsed)?.value ?? 0; const maxSelected = Math.max(0, Math.min(maxDronesActive, Math.floor(bandwidthAvailable / droneBandwidth))); let maxOpen = Math.max( @@ -61,10 +61,6 @@ const DroneBayEntry = ({ name, drones }: { name: string; drones: ShipSnapshotIte const dronesSelected = drones.slice(0, maxSelected); - const onRemove = React.useCallback(() => { - snapshot.removeDrones(drones[0].type_id); - }, [snapshot, drones]); - return (
{drones.length} x
@@ -91,28 +87,21 @@ const DroneBayEntry = ({ name, drones }: { name: string; drones: ShipSnapshotIte }; export const DroneBay = () => { - const eveData = React.useContext(EveDataContext); - const snapshot = React.useContext(ShipSnapshotContext); - - const [drones, setDrones] = React.useState>({}); + const eveData = useEveData(); + const statistics = useStatistics(); - React.useEffect(() => { - if (snapshot === undefined || !snapshot.loaded || snapshot.items === undefined) return; - if (eveData === undefined || !eveData.loaded || eveData.typeIDs === undefined) return; + if (eveData === null || statistics === null) return <>; - /* Group drones by type_id */ - const dronesGrouped: Record = {}; - for (const drone of snapshot.items.filter((item) => item.flag == 87)) { - const name = eveData.typeIDs?.[drone.type_id].name ?? ""; + /* Group drones by type_id */ + const dronesGrouped: Record = {}; + for (const drone of statistics.items.filter((item) => item.flag == 87)) { + const name = eveData.typeIDs?.[drone.type_id].name ?? ""; - if (dronesGrouped[name] === undefined) { - dronesGrouped[name] = []; - } - dronesGrouped[name].push(drone); + if (dronesGrouped[name] === undefined) { + dronesGrouped[name] = []; } - - setDrones(dronesGrouped); - }, [snapshot, eveData]); + dronesGrouped[name].push(drone); + } return (
@@ -120,7 +109,7 @@ export const DroneBay = () => { Active drones: /{" "}
- {Object.entries(drones) + {Object.entries(dronesGrouped) .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, droneList]) => { return ; diff --git a/src/components/EsiCharacterSelection/EsiCharacterSelection.stories.tsx b/src/components/EsiCharacterSelection/EsiCharacterSelection.stories.tsx deleted file mode 100644 index e4b5d72..0000000 --- a/src/components/EsiCharacterSelection/EsiCharacterSelection.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { EsiCharacterSelection } from "./"; - -const meta: Meta = { - component: EsiCharacterSelection, - tags: ["autodocs"], - title: "Component/EsiCharacterSelection", -}; - -export default meta; -type Story = StoryObj; - -const withEsiProvider: Decorator> = (Story) => { - return ( - - - - - - ); -}; - -export const Default: Story = { - args: {}, - decorators: [withEsiProvider], -}; diff --git a/src/components/EsiCharacterSelection/EsiCharacterSelection.tsx b/src/components/EsiCharacterSelection/EsiCharacterSelection.tsx deleted file mode 100644 index 1449061..0000000 --- a/src/components/EsiCharacterSelection/EsiCharacterSelection.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; - -import { EsiContext } from "@/providers/EsiProvider"; - -import styles from "./EsiCharacterSelection.module.css"; - -/** - * Character selection for EsiProvider. - * - * It shows both a dropdown for all the characters that the EsiProvider knows, - * and a button to add another character. - */ -export const EsiCharacterSelection = () => { - const esi = React.useContext(EsiContext); - - const isExpired = esi.currentCharacter && esi.characters[esi.currentCharacter].expired; - - return ( -
- - {isExpired && ( - - )} - {!isExpired && ( - - )} -
- ); -}; diff --git a/src/components/EsiCharacterSelection/index.ts b/src/components/EsiCharacterSelection/index.ts deleted file mode 100644 index cf40bcb..0000000 --- a/src/components/EsiCharacterSelection/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EsiCharacterSelection } from "./EsiCharacterSelection"; diff --git a/src/components/FitButtonBar/ClipboardButton.tsx b/src/components/FitButtonBar/ClipboardButton.tsx index 09df3e8..c43c3ae 100644 --- a/src/components/FitButtonBar/ClipboardButton.tsx +++ b/src/components/FitButtonBar/ClipboardButton.tsx @@ -1,33 +1,35 @@ import clsx from "clsx"; import React from "react"; -import { EsiFit, ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; import { ModalDialog } from "@/components/ModalDialog"; import { useClipboard } from "@/hooks/Clipboard"; -import { useFormatAsEft } from "@/hooks/FormatAsEft"; -import { useFormatEftToEsi } from "@/hooks/FormatEftToEsi"; +import { useExportEft } from "@/hooks/ExportEft"; +import { useImportEft } from "@/hooks/ImportEft"; +import { EsfFit } from "@/providers/CurrentFitProvider"; +import { useFitManager } from "@/providers/FitManagerProvider"; import styles from "./FitButtonBar.module.css"; export const ClipboardButton = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); - const toEft = useFormatAsEft(); - const eftToEsiFit = useFormatEftToEsi(); + const fitManager = useFitManager(); + const exportEft = useExportEft(); + const importEft = useImportEft(); const { copy, copied } = useClipboard(); const [isPopupOpen, setIsPopupOpen] = React.useState(false); const [isPasteOpen, setIsPasteOpen] = React.useState(false); const [error, setError] = React.useState(undefined); + const textAreaRef = React.useRef(null); const copyToClipboard = React.useCallback(() => { - const eft = toEft(); - if (eft === undefined) return; + const eft = exportEft(); + if (eft === null) return; copy(eft); setIsPopupOpen(false); - }, [copy, toEft]); + }, [copy, exportEft]); const importFromClipboard = React.useCallback(() => { setError(undefined); @@ -38,32 +40,33 @@ export const ClipboardButton = () => { const fitString = textArea.value; if (fitString === "") return; - let fit: EsiFit | undefined; + let fit: EsfFit | undefined | null; if (fitString.startsWith("{")) { fit = JSON.parse(fitString); } else { try { - fit = eftToEsiFit(fitString); + fit = importEft(fitString); } catch (e: unknown) { const message = (e as Error).message; setError(`Importing EFT fit failed: ${message}`); return; } } + if (fit === undefined) { setError("Unknown fit format"); return; } + if (fit === null) { + setError("Invalid fit"); + return; + } - shipSnapshot.changeFit(fit); + fitManager.setFit(fit); setIsPasteOpen(false); setIsPopupOpen(false); - }, [eftToEsiFit, shipSnapshot]); - - React.useEffect(() => { - if (isPasteOpen) setError(undefined); - }, [isPasteOpen]); + }, [fitManager, importEft]); return ( <> @@ -75,7 +78,13 @@ export const ClipboardButton = () => {
{copied ? "In Clipboard" : "Clipboard"}
-
setIsPasteOpen(true)}> +
{ + setError(undefined); + setIsPasteOpen(true); + }} + > Import from Clipboard
copyToClipboard()}> diff --git a/src/components/FitButtonBar/FitButtonBar.stories.tsx b/src/components/FitButtonBar/FitButtonBar.stories.tsx index 9a9c54d..88a5e43 100644 --- a/src/components/FitButtonBar/FitButtonBar.stories.tsx +++ b/src/components/FitButtonBar/FitButtonBar.stories.tsx @@ -1,47 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { LocalFitProvider } from "@/providers/LocalFitProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; -import { ModalDialogAnchor } from "@/components/ModalDialog/ModalDialog"; +import { EsfFit } from "@/providers/CurrentFitProvider"; import { FitButtonBar } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: FitButtonBar, tags: ["autodocs"], - title: "Component/FitButtonBar", }; export default meta; -type Story = StoryObj; - -const withEveDataProvider: Decorator> = (Story, context) => { - return ( - - - - -
- - -
-
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { - decorators: [withEveDataProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + width: 400, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/FitButtonBar/RenameButton.tsx b/src/components/FitButtonBar/RenameButton.tsx index 37b1588..dc8086c 100644 --- a/src/components/FitButtonBar/RenameButton.tsx +++ b/src/components/FitButtonBar/RenameButton.tsx @@ -1,26 +1,32 @@ import clsx from "clsx"; import React from "react"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; import { ModalDialog } from "@/components/ModalDialog"; +import { useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useFitManager } from "@/providers/FitManagerProvider"; import styles from "./FitButtonBar.module.css"; export const RenameButton = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); + const currentFit = useCurrentFit(); + const fitManager = useFitManager(); const [isRenameOpen, setIsRenameOpen] = React.useState(false); - const [rename, setRename] = React.useState(""); + const [name, setName] = React.useState(""); + + const nameRef = React.useRef(name); + nameRef.current = name; const saveRename = React.useCallback(() => { - shipSnapshot?.setName(rename); + fitManager.setName(nameRef.current); setIsRenameOpen(false); - }, [rename, shipSnapshot]); + }, [fitManager]); const openRename = React.useCallback(() => { - setRename(shipSnapshot?.currentFit?.name ?? ""); + if (currentFit.fit === null) return; + setName(currentFit.fit.name); setIsRenameOpen(true); - }, [shipSnapshot]); + }, [currentFit.fit]); return ( <> @@ -31,7 +37,7 @@ export const RenameButton = () => { setIsRenameOpen(false)} title="Fit Name">
- setRename(e.target.value)} /> + setName(e.target.value)} /> saveRename()}> Save diff --git a/src/components/FitButtonBar/SaveButton.tsx b/src/components/FitButtonBar/SaveButton.tsx index cdca313..5ffe317 100644 --- a/src/components/FitButtonBar/SaveButton.tsx +++ b/src/components/FitButtonBar/SaveButton.tsx @@ -1,29 +1,28 @@ import clsx from "clsx"; import React from "react"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; -import { LocalFitContext } from "@/providers/LocalFitProvider"; import { ModalDialog } from "@/components/ModalDialog"; +import { useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useLocalFits } from "@/providers/LocalFitsProvider"; import styles from "./FitButtonBar.module.css"; export const SaveButton = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); - const localFit = React.useContext(LocalFitContext); + const currentFit = useCurrentFit(); + const localFits = useLocalFits(); const [isPopupOpen, setIsPopupOpen] = React.useState(false); const [isAlreadyExistsOpen, setIsAlreadyExistsOpen] = React.useState(false); const saveBrowser = React.useCallback( (force?: boolean) => { - if (!localFit.loaded) return; - if (!shipSnapshot.loaded || !shipSnapshot?.currentFit) return; + if (currentFit.fit === null) return; setIsPopupOpen(false); if (!force) { - for (const fit of localFit.fittings) { - if (fit.name === shipSnapshot.currentFit.name) { + for (const fit of localFits.fittings) { + if (fit.name === currentFit.fit.name) { setIsAlreadyExistsOpen(true); return; } @@ -31,10 +30,9 @@ export const SaveButton = () => { } setIsAlreadyExistsOpen(false); - - localFit.addFit(shipSnapshot.currentFit); + localFits.addFit(currentFit.fit); }, - [localFit, shipSnapshot], + [localFits, currentFit.fit], ); return ( @@ -61,7 +59,9 @@ export const SaveButton = () => { title="Update Fitting?" >
-
You have a fitting with the name {shipSnapshot?.currentFit?.name}, do you want to update it?
+
+ You have a local fitting with the name "{currentFit.fit?.name}"; do you want to update it? +
saveBrowser(true)}> Yes diff --git a/src/components/FitButtonBar/ShareButton.tsx b/src/components/FitButtonBar/ShareButton.tsx index a260a8c..a1f49c0 100644 --- a/src/components/FitButtonBar/ShareButton.tsx +++ b/src/components/FitButtonBar/ShareButton.tsx @@ -1,17 +1,19 @@ import React from "react"; import { useClipboard } from "@/hooks/Clipboard"; -import { useEveShipFitLink } from "@/hooks/EveShipFitLink"; +import { useExportEveShipFitHash } from "@/hooks/ExportEveShipFitHash"; import styles from "./FitButtonBar.module.css"; export const ShareButton = () => { - const link = useEveShipFitLink(); + const link = useExportEveShipFitHash(); const { copy, copied } = useClipboard(); const onClick = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); + + if (link === null) return; copy(link); }, [copy, link], diff --git a/src/components/HardwareListing/HardwareListing.stories.tsx b/src/components/HardwareListing/HardwareListing.stories.tsx index 2be7b36..1a505bf 100644 --- a/src/components/HardwareListing/HardwareListing.stories.tsx +++ b/src/components/HardwareListing/HardwareListing.stories.tsx @@ -1,45 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; +import { EsfFit } from "@/providers/CurrentFitProvider"; import { HardwareListing } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: HardwareListing, tags: ["autodocs"], - title: "Component/HardwareListing", }; export default meta; -type Story = StoryObj; - -const useShipSnapshotProvider: Decorator> = (Story, context) => { - return ( - - - - -
- -
-
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { - decorators: [useShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + width: 400, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/HardwareListing/HardwareListing.tsx b/src/components/HardwareListing/HardwareListing.tsx index 32fe86c..88e6108 100644 --- a/src/components/HardwareListing/HardwareListing.tsx +++ b/src/components/HardwareListing/HardwareListing.tsx @@ -2,10 +2,11 @@ import clsx from "clsx"; import React from "react"; import { defaultDataUrl } from "@/settings"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext, ShipSnapshotSlotsType } from "@/providers/ShipSnapshotProvider"; import { Icon } from "@/components/Icon"; import { TreeListing, TreeHeader, TreeLeaf } from "@/components/TreeListing"; +import { StatisticsSlotType, useStatistics } from "@/providers/StatisticsProvider"; +import { useFitManager } from "@/providers/FitManagerProvider"; +import { useEveData } from "@/providers/EveDataProvider"; import styles from "./HardwareListing.module.css"; @@ -20,7 +21,7 @@ interface ListingItem { name: string; meta: number; typeId: number; - slotType: ShipSnapshotSlotsType | "charge"; + slotType: StatisticsSlotType | "droneBay" | "charge"; } interface ListingGroup { @@ -40,34 +41,28 @@ interface Filter { moduleWithCharge: ModuleCharge | undefined; } -const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: boolean }) => { - const shipSnapShot = React.useContext(ShipSnapshotContext); - - const onItemDragStart = React.useCallback( - ( - typeId: ListingItem["typeId"], - slotType: ListingItem["slotType"], - ): ((e: React.DragEvent) => void) => { - return (e: React.DragEvent) => { - const img = new Image(); - img.src = `https://images.evetech.net/types/${typeId}/icon?size=64`; - e.dataTransfer.setDragImage(img, 32, 32); - e.dataTransfer.setData("application/type_id", typeId.toString()); - e.dataTransfer.setData("application/slot_type", slotType); - }; - }, - [], - ); +const OnItemDragStart = ( + typeId: number, + slotType: StatisticsSlotType | "droneBay" | "charge", +): ((e: React.DragEvent) => void) => { + return (e: React.DragEvent) => { + const img = new Image(); + img.src = `https://images.evetech.net/types/${typeId}/icon?size=64`; + e.dataTransfer.setDragImage(img, 32, 32); + e.dataTransfer.setData("application/type_id", typeId.toString()); + e.dataTransfer.setData("application/slot_type", slotType); + }; +}; - const preloadImage = React.useCallback( - (typeId: number): ((e: React.MouseEvent) => void) => { - return () => { - const img = new Image(); - img.src = `https://images.evetech.net/types/${typeId}/icon?size=64`; - }; - }, - [], - ); +const PreloadImage = (typeId: number): ((e: React.MouseEvent) => void) => { + return () => { + const img = new Image(); + img.src = `https://images.evetech.net/types/${typeId}/icon?size=64`; + }; +}; + +const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: boolean }) => { + const fitManager = useFitManager(); const getChildren = React.useCallback(() => { return ( @@ -75,30 +70,17 @@ const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: bo {Object.values(props.group.items) .sort((a, b) => a.meta - b.meta || a.name.localeCompare(b.name)) .map((item) => { - if (item.slotType === "charge") { - return ( - shipSnapShot.addCharge(item.typeId)} - onDragStart={onItemDragStart(item.typeId, "charge")} - onMouseEnter={preloadImage(item.typeId)} - /> - ); - } else { - const slotType = item.slotType; - return ( - shipSnapShot.addModule(item.typeId, slotType)} - onDragStart={onItemDragStart(item.typeId, slotType)} - onMouseEnter={preloadImage(item.typeId)} - /> - ); - } + const slotType = item.slotType; + return ( + fitManager.addItem(item.typeId, slotType)} + onDragStart={OnItemDragStart(item.typeId, slotType)} + onMouseEnter={PreloadImage(item.typeId)} + /> + ); })} {Object.keys(props.group.groups) .sort( @@ -111,7 +93,7 @@ const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: bo })} ); - }, [props, shipSnapShot, onItemDragStart, preloadImage]); + }, [fitManager, props.group, props.level]); if (props.hideGroup) { return ; @@ -130,21 +112,9 @@ const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: bo * Show all the modules you can fit to a ship. */ export const HardwareListing = () => { - const eveData = React.useContext(EveDataContext); - const shipSnapShot = React.useContext(ShipSnapshotContext); - - const [moduleGroups, setModuleGroups] = React.useState({ - name: "Modules", - meta: 0, - groups: {}, - items: [], - }); - const [chargeGroups, setChageGroups] = React.useState({ - name: "Charges", - meta: 0, - groups: {}, - items: [], - }); + const eveData = useEveData(); + const statistics = useStatistics(); + const [search, setSearch] = React.useState(""); const [filter, setFilter] = React.useState({ lowslot: false, @@ -155,21 +125,19 @@ export const HardwareListing = () => { moduleWithCharge: undefined, }); const [selection, setSelection] = React.useState<"modules" | "charges">("modules"); - const [modulesWithCharges, setModulesWithCharges] = React.useState([]); - React.useEffect(() => { - if (!eveData.loaded) return; - if (!shipSnapShot.loaded || shipSnapShot.items === undefined) return; + const modulesWithCharges = React.useMemo(() => { + if (eveData === null || statistics === null) return []; /* Iterate all items to check if they have a charge. */ - const newModulesWithCharges: ModuleCharge[] = []; + const modules: ModuleCharge[] = []; const seenModules = new Set(); - for (const item of shipSnapShot.items) { - const chargeGroup1 = item.attributes.get(eveData?.attributeMapping?.chargeGroup1 || 0)?.value; - const chargeGroup2 = item.attributes.get(eveData?.attributeMapping?.chargeGroup2 || 0)?.value; - const chargeGroup3 = item.attributes.get(eveData?.attributeMapping?.chargeGroup3 || 0)?.value; - const chargeGroup4 = item.attributes.get(eveData?.attributeMapping?.chargeGroup4 || 0)?.value; - const chargeGroup5 = item.attributes.get(eveData?.attributeMapping?.chargeGroup5 || 0)?.value; + for (const item of statistics.items) { + const chargeGroup1 = item.attributes.get(eveData?.attributeMapping.chargeGroup1 ?? 0)?.value; + const chargeGroup2 = item.attributes.get(eveData?.attributeMapping.chargeGroup2 ?? 0)?.value; + const chargeGroup3 = item.attributes.get(eveData?.attributeMapping.chargeGroup3 ?? 0)?.value; + const chargeGroup4 = item.attributes.get(eveData?.attributeMapping.chargeGroup4 ?? 0)?.value; + const chargeGroup5 = item.attributes.get(eveData?.attributeMapping.chargeGroup5 ?? 0)?.value; const chargeGroupIDs: number[] = [chargeGroup1, chargeGroup2, chargeGroup3, chargeGroup4, chargeGroup5].filter( (x): x is number => x !== undefined, @@ -179,30 +147,23 @@ export const HardwareListing = () => { if (seenModules.has(item.type_id)) continue; seenModules.add(item.type_id); - newModulesWithCharges.push({ + modules.push({ typeId: item.type_id, name: eveData?.typeIDs?.[item.type_id].name ?? "Unknown", chargeGroupIDs, - chargeSize: item.attributes.get(eveData?.attributeMapping?.chargeSize || 0)?.value ?? -1, + chargeSize: item.attributes.get(eveData?.attributeMapping.chargeSize ?? 0)?.value ?? -1, }); } - setModulesWithCharges(newModulesWithCharges); - - /* If the moduleWithCharge filter was set, validate if it is still valid. */ - if (newModulesWithCharges.find((charge) => charge.typeId === filter.moduleWithCharge?.typeId) !== undefined) return; - - setFilter({ - ...filter, - moduleWithCharge: undefined, - }); - - /* Filter should not be part of the dependency array. */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shipSnapShot, eveData, setFilter]); + return modules; + }, [eveData, statistics]); - React.useEffect(() => { - if (!eveData.loaded) return; + const { charges, modules } = React.useMemo(() => { + if (eveData === null) + return { + charges: {} as ListingGroup, + modules: {} as ListingGroup, + }; const newModuleGroups: ListingGroup = { name: "Modules", @@ -232,20 +193,20 @@ export const HardwareListing = () => { if (module.marketGroupID === undefined) continue; if (!module.published) continue; - let slotType: ShipSnapshotSlotsType | "charge" | undefined; + let slotType: StatisticsSlotType | "droneBay" | "charge" | undefined; if (module.categoryID !== 8) { - slotType = eveData.typeDogma?.[typeId]?.dogmaEffects + slotType = eveData.typeDogma[typeId]?.dogmaEffects .map((effect) => { switch (effect.effectID) { - case eveData.effectMapping?.loPower: + case eveData.effectMapping.loPower: return "lowslot"; - case eveData.effectMapping?.medPower: + case eveData.effectMapping.medPower: return "medslot"; - case eveData.effectMapping?.hiPower: + case eveData.effectMapping.hiPower: return "hislot"; - case eveData.effectMapping?.rigSlot: + case eveData.effectMapping.rigSlot: return "rig"; - case eveData.effectMapping?.subSystem: + case eveData.effectMapping.subSystem: return "subsystem"; } }) @@ -267,8 +228,8 @@ export const HardwareListing = () => { if (filter.moduleWithCharge !== undefined) { /* If the module has size restrictions, ensure the charge matches. */ const chargeSize = - eveData.typeDogma?.[typeId]?.dogmaAttributes.find( - (attr) => attr.attributeID === eveData.attributeMapping?.chargeSize, + eveData.typeDogma[typeId]?.dogmaAttributes.find( + (attr) => attr.attributeID === eveData.attributeMapping.chargeSize, )?.value ?? -1; if (filter.moduleWithCharge.chargeSize !== -1 && chargeSize !== filter.moduleWithCharge.chargeSize) continue; @@ -308,7 +269,7 @@ export const HardwareListing = () => { let marketGroup: number | undefined = module.marketGroupID; while (marketGroup !== undefined) { marketGroups.push(marketGroup); - marketGroup = eveData.marketGroups?.[marketGroup].parentGroupID; + marketGroup = eveData.marketGroups[marketGroup].parentGroupID; } /* Remove the root group. */ @@ -348,9 +309,9 @@ export const HardwareListing = () => { break; default: - name = eveData.marketGroups?.[group].name ?? "Unknown group"; + name = eveData.marketGroups[group].name ?? "Unknown group"; meta = 1; - iconID = eveData.marketGroups?.[group].iconID; + iconID = eveData.marketGroups[group].iconID; break; } @@ -374,10 +335,23 @@ export const HardwareListing = () => { }); } - setModuleGroups(newModuleGroups); - setChageGroups(newChargeGroups); + return { + charges: newChargeGroups, + modules: newModuleGroups, + }; }, [eveData, search, filter]); + /* If the moduleWithCharge filter was set, validate if it is still valid. */ + if ( + filter.moduleWithCharge !== undefined && + modulesWithCharges.find((charge) => charge.typeId === filter.moduleWithCharge?.typeId) === undefined + ) { + setFilter({ + ...filter, + moduleWithCharge: undefined, + }); + } + return (
@@ -451,10 +425,10 @@ export const HardwareListing = () => {
- +
- +
); diff --git a/src/components/HullListing/HullListing.stories.tsx b/src/components/HullListing/HullListing.stories.tsx index be5e009..f2b7f00 100644 --- a/src/components/HullListing/HullListing.stories.tsx +++ b/src/components/HullListing/HullListing.stories.tsx @@ -1,48 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; import { HullListing } from "./"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { LocalFitProvider } from "@/providers/LocalFitProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: HullListing, tags: ["autodocs"], - title: "Component/HullListing", }; export default meta; -type Story = StoryObj; - -const withEsiProvider: Decorator> = (Story, context) => { - return ( - - - - - -
- -
-
-
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { - args: {}, - decorators: [withEsiProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + width: 400, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/HullListing/HullListing.tsx b/src/components/HullListing/HullListing.tsx index a92efa1..5bdbc4e 100644 --- a/src/components/HullListing/HullListing.tsx +++ b/src/components/HullListing/HullListing.tsx @@ -1,18 +1,19 @@ import clsx from "clsx"; import React from "react"; -import { EsiContext } from "@/providers/EsiProvider"; -import { EsiFit, ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { LocalFitContext } from "@/providers/LocalFitProvider"; import { Icon, IconName } from "@/components/Icon"; import { TreeListing, TreeHeader, TreeHeaderAction, TreeLeaf } from "@/components/TreeListing"; +import { EsfFit, useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useFitManager } from "@/providers/FitManagerProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useCurrentCharacter } from "@/providers/CurrentCharacterProvider"; +import { useLocalFits } from "@/providers/LocalFitsProvider"; import styles from "./HullListing.module.css"; interface ListingFit { - origin: "local" | "esi-character"; - fit: EsiFit; + origin: "local" | "character"; + fit: EsfFit; } interface ListingHull { @@ -41,7 +42,7 @@ const factionIdToRace: Record = { } as const; const Hull = (props: { typeId: number; entry: ListingHull }) => { - const shipSnapShot = React.useContext(ShipSnapshotContext); + const fitManager = useFitManager(); const getChildren = React.useCallback(() => { if (props.entry.fits.length === 0) { @@ -63,7 +64,7 @@ const Hull = (props: { typeId: number; entry: ListingHull }) => { iconTitle = "Browser-stored fitting"; break; - case "esi-character": + case "character": icon = "fitting-character"; iconTitle = "In-game personal fitting"; break; @@ -74,7 +75,7 @@ const Hull = (props: { typeId: number; entry: ListingHull }) => { key={`${fit.fit.ship_type_id}-${index}`} level={4} content={fit.fit.name} - onClick={() => shipSnapShot.changeFit(fit.fit)} + onClick={() => fitManager.setFit(fit.fit)} icon={icon} iconTitle={iconTitle} /> @@ -83,14 +84,14 @@ const Hull = (props: { typeId: number; entry: ListingHull }) => { ); } - }, [props, shipSnapShot]); + }, [fitManager, props.entry]); const onClick = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - shipSnapShot.changeHull(props.typeId); + fitManager.createNewFit(props.typeId); }, - [props, shipSnapShot], + [fitManager, props.typeId], ); const headerAction = ; @@ -116,7 +117,7 @@ const HullRace = (props: { raceId: number; entries: ListingHulls }) => { })} ); - }, [props]); + }, [props.entries]); if (props.entries === undefined) return null; @@ -140,7 +141,7 @@ const HullGroup = (props: { name: string; entries: ListingGroup }) => { ); - }, [props]); + }, [props.entries]); const header = ; return ; @@ -150,114 +151,103 @@ const HullGroup = (props: { name: string; entries: ListingGroup }) => { * Show all the fittings for the current ESI character. */ export const HullListing = () => { - const esi = React.useContext(EsiContext); - const localFit = React.useContext(LocalFitContext); - const eveData = React.useContext(EveDataContext); - const shipSnapShot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const currentFit = useCurrentFit(); + const currentCharacter = useCurrentCharacter(); + const localFits = useLocalFits(); - const [hullGroups, setHullGroups] = React.useState({}); const [search, setSearch] = React.useState(""); const [filter, setFilter] = React.useState({ - localCharacter: false, - esiCharacter: false, + localFits: false, + characterFits: false, currentHull: false, }); - const [localCharacterFits, setLocalCharacterFits] = React.useState>({}); - const [esiCharacterFits, setEsiCharacterFits] = React.useState>({}); - - React.useEffect(() => { - if (!localFit.loaded) return; - if (!localFit.fittings) return; - - const newLocalCharacterFits: Record = {}; - for (const fit of localFit.fittings) { + const localFitsGrouped = React.useMemo(() => { + const grouped: Record = {}; + for (const fit of localFits.fittings) { if (fit.ship_type_id === undefined) continue; - if (newLocalCharacterFits[fit.ship_type_id] === undefined) { - newLocalCharacterFits[fit.ship_type_id] = []; + if (grouped[fit.ship_type_id] === undefined) { + grouped[fit.ship_type_id] = []; } - newLocalCharacterFits[fit.ship_type_id].push({ + grouped[fit.ship_type_id].push({ origin: "local", fit, }); } - setLocalCharacterFits(newLocalCharacterFits); - }, [localFit]); - - React.useEffect(() => { - if (!esi.loaded) return; - if (!esi.currentCharacter) return; + return grouped; + }, [localFits]); - const charFittings = esi.characters[esi.currentCharacter].charFittings || []; + const characterFitsGrouped = React.useMemo(() => { + const characterFittings = currentCharacter.character?.fittings ?? []; - const newEsiCharacterFits: Record = {}; - for (const fit of charFittings) { + const grouped: Record = {}; + for (const fit of characterFittings) { if (fit.ship_type_id === undefined) continue; - if (newEsiCharacterFits[fit.ship_type_id] === undefined) { - newEsiCharacterFits[fit.ship_type_id] = []; + if (grouped[fit.ship_type_id] === undefined) { + grouped[fit.ship_type_id] = []; } - newEsiCharacterFits[fit.ship_type_id].push({ - origin: "esi-character", + grouped[fit.ship_type_id].push({ + origin: "character", fit, }); } - setEsiCharacterFits(newEsiCharacterFits); - }, [esi]); + return grouped; + }, [currentCharacter.character?.fittings]); - React.useEffect(() => { - if (!eveData.loaded) return; - const anyFilter = filter.localCharacter || filter.esiCharacter; + const hullGrouped = React.useMemo(() => { + if (eveData === null) return {}; - const newHullGroups: ListingGroups = {}; + const anyFilter = filter.localFits || filter.characterFits; + const grouped: ListingGroups = {}; for (const typeId in eveData.typeIDs) { const hull = eveData.typeIDs[typeId]; if (hull.categoryID !== 6) continue; if (hull.marketGroupID === undefined) continue; if (!hull.published) continue; - if (filter.currentHull && shipSnapShot.currentFit?.ship_type_id !== parseInt(typeId)) continue; + if (filter.currentHull && currentFit.fit?.ship_type_id !== parseInt(typeId)) continue; const fits: ListingFit[] = []; if (anyFilter) { - if (filter.localCharacter && Object.keys(localCharacterFits).includes(typeId)) - fits.push(...localCharacterFits[typeId]); - if (filter.esiCharacter && Object.keys(esiCharacterFits).includes(typeId)) - fits.push(...esiCharacterFits[typeId]); + if (filter.localFits && Object.keys(localFitsGrouped).includes(typeId)) fits.push(...localFitsGrouped[typeId]); + if (filter.characterFits && Object.keys(characterFitsGrouped).includes(typeId)) + fits.push(...characterFitsGrouped[typeId]); if (fits.length == 0) { - if (!filter.currentHull || shipSnapShot.currentFit?.ship_type_id !== parseInt(typeId)) continue; + if (!filter.currentHull || currentFit.fit?.ship_type_id !== parseInt(typeId)) continue; } } else { - if (Object.keys(localCharacterFits).includes(typeId)) fits.push(...localCharacterFits[typeId]); - if (Object.keys(esiCharacterFits).includes(typeId)) fits.push(...esiCharacterFits[typeId]); + if (Object.keys(localFitsGrouped).includes(typeId)) fits.push(...localFitsGrouped[typeId]); + if (Object.keys(characterFitsGrouped).includes(typeId)) fits.push(...characterFitsGrouped[typeId]); } if (search !== "" && !hull.name.toLowerCase().includes(search.toLowerCase())) continue; - const group = eveData.groupIDs?.[hull.groupID]?.name ?? "Unknown Group"; - const race = factionIdToRace[hull.factionID || 0] ?? "NonEmpire"; + const group = eveData.groupIDs[hull.groupID]?.name ?? "Unknown Group"; + const race = factionIdToRace[hull.factionID ?? 0] ?? "NonEmpire"; - if (newHullGroups[group] === undefined) { - newHullGroups[group] = {}; + if (grouped[group] === undefined) { + grouped[group] = {}; } - if (newHullGroups[group][race] === undefined) { - newHullGroups[group][race] = {}; + if (grouped[group][race] === undefined) { + grouped[group][race] = {}; } - newHullGroups[group][race][typeId] = { + grouped[group][race][typeId] = { name: hull.name, fits, }; } - setHullGroups(newHullGroups); - }, [eveData, search, filter, localCharacterFits, esiCharacterFits, shipSnapShot.currentFit?.ship_type_id]); + return grouped; + }, [eveData, search, filter, localFitsGrouped, characterFitsGrouped, currentFit]); return (
@@ -266,14 +256,14 @@ export const HullListing = () => {
setFilter({ ...filter, localCharacter: !filter.localCharacter })} + className={clsx({ [styles.selected]: filter.localFits })} + onClick={() => setFilter({ ...filter, localFits: !filter.localFits })} > setFilter({ ...filter, esiCharacter: !filter.esiCharacter })} + className={clsx({ [styles.selected]: filter.characterFits })} + onClick={() => setFilter({ ...filter, characterFits: !filter.characterFits })} > @@ -291,10 +281,10 @@ export const HullListing = () => {
- {Object.keys(hullGroups) + {Object.keys(hullGrouped) .sort() .map((groupName) => { - const groupData = hullGroups[groupName]; + const groupData = hullGrouped[groupName]; return ; })}
diff --git a/src/components/Icon/Icon.stories.tsx b/src/components/Icon/Icon.stories.tsx index 551104e..6396195 100644 --- a/src/components/Icon/Icon.stories.tsx +++ b/src/components/Icon/Icon.stories.tsx @@ -5,7 +5,6 @@ import { Icon } from "./"; const meta: Meta = { component: Icon, tags: ["autodocs"], - title: "Component/Icon", }; export default meta; diff --git a/src/components/ModalDialog/ModalDialog.stories.tsx b/src/components/ModalDialog/ModalDialog.stories.tsx index 8230942..6520a6d 100644 --- a/src/components/ModalDialog/ModalDialog.stories.tsx +++ b/src/components/ModalDialog/ModalDialog.stories.tsx @@ -7,7 +7,6 @@ import { ModalDialogAnchor } from "./ModalDialog"; const meta: Meta = { component: ModalDialog, tags: ["autodocs"], - title: "Component/ModalDialog", }; export default meta; diff --git a/src/components/ShipAttribute/ShipAttribute.stories.tsx b/src/components/ShipAttribute/ShipAttribute.stories.tsx index ec0ed2c..3b5e64e 100644 --- a/src/components/ShipAttribute/ShipAttribute.stories.tsx +++ b/src/components/ShipAttribute/ShipAttribute.stories.tsx @@ -1,42 +1,35 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { ShipAttribute } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { component: ShipAttribute, tags: ["autodocs"], - title: "Component/ShipAttribute", }; export default meta; -type Story = StoryObj; - -const withShipSnapshotProvider: Decorator<{ name: string }> = (Story, context) => { - return ( - - - - cpuUsage: - - - - ); -}; +type Story = StoryObj; export const Default: Story = { + argTypes: { + fit: fitArgType, + }, args: { + fit: null, name: "cpuUsed", }, - decorators: [withShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + decorators: [withDecoratorFull], + render: ({ fit, ...args }) => { + useFitSelection(fit); + + return ; }, }; diff --git a/src/components/ShipAttribute/ShipAttribute.tsx b/src/components/ShipAttribute/ShipAttribute.tsx index ace7dc5..ddbf83d 100644 --- a/src/components/ShipAttribute/ShipAttribute.tsx +++ b/src/components/ShipAttribute/ShipAttribute.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; export interface AttributeProps { /** Name of the attribute. */ @@ -20,52 +20,52 @@ export interface AttributeProps { * Return the value of a ship's attribute. */ export function useAttribute(type: "Ship" | "Char", props: AttributeProps) { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const statistics = useStatistics(); - if (shipSnapshot?.loaded) { - const attributeId = eveData.attributeMapping?.[props.name] || 0; - let value; - if (type === "Ship") { - value = shipSnapshot.hull?.attributes.get(attributeId)?.value; - } else { - value = shipSnapshot.char?.attributes.get(attributeId)?.value; - } + if (eveData === null || statistics === null) return ""; - if (value == undefined) { - return "?"; - } + const attributeId = eveData.attributeMapping[props.name] ?? 0; + let value; + if (type === "Ship") { + value = statistics.hull.attributes.get(attributeId)?.value; + } else { + value = statistics.char.attributes.get(attributeId)?.value; + } - if (props.isResistance) { - value = 100 - value * 100; - } + if (value === undefined) { + return "?"; + } - if (props.divideBy) { - value /= props.divideBy; - } + if (props.isResistance) { + value = 100 - value * 100; + } - const k = Math.pow(10, props.fixed); - if (k > 0) { - if (props.isResistance) { - value -= 1 / k / 10; - value = Math.ceil(value * k) / k; - } else if (props.roundDown) { - value = Math.floor(value * k) / k; - } else { - value = Math.round(value * k) / k; - } - } + if (props.divideBy) { + value /= props.divideBy; + } - /* Make sure we don't display "-0", but "0" instead. */ - if (Object.is(value, -0)) { - value = 0; + const k = Math.pow(10, props.fixed); + if (k > 0) { + if (props.isResistance) { + value -= 1 / k / 10; + value = Math.ceil(value * k) / k; + } else if (props.roundDown) { + value = Math.floor(value * k) / k; + } else { + value = Math.round(value * k) / k; } + } - return value.toLocaleString("en", { - minimumFractionDigits: props.fixed, - maximumFractionDigits: props.fixed, - }); + /* Make sure we don't display "-0", but "0" instead. */ + if (Object.is(value, -0)) { + value = 0; } + + return value.toLocaleString("en", { + minimumFractionDigits: props.fixed, + maximumFractionDigits: props.fixed, + }); } /** diff --git a/src/components/ShipFit/FitLink.tsx b/src/components/ShipFit/FitLink.tsx index 9927df3..abc19b0 100644 --- a/src/components/ShipFit/FitLink.tsx +++ b/src/components/ShipFit/FitLink.tsx @@ -1,39 +1,32 @@ import React from "react"; -import { useEveShipFitLink } from "@/hooks/EveShipFitLink"; import { useClipboard } from "@/hooks/Clipboard"; +import { useExportEveShipFitHash } from "@/hooks/ExportEveShipFitHash"; import styles from "./ShipFit.module.css"; -const useIsRemoteViewer = () => { - const [remote, setRemote] = React.useState(true); - - React.useEffect(() => { - if (typeof window !== "undefined") { - setRemote(window.location.hostname !== "eveship.fit"); - } - }, []); - - return remote; -}; - export const FitLink = () => { - const link = useEveShipFitLink(); - const isRemoteViewer = useIsRemoteViewer(); + const link = useExportEveShipFitHash(); const { copy, copied } = useClipboard(); - const linkText = isRemoteViewer ? "open on eveship.fit" : "share fit"; + const isRemote = typeof window !== "undefined"; + + const linkText = isRemote ? "open on eveship.fit" : "share fit"; const linkPropsClick = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); + + if (link === null) return; copy(link); }, [copy, link], ); const linkProps = { - onClick: isRemoteViewer ? undefined : linkPropsClick, + onClick: isRemote ? undefined : linkPropsClick, }; + if (link === null) return <>; + return (
diff --git a/src/components/ShipFit/Hull.tsx b/src/components/ShipFit/Hull.tsx index 4458ab9..9f88127 100644 --- a/src/components/ShipFit/Hull.tsx +++ b/src/components/ShipFit/Hull.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; +import { useCurrentFit } from "@/providers/CurrentFitProvider"; import styles from "./ShipFit.module.css"; @@ -9,16 +9,19 @@ export interface ShipFitProps { } export const Hull = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); + const currentFit = useCurrentFit(); + if (currentFit.fit === null) { + return <>; + } - const hull = shipSnapshot?.currentFit?.ship_type_id; - if (hull === undefined) { + const shipTypeId = currentFit.fit.ship_type_id; + if (shipTypeId === undefined) { return <>; } return (
- +
); }; diff --git a/src/components/ShipFit/HullDraggable.tsx b/src/components/ShipFit/HullDraggable.tsx index 964d364..14b35aa 100644 --- a/src/components/ShipFit/HullDraggable.tsx +++ b/src/components/ShipFit/HullDraggable.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { ShipSnapshotContext, ShipSnapshotSlotsType } from "@/providers"; +import { useFitManager } from "@/providers/FitManagerProvider"; +import { StatisticsSlotType } from "@/providers/StatisticsProvider"; import styles from "./ShipFit.module.css"; export const HullDraggable = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); + const fitManager = useFitManager(); const onDragOver = React.useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -22,9 +23,9 @@ export const HullDraggable = () => { const draggedTypeId: number | undefined = parseNumber(e.dataTransfer.getData("application/type_id")); const draggedSlotId: number | undefined = parseNumber(e.dataTransfer.getData("application/slot_id")); - const draggedSlotType: ShipSnapshotSlotsType | "charge" = e.dataTransfer.getData("application/slot_type") as - | ShipSnapshotSlotsType - | "charge"; + const draggedSlotType: StatisticsSlotType | "droneBay" | "charge" = e.dataTransfer.getData( + "application/slot_type", + ) as StatisticsSlotType | "droneBay" | "charge"; if (draggedTypeId === undefined) { return; @@ -34,14 +35,9 @@ export const HullDraggable = () => { return; } - if (draggedSlotType === "charge") { - shipSnapshot.addCharge(draggedTypeId); - return; - } - - shipSnapshot.addModule(draggedTypeId, draggedSlotType); + fitManager.addItem(draggedTypeId, draggedSlotType); }, - [shipSnapshot], + [fitManager], ); return
; diff --git a/src/components/ShipFit/ShipFit.stories.tsx b/src/components/ShipFit/ShipFit.stories.tsx index 8c83d27..189f5bc 100644 --- a/src/components/ShipFit/ShipFit.stories.tsx +++ b/src/components/ShipFit/ShipFit.stories.tsx @@ -1,44 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { ShipFit } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: ShipFit, tags: ["autodocs"], - title: "Component/ShipFit", }; export default meta; -type Story = StoryObj; - -const withShipSnapshotProvider: Decorator> = (Story, context) => { - return ( - - - -
- -
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { + argTypes: { + fit: fitArgType, + }, args: { + fit: null, width: 730, }, - decorators: [withShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/ShipFit/ShipFit.tsx b/src/components/ShipFit/ShipFit.tsx index d0147fc..d0fc8c7 100644 --- a/src/components/ShipFit/ShipFit.tsx +++ b/src/components/ShipFit/ShipFit.tsx @@ -1,12 +1,13 @@ import React from "react"; import clsx from "clsx"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; import { Icon } from "@/components/Icon"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; import { FitLink } from "./FitLink"; import { Hull } from "./Hull"; +import { HullDraggable } from "./HullDraggable"; import { RadialMenu } from "./RadialMenu"; import { RingInner } from "./RingInner"; import { RingOuter } from "./RingOuter"; @@ -15,26 +16,36 @@ import { Slot } from "./Slot"; import { Usage } from "./Usage"; import styles from "./ShipFit.module.css"; -import { HullDraggable } from "./HullDraggable"; /** * Render a ship fit similar to how it is done in-game. */ export const ShipFit = (props: { withStats?: boolean }) => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); - const slots = shipSnapshot.slots; + const eveData = useEveData(); + const statistics = useStatistics(); + + if (eveData === null) return <>; + + const slots = statistics?.slots ?? { + hislot: 0, + medslot: 0, + lowslot: 0, + rig: 0, + subsystem: 0, + turret: 0, + launcher: 0, + }; let launcherSlotsUsed = - shipSnapshot.items?.filter((item) => - eveData?.typeDogma?.[item.type_id].dogmaEffects.find( - (effect) => effect.effectID === eveData.effectMapping?.launcherFitted, + statistics?.items.filter((item) => + eveData.typeDogma[item.type_id].dogmaEffects.find( + (effect) => effect.effectID === eveData.effectMapping.launcherFitted, ), ).length ?? 0; let turretSlotsUsed = - shipSnapshot.items?.filter((item) => - eveData?.typeDogma?.[item.type_id].dogmaEffects.find( - (effect) => effect.effectID === eveData.effectMapping?.turretFitted, + statistics?.items.filter((item) => + eveData.typeDogma[item.type_id].dogmaEffects.find( + (effect) => effect.effectID === eveData.effectMapping.turretFitted, ), ).length ?? 0; @@ -54,7 +65,7 @@ export const ShipFit = (props: { withStats?: boolean }) => {
- {Array.from({ length: slots?.turret }, (_, i) => { + {Array.from({ length: slots.turret }, (_, i) => { turretSlotsUsed--; return ( @@ -74,7 +85,7 @@ export const ShipFit = (props: { withStats?: boolean }) => {
- {Array.from({ length: slots?.launcher }, (_, i) => { + {Array.from({ length: slots.launcher }, (_, i) => { launcherSlotsUsed--; return ( @@ -112,28 +123,28 @@ export const ShipFit = (props: { withStats?: boolean }) => { - = 1} main /> + = 1} main /> - = 2} /> + = 2} /> - = 3} /> + = 3} /> - = 4} /> + = 4} /> - = 5} /> + = 5} /> - = 6} /> + = 6} /> - = 7} /> + = 7} /> - = 8} /> + = 8} /> @@ -141,28 +152,28 @@ export const ShipFit = (props: { withStats?: boolean }) => { - = 1} /> + = 1} /> - = 2} /> + = 2} /> - = 3} /> + = 3} /> - = 4} /> + = 4} /> - = 5} /> + = 5} /> - = 6} /> + = 6} /> - = 7} /> + = 7} /> - = 8} /> + = 8} /> @@ -170,51 +181,51 @@ export const ShipFit = (props: { withStats?: boolean }) => { - = 1} /> + = 1} /> - = 2} /> + = 2} /> - = 3} /> + = 3} /> - = 4} /> + = 4} /> - = 5} /> + = 5} /> - = 6} /> + = 6} /> - = 7} /> + = 7} /> - = 8} /> + = 8} /> - = 1} /> + = 1} /> - = 2} /> + = 2} /> - = 3} /> + = 3} /> - = 1} /> + = 1} /> - = 2} /> + = 2} /> - = 3} /> + = 3} /> - = 4} /> + = 4} /> diff --git a/src/components/ShipFit/Slot.tsx b/src/components/ShipFit/Slot.tsx index 8096a90..e8923ec 100644 --- a/src/components/ShipFit/Slot.tsx +++ b/src/components/ShipFit/Slot.tsx @@ -1,8 +1,10 @@ import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; import { Icon, IconName } from "@/components/Icon"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; +import { useFitManager } from "@/providers/FitManagerProvider"; +import { State } from "@/providers/CurrentFitProvider"; import styles from "./ShipFit.module.css"; @@ -14,7 +16,7 @@ const esiFlagMapping: Record = { subsystem: [125, 126, 127, 128], }; -const stateRotation: Record = { +const stateRotation: Record = { Passive: ["Passive"], Online: ["Passive", "Online"], Active: ["Passive", "Online", "Active"], @@ -22,97 +24,34 @@ const stateRotation: Record = { }; export const Slot = (props: { type: string; index: number; fittable: boolean; main?: boolean }) => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const statistics = useStatistics(); + const fitManager = useFitManager(); const esiFlagType = props.type; const esiFlag = esiFlagMapping[esiFlagType][props.index - 1]; - const esiItem = shipSnapshot?.items?.find((item) => item.flag == esiFlag); + const esiItem = statistics?.items.find((item) => item.flag == esiFlag); const active = esiItem?.max_state !== "Passive" && esiItem?.max_state !== "Online"; - let item = <>; - let svg = <>; - let imageStyle = styles.slotImage; - - if (props.main !== undefined) { - svg = ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } - - svg = ( - <> - {svg} - - - {props.fittable && esiItem && active && } - {props.fittable && esiItem && !active && } - - - ); - const offlineState = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if (!shipSnapshot?.loaded || !esiItem) return; + + if (esiItem === undefined) return; if (esiItem.state === "Passive") { - shipSnapshot.setItemState(esiItem.flag, "Online"); + fitManager.setModuleState(esiItem.flag, "Online"); } else { - shipSnapshot.setItemState(esiItem.flag, "Passive"); + fitManager.setModuleState(esiItem.flag, "Passive"); } }, - [shipSnapshot, esiItem], + [fitManager, esiItem], ); const cycleState = React.useCallback( (e: React.MouseEvent) => { - if (!shipSnapshot?.loaded || !esiItem) return; + if (esiItem === undefined) return; const states = stateRotation[esiItem.max_state]; const stateIndex = states.indexOf(esiItem.state); @@ -124,29 +63,30 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma newState = states[(stateIndex + 1) % states.length]; } - shipSnapshot.setItemState(esiItem.flag, newState); + fitManager.setModuleState(esiItem.flag, newState); }, - [shipSnapshot, esiItem], + [fitManager, esiItem], ); const unfitModule = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if (!shipSnapshot?.loaded || !esiItem) return; - shipSnapshot.removeModule(esiItem.flag); + if (esiItem === undefined) return; + + fitManager.removeModule(esiItem.flag); }, - [shipSnapshot, esiItem], + [fitManager, esiItem], ); const unfitCharge = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if (!shipSnapshot?.loaded || !esiItem) return; + if (esiItem === undefined) return; - shipSnapshot.removeCharge(esiItem.flag); + fitManager.removeCharge(esiItem.flag); }, - [shipSnapshot, esiItem], + [fitManager, esiItem], ); const onDragStart = React.useCallback( @@ -182,7 +122,7 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma } if (draggedSlotType === "charge") { - shipSnapshot.addCharge(draggedTypeId, esiFlag); + fitManager.setCharge(esiFlag, draggedTypeId); return; } @@ -193,12 +133,79 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma const isDraggedFromAnotherSlot = draggedSlotId !== undefined; if (isDraggedFromAnotherSlot) { - shipSnapshot.moveModule(draggedSlotId, esiFlag); + fitManager.swapModule(esiFlag, draggedSlotId); } else { - shipSnapshot.setModule(draggedTypeId, esiFlag); + fitManager.setModule(esiFlag, draggedTypeId); } }, - [shipSnapshot, esiFlag, esiFlagType], + [fitManager, esiFlag, esiFlagType], + ); + + if (eveData === null || statistics === null) return <>; + + let item = <>; + let svg = <>; + let imageStyle = styles.slotImage; + + if (props.main !== undefined) { + svg = ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + svg = ( + <> + {svg} + + + {props.fittable && esiItem && active && } + {props.fittable && esiItem && !active && } + + ); /* Not fittable and nothing fitted; no need to render the slot. */ @@ -212,12 +219,12 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma ); } - if (esiItem !== undefined) { + if (esiItem !== undefined && eveData !== null) { if (esiItem.charge !== undefined) { item = ( @@ -226,7 +233,7 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma item = ( diff --git a/src/components/ShipFit/Usage.tsx b/src/components/ShipFit/Usage.tsx index 7e5aade..d44c27b 100644 --- a/src/components/ShipFit/Usage.tsx +++ b/src/components/ShipFit/Usage.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; import styles from "./ShipFit.module.css"; @@ -18,32 +18,33 @@ export const Usage = (props: { markers: number; color: string; }) => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const statistics = useStatistics(); + + if (eveData === null) return <>; let usageTotal; let usageUsed; switch (props.type) { case "rig": - usageTotal = shipSnapshot?.hull?.attributes?.get(eveData.attributeMapping?.upgradeCapacity ?? 0)?.value ?? 0; + usageTotal = statistics?.hull.attributes?.get(eveData.attributeMapping.upgradeCapacity ?? 0)?.value ?? 0; usageUsed = - shipSnapshot?.items?.reduce( - (acc, item) => acc + (item.attributes?.get(eveData.attributeMapping?.upgradeCost ?? 0)?.value ?? 0), + statistics?.items.reduce( + (acc, item) => acc + (item.attributes?.get(eveData.attributeMapping.upgradeCost ?? 0)?.value ?? 0), 0, ) ?? 0; break; case "cpu": - usageTotal = shipSnapshot?.hull?.attributes?.get(eveData.attributeMapping?.cpuOutput ?? 0)?.value ?? 0; - usageUsed = - usageTotal - (shipSnapshot?.hull?.attributes?.get(eveData.attributeMapping?.cpuUnused ?? 0)?.value ?? 0); + usageTotal = statistics?.hull.attributes?.get(eveData.attributeMapping.cpuOutput ?? 0)?.value ?? 0; + usageUsed = usageTotal - (statistics?.hull.attributes?.get(eveData.attributeMapping.cpuUnused ?? 0)?.value ?? 0); break; case "pg": - usageTotal = shipSnapshot?.hull?.attributes?.get(eveData.attributeMapping?.powerOutput ?? 0)?.value ?? 0; + usageTotal = statistics?.hull.attributes?.get(eveData.attributeMapping.powerOutput ?? 0)?.value ?? 0; usageUsed = - usageTotal - (shipSnapshot?.hull?.attributes?.get(eveData.attributeMapping?.powerUnused ?? 0)?.value ?? 0); + usageTotal - (statistics?.hull.attributes?.get(eveData.attributeMapping.powerUnused ?? 0)?.value ?? 0); break; } diff --git a/src/components/ShipFitExtended/ShipFitExtended.module.css b/src/components/ShipFitExtended/ShipFitExtended.module.css index 46bd829..cb6c92c 100644 --- a/src/components/ShipFitExtended/ShipFitExtended.module.css +++ b/src/components/ShipFitExtended/ShipFitExtended.module.css @@ -64,3 +64,14 @@ .droneBayVisible { display: block; } + +.empty { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + position: absolute; + top: 0px; + width: 100%; + z-index: 100; +} diff --git a/src/components/ShipFitExtended/ShipFitExtended.stories.tsx b/src/components/ShipFitExtended/ShipFitExtended.stories.tsx index c3b1acc..5bb60dc 100644 --- a/src/components/ShipFitExtended/ShipFitExtended.stories.tsx +++ b/src/components/ShipFitExtended/ShipFitExtended.stories.tsx @@ -1,47 +1,91 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { HardwareListing } from "@/components/HardwareListing"; +import { HullListing } from "@/components/HullListing"; +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { ShipFitExtended } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: ShipFitExtended, tags: ["autodocs"], - title: "Component/ShipFitExtended", }; export default meta; -type Story = StoryObj; - -const useShipSnapshotProvider: Decorator> = (Story, context) => { - return ( - - - - -
- -
-
-
-
-
- ); -}; +type Story = StoryObj; export const Default: Story = { + argTypes: { + fit: fitArgType, + }, args: { + fit: null, width: 730, }, - decorators: [useShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); + }, +}; + +export const WithHardwareListing: Story = { + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+
+ +
+
+
+ +
+
+ ); + }, +}; + +export const WithHullListing: Story = { + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+
+ +
+
+
+ +
+
+ ); }, }; diff --git a/src/components/ShipFitExtended/ShipFitExtended.tsx b/src/components/ShipFitExtended/ShipFitExtended.tsx index 599f63a..0cc380c 100644 --- a/src/components/ShipFitExtended/ShipFitExtended.tsx +++ b/src/components/ShipFitExtended/ShipFitExtended.tsx @@ -1,12 +1,12 @@ import clsx from "clsx"; import React from "react"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; +import { DroneBay } from "@/components/DroneBay"; import { Icon } from "@/components/Icon"; -import { ShipFit } from "@/components/ShipFit"; import { ShipAttribute } from "@/components/ShipAttribute"; -import { DroneBay } from "@/components/DroneBay"; +import { ShipFit } from "@/components/ShipFit"; +import { useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useEveData } from "@/providers/EveDataProvider"; import styles from "./ShipFitExtended.module.css"; @@ -28,12 +28,14 @@ const ShipCargoHold = () => { }; const ShipDroneBay = () => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const currentFit = useCurrentFit(); const [isOpen, setIsOpen] = React.useState(false); - const isStructure = eveData.typeIDs?.[shipSnapshot?.hull?.type_id ?? 0]?.categoryID === 65; + if (eveData === null) return <>; + + const isStructure = eveData.typeIDs[currentFit.fit?.ship_type_id ?? 0]?.categoryID === 65; return ( <> @@ -69,12 +71,12 @@ const CpuPg = (props: { title: string; children: React.ReactNode }) => { }; const FitName = () => { - const shipSnapshot = React.useContext(ShipSnapshotContext); + const currentFit = useCurrentFit(); return ( <>
Name
-
{shipSnapshot?.currentFit?.name}
+
{currentFit.fit?.name}
); }; @@ -87,6 +89,8 @@ const FitName = () => { * bottom of the fit. */ export const ShipFitExtended = () => { + const currentFit = useCurrentFit(); + return (
@@ -108,6 +112,8 @@ export const ShipFitExtended = () => { /
+ + {currentFit.fit === null &&
To start, select a hull on the left.
}
); }; diff --git a/src/components/ShipStatistics/RechargeRate.tsx b/src/components/ShipStatistics/RechargeRate.tsx index 2a0a6d2..4bada50 100644 --- a/src/components/ShipStatistics/RechargeRate.tsx +++ b/src/components/ShipStatistics/RechargeRate.tsx @@ -1,8 +1,8 @@ import clsx from "clsx"; import React from "react"; -import { useAttribute } from "@/components/ShipAttribute"; import { IconName, Icon } from "@/components/Icon"; +import { useAttribute } from "@/components/ShipAttribute"; import styles from "./ShipStatistics.module.css"; diff --git a/src/components/ShipStatistics/ShipStatistics.stories.tsx b/src/components/ShipStatistics/ShipStatistics.stories.tsx index f7898eb..35e795a 100644 --- a/src/components/ShipStatistics/ShipStatistics.stories.tsx +++ b/src/components/ShipStatistics/ShipStatistics.stories.tsx @@ -1,42 +1,39 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { EsiProvider } from "@/providers/EsiProvider"; -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; import { ShipStatistics } from "./"; -const meta: Meta = { +type StoryProps = React.ComponentProps & { fit: EsfFit | null; width: number }; + +const meta: Meta = { component: ShipStatistics, tags: ["autodocs"], - title: "Component/ShipStatistics", }; export default meta; -type Story = StoryObj; - -const useShipSnapshotProvider: Decorator> = (Story, context) => { - return ( - - - - - - - - - - ); -}; +type Story = StoryObj; export const Default: Story = { - decorators: [useShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + width: 730, + }, + decorators: [withDecoratorFull], + render: ({ fit, width, ...args }) => { + useFitSelection(fit); + + return ( +
+ +
+ ); }, }; diff --git a/src/components/ShipStatistics/ShipStatistics.tsx b/src/components/ShipStatistics/ShipStatistics.tsx index 35ac6f6..eb8329e 100644 --- a/src/components/ShipStatistics/ShipStatistics.tsx +++ b/src/components/ShipStatistics/ShipStatistics.tsx @@ -1,11 +1,11 @@ import clsx from "clsx"; import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; -import { ShipAttribute } from "@/components/ShipAttribute"; import { Icon } from "@/components/Icon"; -import { CharAttribute } from "@/components/ShipAttribute/ShipAttribute"; +import { CharAttribute, ShipAttribute } from "@/components/ShipAttribute"; +import { useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; import { Category, CategoryLine } from "./Category"; import { RechargeRate } from "./RechargeRate"; @@ -17,26 +17,25 @@ import styles from "./ShipStatistics.module.css"; * Render ship statistics similar to how it is done in-game. */ export const ShipStatistics = () => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); + const eveData = useEveData(); + const currentFit = useCurrentFit(); + const statistics = useStatistics(); let capacitorState = "Stable"; - const isStructure = eveData.typeIDs?.[shipSnapshot?.hull?.type_id ?? 0]?.categoryID === 65; + const isStructure = eveData?.typeIDs[currentFit.fit?.ship_type_id ?? 0]?.categoryID === 65; - if (shipSnapshot?.loaded) { - const attributeId = eveData.attributeMapping?.capacitorDepletesIn || 0; - const capacitorDepletesIn = shipSnapshot.hull?.attributes.get(attributeId)?.value; + const attributeId = eveData?.attributeMapping.capacitorDepletesIn ?? 0; + const capacitorDepletesIn = statistics?.hull.attributes.get(attributeId)?.value; - if (capacitorDepletesIn !== undefined && capacitorDepletesIn >= 0) { - const hours = Math.floor(capacitorDepletesIn / 3600); - const minutes = Math.floor((capacitorDepletesIn % 3600) / 60); - const seconds = Math.floor(capacitorDepletesIn % 60); - capacitorState = `Depletes in ${hours.toString().padStart(2, "0")}:${minutes - .toString() - .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } else { - capacitorState = "Stable"; - } + if (capacitorDepletesIn !== undefined && capacitorDepletesIn >= 0) { + const hours = Math.floor(capacitorDepletesIn / 3600); + const minutes = Math.floor((capacitorDepletesIn % 3600) / 60); + const seconds = Math.floor(capacitorDepletesIn % 60); + capacitorState = `Depletes in ${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } else { + capacitorState = "Stable"; } return ( diff --git a/src/components/TreeListing/TreeListing.stories.tsx b/src/components/TreeListing/TreeListing.stories.tsx index fdd859d..949a3e9 100644 --- a/src/components/TreeListing/TreeListing.stories.tsx +++ b/src/components/TreeListing/TreeListing.stories.tsx @@ -1,14 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; - import { TreeHeader, TreeListing } from "./"; const meta: Meta = { component: TreeListing, tags: ["autodocs"], - title: "Component/TreeListing", }; export default meta; @@ -20,9 +17,4 @@ export const Default: Story = { header: , level: 0, }, - parameters: { - snapshot: { - initialFit: fullFit, - }, - }, }; diff --git a/src/components/index.ts b/src/components/index.ts index c5e5a08..e69de29 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,12 +0,0 @@ -export * from "./CalculationDetail"; -export * from "./DroneBay"; -export * from "./EsiCharacterSelection"; -export * from "./FitButtonBar"; -export * from "./HardwareListing"; -export * from "./HullListing"; -export * from "./Icon"; -export * from "./ModalDialog"; -export * from "./ShipAttribute"; -export * from "./ShipFit"; -export * from "./ShipFitExtended"; -export * from "./ShipStatistics"; diff --git a/src/hooks/Clipboard.tsx b/src/hooks/Clipboard.tsx index 5d1806f..c5aa26c 100644 --- a/src/hooks/Clipboard.tsx +++ b/src/hooks/Clipboard.tsx @@ -5,17 +5,26 @@ export function useClipboard({ timeout = 2000 } = {}) { const [copied, setCopied] = React.useState(false); const [copyTimeout, setCopyTimeout] = React.useState(undefined); - const handleCopyResult = (value: boolean) => { - if (copyTimeout !== undefined) { - window.clearTimeout(copyTimeout); - } - setCopyTimeout(window.setTimeout(() => setCopied(false), timeout)); - setCopied(value); - }; + const copyTimeoutRef = React.useRef(copyTimeout); + copyTimeoutRef.current = copyTimeout; - const copy = (valueToCopy: string) => { - navigator.clipboard.writeText(valueToCopy).then(() => handleCopyResult(true)); - }; + const handleCopyResult = React.useCallback( + (value: boolean) => { + if (copyTimeoutRef.current !== undefined) { + window.clearTimeout(copyTimeoutRef.current); + } + setCopyTimeout(window.setTimeout(() => setCopied(false), timeout)); + setCopied(value); + }, + [timeout], + ); + + const copy = React.useCallback( + (valueToCopy: string) => { + navigator.clipboard.writeText(valueToCopy).then(() => handleCopyResult(true)); + }, + [handleCopyResult], + ); return { copy, copied }; } diff --git a/src/hooks/EveShipFitHash/EveShipFitHash.stories.tsx b/src/hooks/EveShipFitHash/EveShipFitHash.stories.tsx deleted file mode 100644 index b625efa..0000000 --- a/src/hooks/EveShipFitHash/EveShipFitHash.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { hashFit } from "../../../.storybook/fits"; - -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { EveShipFitHash } from "./EveShipFitHash"; - -const meta: Meta = { - component: EveShipFitHash, - tags: ["autodocs"], - title: "Function/EveShipFitHash", -}; - -const withEveDataProvider: Decorator<{ fitHash: string }> = (Story) => { - return ( - - - - ); -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - fitHash: hashFit, - }, - decorators: [withEveDataProvider], -}; diff --git a/src/hooks/EveShipFitHash/index.ts b/src/hooks/EveShipFitHash/index.ts deleted file mode 100644 index c142acb..0000000 --- a/src/hooks/EveShipFitHash/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useEveShipFitHash } from "./EveShipFitHash"; diff --git a/src/hooks/EveShipFitLink/EveShipFitLink.stories.tsx b/src/hooks/EveShipFitLink/EveShipFitLink.stories.tsx deleted file mode 100644 index e8ef25a..0000000 --- a/src/hooks/EveShipFitLink/EveShipFitLink.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { fullFit } from "../../../.storybook/fits"; - -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; -import { EveShipFitLink } from "./EveShipFitLink"; - -const meta: Meta = { - component: EveShipFitLink, - tags: ["autodocs"], - title: "Function/EveShipFitLink", -}; - -const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context) => { - return ( - - - - - - - - ); -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, - decorators: [withShipSnapshotProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, - }, -}; diff --git a/src/hooks/EveShipFitLink/EveShipFitLink.tsx b/src/hooks/EveShipFitLink/EveShipFitLink.tsx deleted file mode 100644 index fe3f07a..0000000 --- a/src/hooks/EveShipFitLink/EveShipFitLink.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from "react"; - -import { EsiFit, ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; - -async function compress(str: string): Promise { - const stream = new Blob([str]).stream(); - const compressedStream = stream.pipeThrough(new CompressionStream("gzip")); - const reader = compressedStream.getReader(); - - let result = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - result += String.fromCharCode.apply(null, value); - } - - return btoa(result); -} - -async function encodeEsiFit(esiFit: EsiFit): Promise { - let result = `${esiFit.ship_type_id},${esiFit.name},${esiFit.description}\n`; - - for (const item of esiFit.items) { - result += `${item.flag},${item.type_id},${item.quantity},${item.charge?.type_id ?? ""},${item.state ?? ""}\n`; - } - - return "v2:" + (await compress(result)); -} - -/** - * Returns an encoded hash with the current fit. - */ -export function useEveShipFitLinkHash() { - const [fitHash, setFitHash] = React.useState(""); - const shipSnapshot = React.useContext(ShipSnapshotContext); - - React.useEffect(() => { - if (!shipSnapshot?.loaded) return; - - async function doCreateHash() { - if (!shipSnapshot?.currentFit) { - setFitHash(""); - return; - } - - const newFitHash = await encodeEsiFit(shipSnapshot.currentFit); - setFitHash(`#fit:${newFitHash}`); - } - doCreateHash(); - }, [shipSnapshot?.loaded, shipSnapshot?.currentFit]); - - return fitHash; -} - -/** - * Returns a link to https://eveship.fit that contains the current fit. - */ -export function useEveShipFitLink() { - const fitHash = useEveShipFitLinkHash(); - const [fitLink, setFitLink] = React.useState(""); - - React.useEffect(() => { - async function doCreateLink() { - if (fitHash === "") { - setFitLink(""); - return; - } - - setFitLink(`https://eveship.fit/${fitHash}`); - } - doCreateLink(); - }, [fitHash]); - - return fitLink; -} - -/** - * useEveShipFitLink() converts the current fit into a link to https://eveship.fit. - * - * Note: do not use this React component itself, but the useEveShipFitLink() React hook instead. - */ -export const EveShipFitLink = () => { - const eveShipFitLinkHash = useEveShipFitLinkHash(); - const eveShipFitLink = useEveShipFitLink(); - - return ( -
-      Hash: {eveShipFitLinkHash}
-      
- Link: {eveShipFitLink} -
- ); -}; diff --git a/src/hooks/EveShipFitLink/index.ts b/src/hooks/EveShipFitLink/index.ts deleted file mode 100644 index be65dce..0000000 --- a/src/hooks/EveShipFitLink/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useEveShipFitLink, useEveShipFitLinkHash } from "./EveShipFitLink"; diff --git a/src/hooks/ExportEft/ExportEft.stories.tsx b/src/hooks/ExportEft/ExportEft.stories.tsx new file mode 100644 index 0000000..bb6216f --- /dev/null +++ b/src/hooks/ExportEft/ExportEft.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; + +import { ExportEft } from "./ExportEft"; + +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { + component: ExportEft, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [withDecoratorFull], + render: ({ fit, ...args }) => { + useFitSelection(fit); + + return ; + }, +}; diff --git a/src/hooks/ExportEft/ExportEft.tsx b/src/hooks/ExportEft/ExportEft.tsx new file mode 100644 index 0000000..f040bab --- /dev/null +++ b/src/hooks/ExportEft/ExportEft.tsx @@ -0,0 +1,104 @@ +import React from "react"; + +import { useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useStatistics } from "@/providers/StatisticsProvider"; + +/** Mapping between slot types and ESI flags (for first slot in the type). */ +const esiFlagMapping: Record<"hislot" | "medslot" | "lowslot" | "subsystem" | "rig" | "droneBay", number[]> = { + lowslot: [11, 12, 13, 14, 15, 16, 17, 18], + medslot: [19, 20, 21, 22, 23, 24, 25, 26], + hislot: [27, 28, 29, 30, 31, 32, 33, 34], + rig: [92, 93, 94], + subsystem: [125, 126, 127, 128], + droneBay: [87], +}; + +/** Mapping between slot-type and the EFT string name. */ +const slotToEft: Record<"hislot" | "medslot" | "lowslot" | "subsystem" | "rig" | "droneBay", string> = { + lowslot: "Low Slot", + medslot: "Mid Slot", + hislot: "High Slot", + rig: "Rig Slot", + subsystem: "Subsystem Slot", + droneBay: "Drone Bay", +}; + +/** + * Convert current fit to an EFT string. + */ +export function useExportEft() { + const eveData = useEveData(); + const currentFit = useCurrentFit(); + const statistics = useStatistics(); + + return (): string | null => { + const fit = currentFit.fit; + + if (eveData === null || fit === null || statistics === null) return null; + + let eft = ""; + + const shipType = eveData.typeIDs[fit.ship_type_id]; + if (shipType === undefined) return null; + + eft += `[${shipType.name}, ${fit.name}]\n`; + + for (const slotType of Object.keys(esiFlagMapping) as ( + | "hislot" + | "medslot" + | "lowslot" + | "subsystem" + | "rig" + | "droneBay" + )[]) { + let index = 1; + + for (const flag of esiFlagMapping[slotType]) { + if (slotType !== "droneBay" && index > statistics.slots[slotType]) break; + index += 1; + + const modules = fit.items.filter((item) => item.flag === flag); + if (modules === undefined || modules.length === 0) { + eft += "[Empty " + slotToEft[slotType] + "]\n"; + continue; + } + + for (const module of modules) { + const moduleType = eveData.typeIDs[module.type_id]; + if (moduleType === undefined) { + eft += "[Empty " + slotToEft[slotType] + "]\n"; + continue; + } + + eft += moduleType.name; + if (module.quantity > 1) { + eft += ` x${module.quantity}`; + } + if (module.charge !== undefined) { + const chargeType = eveData.typeIDs[module.charge.type_id]; + if (chargeType !== undefined) { + eft += `, ${chargeType.name}`; + } + } + eft += "\n"; + } + } + + eft += "\n"; + } + + return eft; + }; +} + +/** + * `useExportEft` converts the current fit to an EFT string. + * + * Note: do not use this React component itself, but the `useExportEft`) React hook instead. + */ +export const ExportEft = () => { + const exportEft = useExportEft(); + + return
{exportEft()}
; +}; diff --git a/src/hooks/ExportEft/index.ts b/src/hooks/ExportEft/index.ts new file mode 100644 index 0000000..0e46b7e --- /dev/null +++ b/src/hooks/ExportEft/index.ts @@ -0,0 +1 @@ +export { useExportEft } from "./ExportEft"; diff --git a/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.stories.tsx b/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.stories.tsx new file mode 100644 index 0000000..bd2d0ce --- /dev/null +++ b/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; + +import { ExportEveShipFitHash } from "./ExportEveShipFitHash"; + +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { + component: ExportEveShipFitHash, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [withDecoratorFull], + render: ({ fit, ...args }) => { + useFitSelection(fit); + + return ; + }, +}; diff --git a/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.tsx b/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.tsx new file mode 100644 index 0000000..4c7a36d --- /dev/null +++ b/src/hooks/ExportEveShipFitHash/ExportEveShipFitHash.tsx @@ -0,0 +1,74 @@ +import React from "react"; + +import { EsfFit, useCurrentFit } from "@/providers/CurrentFitProvider"; + +async function compress(str: string): Promise { + const stream = new Blob([str]).stream(); + const compressedStream = stream.pipeThrough(new CompressionStream("gzip")); + const reader = compressedStream.getReader(); + + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + result += String.fromCharCode.apply(null, value); + } + + return btoa(result); +} + +async function encodeFit(fit: EsfFit): Promise { + let result = `${fit.ship_type_id},${fit.name},${fit.description}\n`; + + for (const item of fit.items) { + result += `${item.flag},${item.type_id},${item.quantity},${item.charge?.type_id ?? ""},${item.state ?? ""}\n`; + } + + return "v2:" + (await compress(result)); +} + +/** + * Returns a link to https://eveship.fit that contains the current fit. + * + * `hashOnly` controls whether to only show the hash, or the full link. + */ +export function useExportEveShipFitHash(hashOnly?: boolean) { + const currentFit = useCurrentFit(); + + const [fitHash, setFitHash] = React.useState(null); + + React.useEffect(() => { + async function createHash(fit: EsfFit | null) { + if (fit === null) { + setFitHash(null); + return; + } + + const newFitHash = await encodeFit(fit); + setFitHash((hashOnly ? "" : "https://eveship.fit/") + `#fit:${newFitHash}`); + } + + createHash(currentFit.fit); + }, [currentFit.fit, hashOnly]); + + return fitHash; +} + +export interface ExportEveShipFitHashProps { + /** Whether to only show the hash, not the full link. */ + hashOnly?: boolean; +} + +/** + * `useExportEveShipFitHash` converts the current fit into a link to https://eveship.fit. + * + * Note: do not use this React component itself, but the `useExportEveShipFitHash` React hook instead. + */ +export const ExportEveShipFitHash = (props: ExportEveShipFitHashProps) => { + const exportEveShipFitHash = useExportEveShipFitHash(props.hashOnly); + + if (exportEveShipFitHash === null) return <>; + + return
{exportEveShipFitHash}
; +}; diff --git a/src/hooks/ExportEveShipFitHash/index.ts b/src/hooks/ExportEveShipFitHash/index.ts new file mode 100644 index 0000000..70f1773 --- /dev/null +++ b/src/hooks/ExportEveShipFitHash/index.ts @@ -0,0 +1 @@ +export { useExportEveShipFitHash } from "./ExportEveShipFitHash"; diff --git a/src/hooks/FormatAsEft/FormatAsEft.stories.tsx b/src/hooks/FormatAsEft/FormatAsEft.stories.tsx deleted file mode 100644 index 15dddab..0000000 --- a/src/hooks/FormatAsEft/FormatAsEft.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { fullFit } from "../../../.storybook/fits"; - -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { FormatAsEft } from "./FormatAsEft"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; - -const meta: Meta = { - component: FormatAsEft, - tags: ["autodocs"], - title: "Function/FormatAsEft", -}; - -const withEveDataProvider: Decorator> = (Story, context) => { - return ( - - - - - - - - ); -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - decorators: [withEveDataProvider], - parameters: { - snapshot: { - initialFit: fullFit, - }, - }, -}; diff --git a/src/hooks/FormatAsEft/FormatAsEft.tsx b/src/hooks/FormatAsEft/FormatAsEft.tsx deleted file mode 100644 index baab81c..0000000 --- a/src/hooks/FormatAsEft/FormatAsEft.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from "react"; - -import { EveDataContext } from "@/providers/EveDataProvider"; -import { ShipSnapshotContext, ShipSnapshotSlotsType } from "@/providers/ShipSnapshotProvider"; - -/** Mapping between slot types and ESI flags (for first slot in the type). */ -const esiFlagMapping: Record = { - lowslot: [11, 12, 13, 14, 15, 16, 17, 18], - medslot: [19, 20, 21, 22, 23, 24, 25, 26], - hislot: [27, 28, 29, 30, 31, 32, 33, 34], - rig: [92, 93, 94], - subsystem: [125, 126, 127, 128], - droneBay: [87], -}; - -/** Mapping between slot-type and the EFT string name. */ -const slotToEft: Record = { - lowslot: "Low Slot", - medslot: "Mid Slot", - hislot: "High Slot", - rig: "Rig Slot", - subsystem: "Subsystem Slot", - droneBay: "Drone Bay", -}; - -/** - * Convert current fit to an EFT string. - */ -export function useFormatAsEft() { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); - - return (): string | undefined => { - if (!eveData?.loaded) return undefined; - if (!shipSnapshot?.loaded || shipSnapshot.currentFit == undefined) return undefined; - - let eft = ""; - - const shipType = eveData.typeIDs?.[shipSnapshot.currentFit.ship_type_id]; - if (!shipType) return undefined; - - eft += `[${shipType.name}, ${shipSnapshot.currentFit.name}]\n`; - - for (const slotType of Object.keys(esiFlagMapping) as (ShipSnapshotSlotsType | "droneBay")[]) { - let index = 1; - - for (const flag of esiFlagMapping[slotType]) { - if (slotType !== "droneBay" && index > shipSnapshot.slots[slotType]) break; - index += 1; - - const modules = shipSnapshot.currentFit.items.filter((item) => item.flag === flag); - if (modules === undefined || modules.length === 0) { - eft += "[Empty " + slotToEft[slotType] + "]\n"; - continue; - } - - for (const module of modules) { - const moduleType = eveData.typeIDs?.[module.type_id]; - if (moduleType === undefined) { - eft += "[Empty " + slotToEft[slotType] + "]\n"; - continue; - } - - eft += moduleType.name; - if (module.quantity > 1) { - eft += ` x${module.quantity}`; - } - if (module.charge !== undefined) { - const chargeType = eveData.typeIDs?.[module.charge.type_id]; - if (chargeType !== undefined) { - eft += `, ${chargeType.name}`; - } - } - eft += "\n"; - } - } - - eft += "\n"; - } - - return eft; - }; -} - -/** - * useFormatAsEft() converts the current fit to an EFT string. - * - * Note: do not use this React component itself, but the useFormatAsEft() React hook instead. - */ -export const FormatAsEft = () => { - const toEft = useFormatAsEft(); - - return
{toEft()}
; -}; diff --git a/src/hooks/FormatAsEft/index.ts b/src/hooks/FormatAsEft/index.ts deleted file mode 100644 index ad6b3b0..0000000 --- a/src/hooks/FormatAsEft/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFormatAsEft } from "./FormatAsEft"; diff --git a/src/hooks/FormatEftToEsi/FormatEftToEsi.stories.tsx b/src/hooks/FormatEftToEsi/FormatEftToEsi.stories.tsx deleted file mode 100644 index f2ee1aa..0000000 --- a/src/hooks/FormatEftToEsi/FormatEftToEsi.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Decorator, Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { eftFit } from "../../../.storybook/fits"; - -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { FormatEftToEsi } from "./FormatEftToEsi"; - -const meta: Meta = { - component: FormatEftToEsi, - tags: ["autodocs"], - title: "Function/FormatEftToEsi", -}; - -const withEveDataProvider: Decorator<{ eft: string }> = (Story) => { - return ( - - - - ); -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - eft: eftFit, - }, - decorators: [withEveDataProvider], -}; diff --git a/src/hooks/FormatEftToEsi/index.ts b/src/hooks/FormatEftToEsi/index.ts deleted file mode 100644 index f831442..0000000 --- a/src/hooks/FormatEftToEsi/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFormatEftToEsi } from "./FormatEftToEsi"; diff --git a/src/hooks/ImportEft/ImportEft.stories.tsx b/src/hooks/ImportEft/ImportEft.stories.tsx new file mode 100644 index 0000000..4cf722c --- /dev/null +++ b/src/hooks/ImportEft/ImportEft.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { eftFits } from "../../../.storybook/fits"; + +import { EveDataProvider } from "@/providers/EveDataProvider"; + +import { ImportEft } from "./ImportEft"; + +const meta: Meta = { + component: ImportEft, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + eft: { + control: "select", + options: Object.keys(eftFits), + mapping: eftFits, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + render: (args) => , +}; diff --git a/src/hooks/FormatEftToEsi/FormatEftToEsi.tsx b/src/hooks/ImportEft/ImportEft.tsx similarity index 75% rename from src/hooks/FormatEftToEsi/FormatEftToEsi.tsx rename to src/hooks/ImportEft/ImportEft.tsx index 806e2c4..f5b2282 100644 --- a/src/hooks/FormatEftToEsi/FormatEftToEsi.tsx +++ b/src/hooks/ImportEft/ImportEft.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { EsiFit } from "@/providers/ShipSnapshotProvider"; +import { EsfFit } from "@/providers/CurrentFitProvider"; +import { useEveData } from "@/providers/EveDataProvider"; /** Mapping between slot types and ESI flags (for first slot in the type). */ const esiFlagMapping: Record = { @@ -25,15 +25,17 @@ const attributeIdMapping: Record = { }; /** - * Convert an EFT string to an ESI JSON object. + * Convert an EFT string to an ESF Fit. */ -export function useFormatEftToEsi() { - const eveData = React.useContext(EveDataContext); +export function useImportEft() { + const eveData = useEveData(); - return (eft: string): EsiFit | undefined => { - if (!eveData?.loaded) return undefined; + return (eft: string): EsfFit | null => { + if (eveData === null) return null; function lookupTypeByName(name: string): number | undefined { + if (eveData === null) return undefined; + for (const typeId in eveData.typeIDs) { const type = eveData.typeIDs[typeId]; @@ -45,7 +47,7 @@ export function useFormatEftToEsi() { return undefined; } - const esiFit: EsiFit = { + const fit: EsfFit = { name: "EFT Import", description: "", ship_type_id: 0, @@ -54,15 +56,15 @@ export function useFormatEftToEsi() { const lines = eft.trim().split("\n"); - if (!lines[0].startsWith("[")) return undefined; - if (!lines[0].endsWith("]")) return undefined; + if (!lines[0].startsWith("[")) return null; + if (!lines[0].endsWith("]")) return null; const shipType = lines[0].split(",")[0].slice(1); const shipTypeId = lookupTypeByName(shipType); if (shipTypeId === undefined) throw new Error(`Unknown ship '${shipType}'.`); - esiFit.ship_type_id = shipTypeId; - esiFit.name = lines[0].split(",")[1].slice(0, -1).trim(); + fit.ship_type_id = shipTypeId; + fit.name = lines[0].split(",")[1].slice(0, -1).trim(); const slotIndex: Record = { lowslot: 0, @@ -104,8 +106,8 @@ export function useFormatEftToEsi() { const chargeType = (line.split(",")[1] ?? "").trim(); const chargeTypeId = lookupTypeByName(chargeType); - const effects = eveData.typeDogma?.[itemTypeId]?.dogmaEffects; - const attributes = eveData.typeDogma?.[itemTypeId]?.dogmaAttributes; + const effects = eveData.typeDogma[itemTypeId]?.dogmaEffects; + const attributes = eveData.typeDogma[itemTypeId]?.dogmaAttributes; /* Find what type of slot this item goes into. */ let slotType = undefined; @@ -134,7 +136,7 @@ export function useFormatEftToEsi() { }; } - esiFit.items.push({ + fit.items.push({ flag, quantity: itemCount, type_id: itemTypeId, @@ -143,7 +145,7 @@ export function useFormatEftToEsi() { slotIndex[slotType]++; } - return esiFit; + return fit; }; } @@ -153,12 +155,17 @@ export interface FormatEftToEsiProps { } /** - * useFormatEftToEsi() converts an EFT string to an ESI JSON object. + * `useImportEft` converts an EFT string to an ESF fit. * - * Note: do not use this React component itself, but the useFormatEftToEsi() React hook instead. + * Note: do not use this React component itself, but the `useImportEft` React hook instead. */ -export const FormatEftToEsi = (props: FormatEftToEsiProps) => { - const esiFit = useFormatEftToEsi(); - - return
{JSON.stringify(esiFit(props.eft), null, 2)}
; +export const ImportEft = (props: FormatEftToEsiProps) => { + const importEft = useImportEft(); + + try { + const fit = importEft(props.eft); + return
{JSON.stringify(fit, null, 2)}
; + } catch (e: unknown) { + return
Failed to import EFT fit: {(e as Error).message}
; + } }; diff --git a/src/hooks/ImportEft/index.ts b/src/hooks/ImportEft/index.ts new file mode 100644 index 0000000..acec71e --- /dev/null +++ b/src/hooks/ImportEft/index.ts @@ -0,0 +1 @@ +export { useImportEft } from "./ImportEft"; diff --git a/src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.stories.tsx b/src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.stories.tsx new file mode 100644 index 0000000..a6ac367 --- /dev/null +++ b/src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { hashFits } from "../../../.storybook/fits"; + +import { EveDataProvider } from "@/providers/EveDataProvider"; + +import { ImportEveShipFitHash } from "./ImportEveShipFitHash"; + +const meta: Meta = { + component: ImportEveShipFitHash, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + argTypes: { + fitHash: { + control: "select", + options: Object.keys(hashFits), + mapping: hashFits, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + render: (args) => , +}; diff --git a/src/hooks/EveShipFitHash/EveShipFitHash.tsx b/src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.tsx similarity index 70% rename from src/hooks/EveShipFitHash/EveShipFitHash.tsx rename to src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.tsx index 41ca61f..1dceb7e 100644 --- a/src/hooks/EveShipFitHash/EveShipFitHash.tsx +++ b/src/hooks/ImportEveShipFitHash/ImportEveShipFitHash.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { EsiFit } from "@/providers/ShipSnapshotProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { useFormatEftToEsi } from "@/hooks/FormatEftToEsi"; +import { EsfFit } from "@/providers/CurrentFitProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useImportEft } from "../ImportEft"; async function decompress(base64compressedBytes: string): Promise { const stream = new Blob([Uint8Array.from(atob(base64compressedBytes), (c) => c.charCodeAt(0))]).stream(); @@ -20,7 +20,7 @@ async function decompress(base64compressedBytes: string): Promise { return result; } -async function decodeEsiFitV1(fitCompressed: string): Promise { +async function decodeEsiFitV1(fitCompressed: string): Promise { const fitEncoded = await decompress(fitCompressed); const fitLines = fitEncoded.trim().split("\n"); @@ -43,7 +43,7 @@ async function decodeEsiFitV1(fitCompressed: string): Promise { +async function decodeEsiFitV2(fitCompressed: string): Promise { const fitEncoded = await decompress(fitCompressed); const fitLines = fitEncoded.trim().split("\n"); @@ -64,7 +64,7 @@ async function decodeEsiFitV2(fitCompressed: string): Promise => { + if (eveData === null) return null; - return async (killMailHash: string): Promise => { /* The hash is in the format "id/hash". */ const [killmailId, killmailHash] = killMailHash.split("/", 2); /* Fetch the killmail from ESI. */ const response = await fetch(`https://esi.evetech.net/v1/killmails/${killmailId}/${killmailHash}/`); - if (response.status !== 200) return undefined; + if (response.status !== 200) return null; const killMail = await response.json(); /* Convert the killmail to a fit; be mindful that ammo and a module can be on the same slot. */ - let fitItems: EsiFit["items"] = killMail.victim.items.map( + let fitItems: EsfFit["items"] = killMail.victim.items.map( (item: { flag: number; item_type_id: number; quantity_destroyed?: number; quantity_dropped?: number }) => { return { flag: item.flag, @@ -107,7 +109,7 @@ function useFetchKillMail() { /* Ignore cargobay. */ if (item.flag === 5) return item; /* Looks for items that are charges. */ - if (eveData.typeIDs?.[item.type_id]?.categoryID !== 8) return item; + if (eveData.typeIDs[item.type_id]?.categoryID !== 8) return item; /* Find the module on the same slot. */ const module = fitItems.find( @@ -124,7 +126,7 @@ function useFetchKillMail() { /* Remove the charge from the slot. */ return undefined; }) - .filter((item): item is EsiFit["items"][number] => item !== undefined); + .filter((item): item is EsfFit["items"][number] => item !== undefined); return { ship_type_id: killMail.victim.ship_type_id, @@ -136,70 +138,73 @@ function useFetchKillMail() { } function useDecodeEft() { - const formatEftToEsi = useFormatEftToEsi(); + const importEft = useImportEft(); - return async (eftCompressed: string): Promise => { + return async (eftCompressed: string): Promise => { const eft = await decompress(eftCompressed); - return formatEftToEsi(eft); + return importEft(eft); }; } /** * Convert a hash from window.location.hash to an ESI fit. */ -export function useEveShipFitHash() { +export function useImportEveShipFitHash() { const fetchKillMail = useFetchKillMail(); const decodeEft = useDecodeEft(); - return async (fitHash: string): Promise => { - if (!fitHash) return undefined; - + return async (fitHash: string): Promise => { const fitPrefix = fitHash.split(":")[0]; const fitVersion = fitHash.split(":")[1]; const fitEncoded = fitHash.split(":")[2]; - if (fitPrefix !== "fit") return undefined; + if (fitPrefix !== "fit") return null; - let esiFit = undefined; + let fit = undefined; switch (fitVersion) { case "v1": - esiFit = await decodeEsiFitV1(fitEncoded); + fit = await decodeEsiFitV1(fitEncoded); break; case "v2": - esiFit = await decodeEsiFitV2(fitEncoded); + fit = await decodeEsiFitV2(fitEncoded); break; case "killmail": - esiFit = await fetchKillMail(fitEncoded); + fit = await fetchKillMail(fitEncoded); break; case "eft": - esiFit = await decodeEft(fitEncoded); + fit = await decodeEft(fitEncoded); break; } - return esiFit; + return fit; }; } -export interface EveShipFitHashProps { +export interface ImportEveShipFitHashProps { /** The hash of the fit string. */ fitHash: string; } /** - * eveShipFitHash(fitHash) converts a hash from window.location.hash to an ESI fit. + * `importEveShipFitHash` converts a hash from window.location.hash to an ESF fit. * - * Note: do not use this React component itself, but the eveShipFitHash() function instead. + * Note: do not use this React component itself, but the importEveShipFitHash() function instead. */ -export const EveShipFitHash = (props: EveShipFitHashProps) => { - const eveShipFitHash = useEveShipFitHash(); - const [esiFit, setEsiFit] = React.useState(undefined); +export const ImportEveShipFitHash = (props: ImportEveShipFitHashProps) => { + const importEveShipFitHash = useImportEveShipFitHash(); + const [fit, setFit] = React.useState(undefined); React.useEffect(() => { async function getFit(fitHash: string) { - setEsiFit(await eveShipFitHash(fitHash)); + setFit(await importEveShipFitHash(fitHash)); } getFit(props.fitHash); - }, [props.fitHash, eveShipFitHash]); - - return
{JSON.stringify(esiFit, null, 2)}
; + }, [props.fitHash, importEveShipFitHash]); + + return ( +
+ Hash:
{props.fitHash}
+ Fit:
{JSON.stringify(fit, null, 2)}
+
+ ); }; diff --git a/src/hooks/ImportEveShipFitHash/index.ts b/src/hooks/ImportEveShipFitHash/index.ts new file mode 100644 index 0000000..c58ec7f --- /dev/null +++ b/src/hooks/ImportEveShipFitHash/index.ts @@ -0,0 +1 @@ +export { useImportEveShipFitHash } from "./ImportEveShipFitHash"; diff --git a/src/hooks/LocalStorage.tsx b/src/hooks/LocalStorage.tsx index f8642b2..5500a16 100644 --- a/src/hooks/LocalStorage.tsx +++ b/src/hooks/LocalStorage.tsx @@ -8,12 +8,15 @@ export const useLocalStorage = function (key: string, initialValue: T) { return item ? JSON.parse(item) : initialValue; }); + const storedValueRef = React.useRef(storedValue); + storedValueRef.current = storedValue; + const setValue = React.useCallback( (value: T | ((val: T) => T)) => { if (typeof window === "undefined") return; - if (storedValue == value) return; + if (storedValueRef.current == value) return; - const valueToStore = value instanceof Function ? value(storedValue) : value; + const valueToStore = value instanceof Function ? value(storedValueRef.current) : value; setStoredValue(valueToStore); if (valueToStore === undefined) { @@ -23,7 +26,7 @@ export const useLocalStorage = function (key: string, initialValue: T) { window.localStorage.setItem(key, JSON.stringify(valueToStore)); }, - [key, storedValue], + [key], ); return [storedValue, setValue] as const; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9483ff0..e69de29 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +0,0 @@ -export * from "./Clipboard"; -export * from "./EveShipFitHash"; -export * from "./EveShipFitLink"; -export * from "./FormatEftToEsi"; -export * from "./FormatAsEft"; -export * from "./LocalStorage"; diff --git a/src/index.ts b/src/index.ts index 3be0aa2..48c55d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,27 @@ -export * from "./components"; -export * from "./hooks"; -export * from "./providers"; +export * from "./components/CalculationDetail"; +export * from "./components/CharacterSelection"; +export * from "./components/DroneBay"; +export * from "./components/FitButtonBar"; +export * from "./components/HardwareListing"; +export * from "./components/HullListing"; +export * from "./components/Icon"; +export * from "./components/ModalDialog"; +export * from "./components/ShipAttribute"; +export * from "./components/ShipFit"; +export * from "./components/ShipFitExtended"; +export * from "./components/ShipStatistics"; +export * from "./components/TreeListing"; +export * from "./hooks/Clipboard"; +export * from "./hooks/ExportEft"; +export * from "./hooks/ExportEveShipFitHash"; +export * from "./hooks/ImportEft"; +export * from "./hooks/ImportEveShipFitHash"; +export * from "./hooks/LocalStorage"; +export * from "./providers/Characters"; +export * from "./providers/CurrentCharacterProvider"; +export * from "./providers/CurrentFitProvider"; +export * from "./providers/DogmaEngineProvider"; +export * from "./providers/EveDataProvider"; +export * from "./providers/FitManagerProvider"; +export * from "./providers/LocalFitsProvider"; +export * from "./providers/StatisticsProvider"; diff --git a/src/providers/Characters/CharactersContext.tsx b/src/providers/Characters/CharactersContext.tsx new file mode 100644 index 0000000..344cbb2 --- /dev/null +++ b/src/providers/Characters/CharactersContext.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +import { Character } from "@/providers/CurrentCharacterProvider"; + +interface Characters { + characters: Record; + /** Callback given to all Characters Providers, to inform them the character is changed. */ + onCharacterIdChange: (characterId: string) => void; + /** Request from Characters Providers to whoever maintains the current character, to change it. */ + characterIdChangeRequest: string | null; +} + +export const CharactersContext = React.createContext({ + characters: {}, + onCharacterIdChange: () => {}, + characterIdChangeRequest: null, +}); + +export const useCharacters = () => { + return React.useContext(CharactersContext).characters; +}; + +export const useCharactersInternal = () => { + return React.useContext(CharactersContext); +}; diff --git a/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.stories.tsx b/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.stories.tsx new file mode 100644 index 0000000..b59e611 --- /dev/null +++ b/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { EveDataProvider } from "@/providers/EveDataProvider"; + +import { useCharacters } from ".."; +import { DefaultCharactersProvider } from "./"; + +const meta: Meta = { + component: DefaultCharactersProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = () => { + const characters = useCharacters(); + + return ( +
+ {Object.values(characters).map((character) => { + return ( +
+ {character.name} - {Object.keys(character.skills).length} skills +
+ ); + })} +
+ ); +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + }, + decorators: [ + (Story) => { + return ( + + + + ); + }, + ], + render: (args) => ( + + + + ), +}; diff --git a/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.tsx b/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.tsx new file mode 100644 index 0000000..eb644f6 --- /dev/null +++ b/src/providers/Characters/DefaultCharactersProvider/DefaultCharactersProvider.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +import { CharactersContext, useCharactersInternal } from "../CharactersContext"; +import { EveData, useEveData } from "@/providers/EveDataProvider"; +import { Character, Skills } from "@/providers/CurrentCharacterProvider"; + +interface DefaultCharacterProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +const CreateSkills = (eveData: EveData, level: number) => { + const skills: Skills = {}; + for (const typeId in eveData.typeIDs) { + if (eveData.typeIDs[typeId].categoryID !== 16) continue; + skills[typeId] = level; + } + + return skills; +}; + +/** + * Provisions two default characters: all L0 and all L5. + * + * Requires `EveDataProvider` to be present as parent in the component tree. + * + * CharactersProviders can be stacked in what ever way works out best. + */ +export const DefaultCharactersProvider = (props: DefaultCharacterProps) => { + const characters = useCharactersInternal(); + const eveData = useEveData(); + + const contextValue = React.useMemo(() => { + if (eveData === null) return characters; + + const characterAll0: Character = { + name: "Default character - All Skills L0", + skills: CreateSkills(eveData, 0), + fittings: [], + expired: false, + }; + const characterAll5: Character = { + name: "Default character - All Skills L5", + skills: CreateSkills(eveData, 5), + fittings: [], + expired: false, + }; + + return { + onCharacterIdChange: characters.onCharacterIdChange, + characters: { + ...characters.characters, + ".all-0": characterAll0, + ".all-5": characterAll5, + }, + characterIdChangeRequest: characters.characterIdChangeRequest, + }; + }, [characters, eveData]); + + return {props.children}; +}; diff --git a/src/providers/Characters/DefaultCharactersProvider/index.ts b/src/providers/Characters/DefaultCharactersProvider/index.ts new file mode 100644 index 0000000..4108afe --- /dev/null +++ b/src/providers/Characters/DefaultCharactersProvider/index.ts @@ -0,0 +1 @@ +export { DefaultCharactersProvider } from "./DefaultCharactersProvider"; diff --git a/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.stories.tsx b/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.stories.tsx new file mode 100644 index 0000000..4cd1b31 --- /dev/null +++ b/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { EveDataProvider } from "@/providers/EveDataProvider"; + +import { useCharacters, useCharactersInternal } from "../CharactersContext"; +import { EsiCharactersProvider } from "./"; + +const meta: Meta = { + component: EsiCharactersProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = () => { + const characters = useCharacters(); + const charactersInternal = useCharactersInternal(); + + return ( +
+ {Object.values(characters).map((character) => { + return ( +
+ {character.name} - {Object.keys(character.skills).length} skills, {Object.keys(character.fittings).length}{" "} + fits {character.expired && " (expired)"} +
+ ); + })} + +
+
+ Press button to load character: +
+ {Object.keys(characters).map((characterId) => ( +
+ +
+ ))} +
+
+ ); +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + }, + decorators: [ + (Story) => { + return ( + + + + ); + }, + ], + render: (args) => ( + + + + ), +}; diff --git a/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.tsx b/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.tsx new file mode 100644 index 0000000..f58323c --- /dev/null +++ b/src/providers/Characters/EsiCharactersProvider/EsiCharactersProvider.tsx @@ -0,0 +1,235 @@ +import React from "react"; + +import { Character } from "@/providers/CurrentCharacterProvider"; +import { useEveData } from "@/providers/EveDataProvider"; +import { useLocalStorage } from "@/hooks/LocalStorage"; + +import { CharactersContext, useCharactersInternal } from "../CharactersContext"; +import { getAccessToken } from "./EsiGetAccessToken"; +import { getSkills } from "./EsiGetSkills"; +import { getCharFittings } from "./EsiGetFittings"; +import { login } from "./EsiLogin"; + +interface EsiLocalCharacters { + name: string; +} + +interface EsiCharacters { + login: () => void; + refresh: () => void; +} + +const EsiCharactersContext = React.createContext({ + login: () => { + if (typeof window === "undefined") return; + window.location.href = "https://esi.eveship.fit/"; + }, + refresh: () => { + if (typeof window === "undefined") return; + window.location.href = "https://esi.eveship.fit/"; + }, +}); + +export const useEsiCharacters = () => { + return React.useContext(EsiCharactersContext); +}; + +interface EsiProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +const createEmptyCharacter = (name: string): Character => { + return { + name, + skills: {}, + fittings: [], + expired: false, + }; +}; + +/** + * Provisions all logged-in ESI characters. + * + * Refresh-tokens and characters are stored in the LocalStorage of the browser. + * Use `useEsiCharacters` to refresh or login a character. + * + * Requires `EveDataProvider` to be present as parent in the component tree. + * + * CharactersProviders can be stacked in what ever way works out best. + */ +export const EsiCharactersProvider = (props: EsiProps) => { + const characters = useCharactersInternal(); + const eveData = useEveData(); + + const [firstLoad, setFirstLoad] = React.useState(true); + + const [esiCharacters, setEsiCharacters] = React.useState>({}); + const [accessTokens, setAccessTokens] = React.useState>({}); + const [characterIdChangeRequest, setCharacterIdChangeRequest] = React.useState(null); + + const [storedCharacters, setStoredCharacters] = useLocalStorage>("characters", {}); + const [refreshTokens, setRefreshTokens] = useLocalStorage>("refreshTokens", {}); + + /* Use reference for callbacks; they only want to read the content, and are never triggered because of it. */ + const accessTokensRef = React.useRef(accessTokens); + const refreshTokensRef = React.useRef(refreshTokens); + const esiCharactersRef = React.useRef(esiCharacters); + accessTokensRef.current = accessTokens; + refreshTokensRef.current = refreshTokens; + esiCharactersRef.current = esiCharacters; + + const ensureAccessToken = React.useCallback( + async (characterId: string): Promise => { + if (accessTokensRef.current[characterId] !== undefined) { + return accessTokensRef.current[characterId]; + } + + const { accessToken, refreshToken } = await getAccessToken(refreshTokensRef.current[characterId]); + if (accessToken === undefined || refreshToken === undefined) { + /* Refresh-token is no longer valid; mark the character as expired. */ + setEsiCharacters((oldEsiCharacters: Record) => { + return { + ...oldEsiCharacters, + [characterId]: { + ...oldEsiCharacters[characterId], + expired: true, + }, + }; + }); + + return undefined; + } + + setAccessTokens((oldAccessTokens: Record) => { + return { + ...oldAccessTokens, + [characterId]: accessToken, + }; + }); + setRefreshTokens((oldRefreshTokens: Record) => { + return { + ...oldRefreshTokens, + [characterId]: refreshToken, + }; + }); + + return accessToken; + }, + [setAccessTokens, setRefreshTokens], + ); + + const updateCharacter = React.useCallback( + async (characterId: string) => { + if (eveData === null) return; + if (esiCharactersRef.current[characterId] === undefined) return; + + /* Skills already fetched? We won't do it again till the user reloads. */ + if (Object.keys(esiCharactersRef.current[characterId]?.skills).length !== 0) { + return; + } + + const accessToken = await ensureAccessToken(characterId); + if (accessToken === undefined) return; + + const skills = await getSkills(characterId, accessToken); + if (skills === undefined) return; + const fittings = await getCharFittings(characterId, accessToken); + if (fittings === undefined) return; + + /* Ensure all skills are set; also those not learnt. */ + for (const typeId in eveData.typeIDs) { + if (eveData?.typeIDs[typeId].categoryID !== 16) continue; + if (skills[typeId] !== undefined) continue; + skills[typeId] = 0; + } + + setEsiCharacters((oldEsiCharacters: Record) => { + return { + ...oldEsiCharacters, + [characterId]: { + ...oldEsiCharacters[characterId], + skills, + fittings, + }, + }; + }); + }, + [setEsiCharacters, ensureAccessToken, eveData], + ); + + if (firstLoad) { + setFirstLoad(false); + + async function loginCharacter(code: string) { + const character = await login(code); + if (character === null) return; + + setAccessTokens((oldAccessTokens: Record) => { + return { + ...oldAccessTokens, + [character.characterId]: character.accessToken, + }; + }); + setRefreshTokens((oldRefreshTokens: Record) => { + return { + ...oldRefreshTokens, + [character.characterId]: character.refreshToken, + }; + }); + setStoredCharacters((oldStoredCharacters: Record) => { + return { + ...oldStoredCharacters, + [character.characterId]: { + name: character.name, + }, + }; + }); + setEsiCharacters((oldEsiCharacters: Record) => { + return { + ...oldEsiCharacters, + [character.characterId]: createEmptyCharacter(character.name), + }; + }); + + setCharacterIdChangeRequest(character.characterId); + + return true; + } + + /* Check if this was a login request. */ + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get("code"); + if (code) { + /* Remove the code from the URL. */ + window.history.replaceState(null, "", window.location.pathname + window.location.hash); + + loginCharacter(code); + } + + /* Restore characters from local storage. */ + const newEsiCharacters: Record = {}; + for (const characterId in storedCharacters) { + const character = storedCharacters[characterId]; + newEsiCharacters[characterId] = createEmptyCharacter(character.name); + } + + setEsiCharacters(newEsiCharacters); + } + + const contextValue = React.useMemo(() => { + return { + onCharacterIdChange: (characterId: string) => { + updateCharacter(characterId); + characters.onCharacterIdChange(characterId); + }, + characters: { + ...characters.characters, + ...esiCharacters, + }, + characterIdChangeRequest: characterIdChangeRequest ?? characters.characterIdChangeRequest, + }; + }, [characters, esiCharacters, characterIdChangeRequest, updateCharacter]); + + return {props.children}; +}; diff --git a/src/providers/EsiProvider/EsiAccessToken.tsx b/src/providers/Characters/EsiCharactersProvider/EsiGetAccessToken.tsx similarity index 100% rename from src/providers/EsiProvider/EsiAccessToken.tsx rename to src/providers/Characters/EsiCharactersProvider/EsiGetAccessToken.tsx diff --git a/src/providers/EsiProvider/EsiFittings.tsx b/src/providers/Characters/EsiCharactersProvider/EsiGetFittings.tsx similarity index 80% rename from src/providers/EsiProvider/EsiFittings.tsx rename to src/providers/Characters/EsiCharactersProvider/EsiGetFittings.tsx index 851f7aa..6e08831 100644 --- a/src/providers/EsiProvider/EsiFittings.tsx +++ b/src/providers/Characters/EsiCharactersProvider/EsiGetFittings.tsx @@ -1,6 +1,6 @@ -import { EsiFit } from "@/providers/ShipSnapshotProvider"; +import { EsfFit } from "@/providers/CurrentFitProvider"; -export async function getCharFittings(characterId: string, accessToken: string): Promise { +export async function getCharFittings(characterId: string, accessToken: string): Promise { let response; try { response = await fetch(`https://esi.evetech.net/v1/characters/${characterId}/fittings/`, { diff --git a/src/providers/EsiProvider/EsiSkills.tsx b/src/providers/Characters/EsiCharactersProvider/EsiGetSkills.tsx similarity index 85% rename from src/providers/EsiProvider/EsiSkills.tsx rename to src/providers/Characters/EsiCharactersProvider/EsiGetSkills.tsx index f385e51..fff52e9 100644 --- a/src/providers/EsiProvider/EsiSkills.tsx +++ b/src/providers/Characters/EsiCharactersProvider/EsiGetSkills.tsx @@ -1,4 +1,6 @@ -export async function getSkills(characterId: string, accessToken: string): Promise | undefined> { +import { Skills } from "@/providers/CurrentCharacterProvider"; + +export async function getSkills(characterId: string, accessToken: string): Promise { let response; try { response = await fetch(`https://esi.evetech.net/v4/characters/${characterId}/skills/`, { diff --git a/src/providers/Characters/EsiCharactersProvider/EsiLogin.tsx b/src/providers/Characters/EsiCharactersProvider/EsiLogin.tsx new file mode 100644 index 0000000..bb761fb --- /dev/null +++ b/src/providers/Characters/EsiCharactersProvider/EsiLogin.tsx @@ -0,0 +1,49 @@ +import { jwtDecode } from "jwt-decode"; + +interface JwtPayload { + name: string; + sub: string; +} + +export async function login(code: string): Promise<{ + accessToken: string; + refreshToken: string; + name: string; + characterId: string; +} | null> { + let response; + try { + response = await fetch("https://esi.eveship.fit/", { + method: "POST", + body: JSON.stringify({ + code: code, + }), + }); + } catch (e) { + return null; + } + + if (response.status !== 201) { + return null; + } + + const data = await response.json(); + + /* Decode the access-token as it contains the name and character id. */ + const jwt = jwtDecode(data.access_token); + if (!jwt.name || !jwt.sub?.startsWith("CHARACTER:EVE:")) { + return null; + } + + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + const name = jwt.name; + const characterId = jwt.sub.slice("CHARACTER:EVE:".length); + + return { + accessToken, + refreshToken, + name, + characterId, + }; +} diff --git a/src/providers/Characters/EsiCharactersProvider/index.ts b/src/providers/Characters/EsiCharactersProvider/index.ts new file mode 100644 index 0000000..04efac6 --- /dev/null +++ b/src/providers/Characters/EsiCharactersProvider/index.ts @@ -0,0 +1 @@ +export { useEsiCharacters, EsiCharactersProvider } from "./EsiCharactersProvider"; diff --git a/src/providers/Characters/index.ts b/src/providers/Characters/index.ts new file mode 100644 index 0000000..bd2e270 --- /dev/null +++ b/src/providers/Characters/index.ts @@ -0,0 +1,3 @@ +export { useCharacters } from "./CharactersContext"; +export * from "./DefaultCharactersProvider"; +export * from "./EsiCharactersProvider"; diff --git a/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.stories.tsx b/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.stories.tsx new file mode 100644 index 0000000..2d43c2e --- /dev/null +++ b/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { CurrentCharacterProvider, useCurrentCharacter } from "./"; +import { DefaultCharactersProvider, useCharacters } from "../Characters"; +import { EsiCharactersProvider } from "../Characters/EsiCharactersProvider"; +import { EveDataProvider } from "../EveDataProvider"; + +const meta: Meta = { + component: CurrentCharacterProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = () => { + const currentCharacter = useCurrentCharacter(); + const characters = useCharacters(); + + return ( +
+
{JSON.stringify(currentCharacter.character, null, 2)}
+ Press button to load character: +
+ {Object.keys(characters).map((characterId) => ( +
+ +
+ ))} +
+ ); +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + }, + decorators: [ + (Story) => { + return ( + + + + + + + + ); + }, + ], + render: (args) => ( + + + + ), +}; diff --git a/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.tsx b/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.tsx new file mode 100644 index 0000000..0b4cd9f --- /dev/null +++ b/src/providers/CurrentCharacterProvider/CurrentCharacterProvider.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { useLocalStorage } from "@/hooks/LocalStorage"; +import { EsfFit } from "@/providers/CurrentFitProvider"; + +import { useCharactersInternal } from "../Characters/CharactersContext"; + +export type Skills = Record; + +export interface Character { + name: string; + skills: Skills; + fittings: EsfFit[]; + /** Whether the character is based on expired information / credentials. */ + expired: boolean; +} + +interface CurrentCharacter { + character: Character | undefined; + characterId: string | undefined; + setCharacterId: (characterId: string) => void; +} + +const CurrentCharacterContext = React.createContext({ + character: undefined, + characterId: undefined, + setCharacterId: () => {}, +}); + +export const useCurrentCharacter = () => { + return React.useContext(CurrentCharacterContext); +}; + +interface CurrentCharacterProps { + /** The initial characterId to use. Changing this value after first render has no effect. */ + initialCharacterId?: string; + + /** Children that can use this provider. */ + children: React.ReactNode; +} + +/** + * Keeps track of the current character. + * + * This provider must be present after all `CharacterProviders` in the component tree. + * + * Use the `useCurrentCharacter` hook to access or change the current character. + */ +export const CurrentCharacterProvider = (props: CurrentCharacterProps) => { + const characters = useCharactersInternal(); + const [currentCharacterId, setCurrentCharacterId] = useLocalStorage( + "currentCharacter", + props.initialCharacterId ?? ".all-0", + ); + const [firstLoad, setFirstLoad] = React.useState(true); + + const setCharacterId = React.useCallback( + (characterId: string) => { + setFirstLoad(false); + setCurrentCharacterId(characterId); + characters.onCharacterIdChange(characterId); + }, + [characters, setCurrentCharacterId], + ); + + /* Ensure the character is loaded when the provider has retrieved the data. */ + if (firstLoad && characters.characters[currentCharacterId] !== undefined) { + setFirstLoad(false); + characters.onCharacterIdChange(currentCharacterId); + } + + /* Check if any of the Characters Providers requested a character change. */ + if (characters.characterIdChangeRequest !== null) { + setCharacterId(characters.characterIdChangeRequest); + characters.characterIdChangeRequest = null; + } + + const contextValue = React.useMemo(() => { + return { + character: characters.characters[currentCharacterId], + characterId: characters.characters[currentCharacterId] !== undefined ? currentCharacterId : undefined, + setCharacterId, + }; + }, [characters, currentCharacterId, setCharacterId]); + + return {props.children}; +}; diff --git a/src/providers/CurrentCharacterProvider/index.ts b/src/providers/CurrentCharacterProvider/index.ts new file mode 100644 index 0000000..fa960aa --- /dev/null +++ b/src/providers/CurrentCharacterProvider/index.ts @@ -0,0 +1,2 @@ +export { useCurrentCharacter, CurrentCharacterProvider } from "./CurrentCharacterProvider"; +export type { Character, Skills } from "./CurrentCharacterProvider"; diff --git a/src/providers/CurrentFitProvider/CurrentFitProvider.stories.tsx b/src/providers/CurrentFitProvider/CurrentFitProvider.stories.tsx new file mode 100644 index 0000000..469d723 --- /dev/null +++ b/src/providers/CurrentFitProvider/CurrentFitProvider.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useArgs } from "@storybook/preview-api"; +import React from "react"; + +import { fitArgType } from "../../../.storybook/fits"; + +import { CurrentFitProvider, EsfFit, useCurrentFit } from "./"; + +const meta: Meta = { + component: CurrentFitProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = ({ fit }: { fit: EsfFit | null }) => { + const currentFit = useCurrentFit(); + + /* Normally the initialFit argument has no impact after first render. + * But for the storybook this looks silly, so we change the fit like + * it was the first render. */ + React.useEffect(() => { + currentFit.setFit(fit ?? null); + }); + + return
{JSON.stringify(currentFit.fit, null, 2)}
; +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + initialFit: fitArgType, + }, + args: { + initialFit: null, + }, + render: (args) => { + const [{ initialFit }] = useArgs(); + + return ( + + + + ); + }, +}; diff --git a/src/providers/CurrentFitProvider/CurrentFitProvider.tsx b/src/providers/CurrentFitProvider/CurrentFitProvider.tsx new file mode 100644 index 0000000..9cf8edb --- /dev/null +++ b/src/providers/CurrentFitProvider/CurrentFitProvider.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +export type State = "Passive" | "Online" | "Active" | "Overload"; + +export interface EsfFit { + name: string; + description: string; + ship_type_id: number; + items: { + type_id: number; + quantity: number; + flag: number; + charge?: { + type_id: number; + }; + /* State defaults to "Active" if not set. */ + state?: State | string; + }[]; +} + +interface CurrentFit { + fit: EsfFit | null; + setFit: React.Dispatch>; +} + +const CurrentFitContext = React.createContext({ + fit: null, + setFit: () => {}, +}); + +export const useCurrentFit = () => { + return React.useContext(CurrentFitContext); +}; + +interface CurrentFitProps { + /** The initial fit to use. Changing this value after first render has no effect. */ + initialFit?: EsfFit | null; + + /** Children that can use this provider. */ + children: React.ReactNode; +} + +/** + * Keeps track of the current fit. + * + * This provider should be added as early as possible in the component tree. Many + * other components use it. + * + * Use the `useCurrentFit` hook to access or change the current fit. + */ +export const CurrentFitProvider = (props: CurrentFitProps) => { + const [currentFit, setCurrentFit] = React.useState(props.initialFit ?? null); + + const contextValue = React.useMemo(() => { + return { + fit: currentFit, + setFit: setCurrentFit, + }; + }, [currentFit, setCurrentFit]); + + return {props.children}; +}; diff --git a/src/providers/CurrentFitProvider/index.ts b/src/providers/CurrentFitProvider/index.ts new file mode 100644 index 0000000..10e8d34 --- /dev/null +++ b/src/providers/CurrentFitProvider/index.ts @@ -0,0 +1,2 @@ +export { useCurrentFit, CurrentFitProvider } from "./CurrentFitProvider"; +export type { EsfFit, State } from "./CurrentFitProvider"; diff --git a/src/providers/DogmaEngineProvider/DogmaEngineProvider.stories.tsx b/src/providers/DogmaEngineProvider/DogmaEngineProvider.stories.tsx index 3da2487..d3cb6fa 100644 --- a/src/providers/DogmaEngineProvider/DogmaEngineProvider.stories.tsx +++ b/src/providers/DogmaEngineProvider/DogmaEngineProvider.stories.tsx @@ -1,19 +1,22 @@ import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { fullFit } from "../../../.storybook/fits"; +import { fitArgType } from "../../../.storybook/fits"; import { EveDataProvider } from "@/providers/EveDataProvider"; -import { DogmaEngineContext, DogmaEngineProvider } from "./"; +import { EsfFit } from "@/providers/CurrentFitProvider"; -const meta: Meta = { +import { DogmaEngineProvider, useDogmaEngine } from "./"; + +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { component: DogmaEngineProvider, tags: ["autodocs"], - title: "Provider/DogmaEngineProvider", }; export default meta; -type Story = StoryObj; +type Story = StoryObj; /** Convert an ES6 map to an Object, which JSON can stringify. */ function MapToDict(_key: string, value: unknown) { @@ -24,35 +27,38 @@ function MapToDict(_key: string, value: unknown) { return value; } -const TestDogmaEngine = () => { - const dogmaEngine = React.useContext(DogmaEngineContext); - - if (dogmaEngine?.loaded) { - const stats = dogmaEngine.engine?.calculate(fullFit, {}); - - return ( -
- DogmaEngine: loaded -
- Stats: {JSON.stringify(stats, MapToDict)} -
- ); +const TestStory = ({ fit }: { fit: EsfFit | null }) => { + const dogmaEngine = useDogmaEngine(); + if (dogmaEngine === null) { + return
Loading...
; + } + if (fit === null) { + return
No fit selected
; } - return ( -
- DogmaEngine: loading -
-
- ); + return
Stats: {JSON.stringify(dogmaEngine.calculate(fit, {}), MapToDict)}
; }; export const Default: Story = { - render: () => ( - - - - - + argTypes: { + children: { control: false }, + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [ + (Story) => { + return ( + + + + ); + }, + ], + render: ({ fit, ...args }) => ( + + + ), }; diff --git a/src/providers/DogmaEngineProvider/DogmaEngineProvider.tsx b/src/providers/DogmaEngineProvider/DogmaEngineProvider.tsx index 67ca461..a8eaa60 100644 --- a/src/providers/DogmaEngineProvider/DogmaEngineProvider.tsx +++ b/src/providers/DogmaEngineProvider/DogmaEngineProvider.tsx @@ -8,21 +8,19 @@ import { TypeDogmaAttribute, TypeDogmaEffect, TypeID, - EveDataContext, + useEveData, } from "@/providers/EveDataProvider"; -interface EsfDogmaEngine { +interface DogmaEngine { init: typeof init; calculate: typeof calculate; } -interface DogmaEngine { - loaded?: boolean; - loadedData?: boolean; - engine?: EsfDogmaEngine; -} +const DogmaEngineContext = React.createContext(null); -export const DogmaEngineContext = React.createContext({}); +export const useDogmaEngine = () => { + return React.useContext(DogmaEngineContext); +}; declare global { interface Window { @@ -43,11 +41,11 @@ export interface DogmaEngineProps { * Provides method of calculating accurate attributes for a ship fit. * * ```typescript - * const dogmaEngine = React.useContext(DogmaEngineContext); + * const dogmaEngine = useDogmaEngine(); * - * if (dogmaEngine?.loaded) { - * // calculate(esiFit: EsiFit, skills: Record) - * const stats = dogmaEngine.engine.calculate(esiFit, {}); + * if (dogmaEngine !== null) { + * // calculate(fit: EsfFit, skills: Skills) + * const stats = dogmaEngine.calculate(fit, {}); * console.log(stats); * } * ``` @@ -58,50 +56,42 @@ export interface DogmaEngineProps { * that are not trained. */ export const DogmaEngineProvider = (props: DogmaEngineProps) => { - const [dogmaEngine, setDogmaEngine] = React.useState({}); - const eveData = React.useContext(EveDataContext); + const eveData = useEveData(); - React.useEffect(() => { - if (!eveData.loaded) return; + const [firstLoad, setFirstLoad] = React.useState(true); - setDogmaEngine((prevDogmaEngine: DogmaEngine) => { - return { - ...prevDogmaEngine, - loadedData: true, - loaded: prevDogmaEngine.engine !== undefined, - }; + const [dogmaEngine, setDogmaEngine] = React.useState(null); + + if (firstLoad) { + setFirstLoad(false); + + import("@eveshipfit/dogma-engine").then((newDogmaEngine) => { + newDogmaEngine.init(); + setDogmaEngine(newDogmaEngine); }); + } + if (eveData !== null) { window.get_dogma_attributes = (type_id: number): TypeDogmaAttribute[] | undefined => { - return eveData.typeDogma?.[type_id].dogmaAttributes; + return eveData.typeDogma[type_id].dogmaAttributes; }; window.get_dogma_attribute = (attribute_id: number): DogmaAttribute | undefined => { - return eveData.dogmaAttributes?.[attribute_id]; + return eveData.dogmaAttributes[attribute_id]; }; window.get_dogma_effects = (type_id: number): TypeDogmaEffect[] | undefined => { - return eveData.typeDogma?.[type_id].dogmaEffects; + return eveData.typeDogma[type_id].dogmaEffects; }; window.get_dogma_effect = (effect_id: number): DogmaEffect | undefined => { - return eveData.dogmaEffects?.[effect_id]; + return eveData.dogmaEffects[effect_id]; }; window.get_type_id = (type_id: number): TypeID | undefined => { - return eveData.typeIDs?.[type_id]; + return eveData.typeIDs[type_id]; }; - }, [eveData]); - - React.useEffect(() => { - import("@eveshipfit/dogma-engine").then((newDogmaEngine) => { - newDogmaEngine.init(); + } - setDogmaEngine((prevDogmaEngine: DogmaEngine) => { - return { - ...prevDogmaEngine, - engine: newDogmaEngine, - loaded: prevDogmaEngine.loadedData, - }; - }); - }); - }, []); + const contextValue = React.useMemo(() => { + return eveData === null ? null : dogmaEngine; + }, [eveData, dogmaEngine]); - return {props.children}; + return {props.children}; }; diff --git a/src/providers/DogmaEngineProvider/index.ts b/src/providers/DogmaEngineProvider/index.ts index 6a74d6e..1014b1c 100644 --- a/src/providers/DogmaEngineProvider/index.ts +++ b/src/providers/DogmaEngineProvider/index.ts @@ -1 +1 @@ -export { DogmaEngineContext, DogmaEngineProvider } from "./DogmaEngineProvider"; +export { DogmaEngineProvider, useDogmaEngine } from "./DogmaEngineProvider"; diff --git a/src/providers/EsiProvider/EsiProvider.stories.tsx b/src/providers/EsiProvider/EsiProvider.stories.tsx deleted file mode 100644 index dd23b80..0000000 --- a/src/providers/EsiProvider/EsiProvider.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { fullFit } from "../../../.storybook/fits"; - -import { EveDataProvider } from "@/providers/EveDataProvider"; -import { ShipSnapshotProvider } from "@/providers/ShipSnapshotProvider"; - -import { EsiContext, EsiProvider } from "./"; - -const meta: Meta = { - component: EsiProvider, - tags: ["autodocs"], - title: "Provider/EsiProvider", -}; - -export default meta; -type Story = StoryObj; - -const TestEsi = () => { - const esi = React.useContext(EsiContext); - - if (!esi.loaded) { - return ( -
- Esi: loading -
-
- ); - } - - return ( -
- Esi: loaded -
-
{JSON.stringify(esi, null, 2)}
-
- ); -}; - -export const Default: Story = { - render: () => ( - - - - - - - - ), -}; diff --git a/src/providers/EsiProvider/EsiProvider.tsx b/src/providers/EsiProvider/EsiProvider.tsx deleted file mode 100644 index 2586737..0000000 --- a/src/providers/EsiProvider/EsiProvider.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { jwtDecode } from "jwt-decode"; -import React from "react"; - -import { EsiFit, ShipSnapshotContext } from "@/providers/ShipSnapshotProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; -import { useLocalStorage } from "@/hooks/LocalStorage"; - -import { getAccessToken } from "./EsiAccessToken"; -import { getSkills } from "./EsiSkills"; -import { getCharFittings } from "./EsiFittings"; - -export interface EsiCharacter { - name: string; - expired: boolean; - skills?: Record; - charFittings?: EsiFit[]; -} - -export interface Esi { - loaded?: boolean; - characters: Record; - currentCharacter?: string; - - changeCharacter: (character: string) => void; - login: () => void; - refresh: () => void; -} - -interface EsiPrivate { - loaded?: boolean; - refreshTokens: Record; - accessTokens: Record; -} - -interface JwtPayload { - name: string; - sub: string; -} - -export const EsiContext = React.createContext({ - loaded: undefined, - characters: {}, - changeCharacter: () => {}, - login: () => {}, - refresh: () => {}, -}); - -export interface EsiProps { - /** Children that can use this provider. */ - children: React.ReactNode; -} - -/** - * Keeps track (in local storage) of ESI characters and their refresh token. - */ -export const EsiProvider = (props: EsiProps) => { - const eveData = React.useContext(EveDataContext); - const snapshot = React.useContext(ShipSnapshotContext); - - const [esi, setEsi] = React.useState({ - loaded: undefined, - characters: {}, - changeCharacter: () => {}, - login: () => {}, - refresh: () => {}, - }); - const [esiPrivate, setEsiPrivate] = React.useState({ - loaded: undefined, - refreshTokens: {}, - accessTokens: {}, - }); - - const [characters, setCharacters] = useLocalStorage>("characters", {}); - const [refreshTokens, setRefreshTokens] = useLocalStorage("refreshTokens", {}); - const [currentCharacter, setCurrentCharacter] = useLocalStorage("currentCharacter", undefined); - - const changeCharacter = React.useCallback( - (character: string) => { - setCurrentCharacter(character); - - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - currentCharacter: character, - }; - }); - }, - [setCurrentCharacter], - ); - - const login = React.useCallback(() => { - if (typeof window === "undefined") return; - window.location.href = "https://esi.eveship.fit/"; - }, []); - const refresh = React.useCallback(() => { - if (typeof window === "undefined") return; - window.location.href = "https://esi.eveship.fit/"; - }, []); - - const ensureAccessToken = React.useCallback( - async (characterId: string): Promise => { - if (esiPrivate.accessTokens[characterId]) { - return esiPrivate.accessTokens[characterId]; - } - - const { accessToken, refreshToken } = await getAccessToken(esiPrivate.refreshTokens[characterId]); - if (accessToken === undefined || refreshToken === undefined) { - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - characters: { - ...oldEsi.characters, - [characterId]: { - ...oldEsi.characters[characterId], - expired: true, - }, - }, - }; - }); - - return undefined; - } - - /* New access token; store for later use. */ - setEsiPrivate((oldEsiPrivate: EsiPrivate) => { - return { - ...oldEsiPrivate, - refreshTokens: { - ...oldEsiPrivate.refreshTokens, - [characterId]: refreshToken, - }, - accessToken: { - ...oldEsiPrivate.accessTokens, - [characterId]: accessToken, - }, - }; - }); - - return accessToken; - }, - [esiPrivate.accessTokens, esiPrivate.refreshTokens], - ); - - React.useEffect(() => { - if (!eveData.loaded) return; - - const characterId = esi.currentCharacter; - if (characterId === undefined) return; - /* Skills already fetched? We won't do it again till the user reloads. */ - const currentSkills = esi.characters[characterId]?.skills; - if (currentSkills !== undefined) { - snapshot.changeSkills(currentSkills); - return; - } - - if (characterId === ".all-0" || characterId === ".all-5") { - const level = characterId === ".all-0" ? 0 : 5; - - const skills: Record = {}; - for (const typeId in eveData.typeIDs) { - if (eveData?.typeIDs?.[typeId].categoryID !== 16) continue; - skills[typeId] = level; - } - - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - characters: { - ...oldEsi.characters, - [characterId]: { - ...oldEsi.characters[characterId], - skills, - charFittings: [], - }, - }, - }; - }); - - snapshot.changeSkills(skills); - return; - } - - ensureAccessToken(characterId).then((accessToken) => { - if (accessToken === undefined) return; - - getSkills(characterId, accessToken).then((skills) => { - if (skills === undefined) return; - - /* Ensure all skills are set; also those not learnt. */ - for (const typeId in eveData.typeIDs) { - if (eveData?.typeIDs?.[typeId].categoryID !== 16) continue; - if (skills[typeId] !== undefined) continue; - skills[typeId] = 0; - } - - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - characters: { - ...oldEsi.characters, - [characterId]: { - ...oldEsi.characters[characterId], - skills, - }, - }, - }; - }); - - snapshot.changeSkills(skills); - }); - - getCharFittings(characterId, accessToken).then((charFittings) => { - if (charFittings === undefined) return; - - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - characters: { - ...oldEsi.characters, - [characterId]: { - ...oldEsi.characters[characterId], - charFittings, - }, - }, - }; - }); - }); - }); - - /* We only update when currentCharacter changes, and ignore all others. */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [esi.currentCharacter, eveData.loaded]); - - React.useEffect(() => { - if (typeof window === "undefined") return; - - async function loginCharacter(code: string) { - let response; - try { - response = await fetch("https://esi.eveship.fit/", { - method: "POST", - body: JSON.stringify({ - code: code, - }), - }); - } catch (e) { - return false; - } - - if (response.status !== 201) { - return false; - } - - const data = await response.json(); - - /* Decode the access-token as it contains the name and character id. */ - const jwt = jwtDecode(data.access_token); - if (!jwt.name || !jwt.sub?.startsWith("CHARACTER:EVE:")) { - return false; - } - - const accessToken = data.access_token; - const refreshToken = data.refresh_token; - const name = jwt.name; - const characterId = jwt.sub.slice("CHARACTER:EVE:".length); - - /* Update the local storage with the new character. */ - setCharacters((oldCharacters: Record) => { - return { - ...oldCharacters, - [characterId]: { - name: name, - expired: false, - }, - }; - }); - setRefreshTokens((oldRefreshTokens: Record) => { - return { - ...oldRefreshTokens, - [characterId]: refreshToken, - }; - }); - setCurrentCharacter(characterId); - - /* Update the current render with the new character. */ - setEsi((oldEsi: Esi) => { - return { - ...oldEsi, - characters: { - ...oldEsi.characters, - [characterId]: { - name: name, - expired: false, - }, - }, - currentCharacter: characterId, - }; - }); - setEsiPrivate((oldEsiPrivate: EsiPrivate) => { - return { - ...oldEsiPrivate, - refreshTokens: { - ...oldEsiPrivate.refreshTokens, - [characterId]: refreshToken, - }, - accessToken: { - ...oldEsiPrivate.accessTokens, - [characterId]: accessToken, - }, - }; - }); - - return true; - } - - async function startup() { - const charactersDefault = { - ".all-0": { - name: "Default character - All Skills L0", - expired: false, - }, - ".all-5": { - name: "Default character - All Skills L5", - expired: false, - }, - ...characters, - }; - - setEsi({ - loaded: true, - characters: charactersDefault, - currentCharacter, - changeCharacter, - login, - refresh, - }); - setEsiPrivate({ - loaded: true, - refreshTokens, - accessTokens: {}, - }); - - /* Check if this was a login request. */ - const urlParams = new URLSearchParams(window.location.search); - const code = urlParams.get("code"); - if (code) { - /* Remove the code from the URL. */ - window.history.replaceState(null, "", window.location.pathname + window.location.hash); - - if (!(await loginCharacter(code))) { - console.log("Failed to login character"); - } - } - } - - startup(); - - /* This should only on first start. */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return {props.children}; -}; diff --git a/src/providers/EsiProvider/index.ts b/src/providers/EsiProvider/index.ts deleted file mode 100644 index 26a1764..0000000 --- a/src/providers/EsiProvider/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EsiContext, EsiProvider } from "./EsiProvider"; -export type { EsiCharacter, Esi } from "./EsiProvider"; diff --git a/src/providers/EveDataProvider/EveDataProvider.stories.tsx b/src/providers/EveDataProvider/EveDataProvider.stories.tsx index 555b364..d787740 100644 --- a/src/providers/EveDataProvider/EveDataProvider.stories.tsx +++ b/src/providers/EveDataProvider/EveDataProvider.stories.tsx @@ -1,45 +1,49 @@ import type { Meta, StoryObj } from "@storybook/react"; import React from "react"; -import { EveDataContext, EveDataProvider } from "./"; +import { EveDataProvider, useEveData } from "./"; const meta: Meta = { component: EveDataProvider, tags: ["autodocs"], - title: "Provider/EveDataProvider", }; export default meta; type Story = StoryObj; const TestEveData = () => { - const eveData = React.useContext(EveDataContext); + const eveData = useEveData(); + + if (eveData === null) { + return
Loading...
; + } return (
- TypeIDs: {eveData.typeIDs ? Object.keys(eveData.typeIDs).length : "loading"} -
- GroupIDs: {eveData.groupIDs ? Object.keys(eveData.groupIDs).length : "loading"} -
- MarketGroups: {eveData.marketGroups ? Object.keys(eveData.marketGroups).length : "loading"} + TypeIDs: {Object.keys(eveData.typeIDs).length}
- TypeDogma: {eveData.typeDogma ? Object.keys(eveData.typeDogma).length : "loading"} + GroupIDs: {Object.keys(eveData.groupIDs).length}
- DogmaEffects: {eveData.dogmaEffects ? Object.keys(eveData.dogmaEffects).length : "loading"} + MarketGroups: {Object.keys(eveData.marketGroups).length}
- DogmaAttributes: {eveData.dogmaAttributes ? Object.keys(eveData.dogmaAttributes).length : "loading"} + TypeDogma: {Object.keys(eveData.typeDogma).length}
- AttributeMapper: {eveData.attributeMapping ? Object.keys(eveData.attributeMapping).length : "loading"} + DogmaEffects: {Object.keys(eveData.dogmaEffects).length}
+ DogmaAttributes: {Object.keys(eveData.dogmaAttributes).length}
- All loaded: {eveData.loaded ? "yes" : "no"} + AttributeMapper: {Object.keys(eveData.attributeMapping).length}
); }; export const Default: Story = { - render: () => ( - + argTypes: { + children: { control: false }, + dataUrl: { control: false }, + }, + render: (args) => ( + ), diff --git a/src/providers/EveDataProvider/EveDataProvider.tsx b/src/providers/EveDataProvider/EveDataProvider.tsx index 3f330e1..b1fbc05 100644 --- a/src/providers/EveDataProvider/EveDataProvider.tsx +++ b/src/providers/EveDataProvider/EveDataProvider.tsx @@ -9,21 +9,28 @@ import { DogmaAttribute, DogmaEffect, GroupID, MarketGroup, TypeDogma, TypeID } // eslint-disable-next-line import/extensions import * as esf_pb2 from "./esf_pb2.js"; -interface DogmaData { - loaded?: boolean; - typeIDs?: Record; - groupIDs?: Record; - marketGroups?: Record; - typeDogma?: Record; - dogmaEffects?: Record; - dogmaAttributes?: Record; - effectMapping?: Record; - attributeMapping?: Record; +export interface EveData { + typeIDs: Record; + groupIDs: Record; + marketGroups: Record; + typeDogma: Record; + dogmaEffects: Record; + dogmaAttributes: Record; + effectMapping: Record; + attributeMapping: Record; } -export const EveDataContext = React.createContext({}); +const EveDataContext = React.createContext(null); -export interface DogmaDataProps { +export const useEveData = () => { + return React.useContext(EveDataContext); +}; + +export interface EveDataProps { + /** + * URL where the data-files are located. Changing this value after first render has no effect. + * If not set, a built-in default is used, which can only be used for localhost development. + */ dataUrl?: string; /** Children that can use this provider. */ @@ -39,15 +46,13 @@ async function fetchDataFile(dataUrl: string, name: string, pb2: any): Promise { +export const EveDataProvider = (props: EveDataProps) => { const dataUrl = props.dataUrl ?? `${defaultDataUrl}sde/`; - const [dogmaData, setDogmaData] = React.useState({}); + /* Initialize with empty data; we never set the context till everything is loaded. */ + const [dogmaData, setDogmaData] = React.useState({ + typeIDs: {}, + groupIDs: {}, + marketGroups: {}, + typeDogma: {}, + dogmaEffects: {}, + dogmaAttributes: {}, + effectMapping: {}, + attributeMapping: {}, + }); React.useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any function fetchAndLoadDataFile(name: string, pb2: any) { fetchDataFile(dataUrl, name, pb2).then((result) => { - setDogmaData((prevDogmaData: DogmaData) => { + setDogmaData((prevDogmaData: EveData) => { const newDogmaData = { ...prevDogmaData, [name]: result, }; - newDogmaData.loaded = isLoaded(newDogmaData); return newDogmaData; }); }); @@ -89,34 +103,30 @@ export const EveDataProvider = (props: DogmaDataProps) => { fetchAndLoadDataFile("typeDogma", esf_pb2.esf.TypeDogma); fetchAndLoadDataFile("dogmaEffects", esf_pb2.esf.DogmaEffects); fetchAndLoadDataFile("dogmaAttributes", esf_pb2.esf.DogmaAttributes); - }, [dataUrl]); - - React.useEffect(() => { - if (!dogmaData.dogmaAttributes || !dogmaData.dogmaEffects) return; - - /* Create a reverse mapping to quickly lookup attribute/effect name to attribute/effect ID. */ - const attributeMapping: Record = {}; - for (const id in dogmaData.dogmaAttributes) { - const name = dogmaData.dogmaAttributes[id].name; - attributeMapping[name] = parseInt(id); - } - const effectMapping: Record = {}; - for (const id in dogmaData.dogmaEffects) { - const name = dogmaData.dogmaEffects[id].name; - effectMapping[name] = parseInt(id); - } - - setDogmaData((prevDogmaData: DogmaData) => { - const newDogmaData = { - ...prevDogmaData, - attributeMapping: attributeMapping, - effectMapping: effectMapping, - }; - - newDogmaData.loaded = isLoaded(newDogmaData); - return newDogmaData; - }); - }, [dogmaData.dogmaAttributes, dogmaData.dogmaEffects]); - return {props.children}; + /* Only fire on first load of this component. */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!isLoaded(dogmaData)) return <>; + + /* Create a reverse mapping to quickly lookup attribute/effect name to attribute/effect ID. */ + const attributeMapping: Record = {}; + for (const id in dogmaData.dogmaAttributes) { + const name = dogmaData.dogmaAttributes[id].name; + attributeMapping[name] = parseInt(id); + } + const effectMapping: Record = {}; + for (const id in dogmaData.dogmaEffects) { + const name = dogmaData.dogmaEffects[id].name; + effectMapping[name] = parseInt(id); + } + + const contextValue = { + ...dogmaData, + attributeMapping: attributeMapping, + effectMapping: effectMapping, + }; + + return {props.children}; }; diff --git a/src/providers/EveDataProvider/index.ts b/src/providers/EveDataProvider/index.ts index dd6761c..5ca3df4 100644 --- a/src/providers/EveDataProvider/index.ts +++ b/src/providers/EveDataProvider/index.ts @@ -1,2 +1,3 @@ -export { EveDataContext, EveDataProvider } from "./EveDataProvider"; +export { useEveData, EveDataProvider } from "./EveDataProvider"; +export type { EveData } from "./EveDataProvider"; export type { DogmaAttribute, DogmaEffect, TypeDogmaAttribute, TypeDogmaEffect, TypeID } from "./DataTypes"; diff --git a/src/providers/FitManagerProvider/FitManagerProvider.stories.tsx b/src/providers/FitManagerProvider/FitManagerProvider.stories.tsx new file mode 100644 index 0000000..e8b0599 --- /dev/null +++ b/src/providers/FitManagerProvider/FitManagerProvider.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { fitArgType } from "../../../.storybook/fits"; +import { useFitSelection, withDecoratorFull } from "../../../.storybook/helpers"; + +import { EsfFit } from "@/providers/CurrentFitProvider"; + +import { FitManagerProvider } from "./"; + +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { + component: FitManagerProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = () => { + return
; +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [withDecoratorFull], + render: ({ fit, ...args }) => { + useFitSelection(fit); + + return ( + + + + ); + }, +}; diff --git a/src/providers/FitManagerProvider/FitManagerProvider.tsx b/src/providers/FitManagerProvider/FitManagerProvider.tsx new file mode 100644 index 0000000..4c3d6b5 --- /dev/null +++ b/src/providers/FitManagerProvider/FitManagerProvider.tsx @@ -0,0 +1,422 @@ +import React from "react"; + +import { EsfFit, State, useCurrentFit } from "@/providers/CurrentFitProvider"; +import { StatisticsSlotType, useStatistics } from "@/providers/StatisticsProvider"; +import { useEveData } from "@/providers/EveDataProvider"; + +interface FitManager { + /** Set the current fit. */ + setFit: (fit: EsfFit) => void; + /** Create a new fit of the given ship type. */ + createNewFit: (typeId: number) => void; + /** Set the name of the current fit. */ + setName: (name: string) => void; + + /** Add an item (module, charge, drone) to the fit. */ + addItem: (typeId: number, slot: StatisticsSlotType | "droneBay" | "charge") => void; + + /** Set a module in a slot. */ + setModule: (flag: number, typeId: number) => void; + /** Set the state of a module. */ + setModuleState: (flag: number, state: State) => void; + /** Remove a module from a slot. */ + removeModule: (flag: number) => void; + /** Swap two modules. */ + swapModule: (flagA: number, flagB: number) => void; + + /** Set a charge in a module. */ + setCharge: (flag: number, typeId: number) => void; + /** Remove a charge from a module. */ + removeCharge: (flag: number) => void; + + /** Activate N drones of a given type. */ + activateDrones: (typeId: number, amount: number) => void; + /** Remove all drones of a given type. */ + removeDrones: (typeId: number) => void; +} + +const slotStart: Record = { + hislot: 27, + medslot: 19, + lowslot: 11, + subsystem: 125, + rig: 92, + launcher: 0, + turret: 0, +}; + +const FitManagerContext = React.createContext({ + setFit: () => {}, + createNewFit: () => {}, + setName: () => {}, + + addItem: () => {}, + + setModule: () => {}, + setModuleState: () => {}, + removeModule: () => {}, + swapModule: () => {}, + + setCharge: () => {}, + removeCharge: () => {}, + + activateDrones: () => {}, + removeDrones: () => {}, +}); + +export const useFitManager = () => { + return React.useContext(FitManagerContext); +}; + +export interface FitManagerProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +/** + * Provides methods to manipulate the current fit. + */ +export const FitManagerProvider = (props: FitManagerProps) => { + const eveData = useEveData(); + const currentFit = useCurrentFit(); + const statistics = useStatistics(); + const setFit = currentFit.setFit; + + const contextValue = React.useMemo(() => { + if (eveData === null) { + return { + setFit: () => {}, + createNewFit: () => {}, + setName: () => {}, + + addItem: () => {}, + + setModule: () => {}, + setModuleState: () => {}, + removeModule: () => {}, + swapModule: () => {}, + + setCharge: () => {}, + removeCharge: () => {}, + + activateDrones: () => {}, + removeDrones: () => {}, + }; + } + + return { + setFit: (fit: EsfFit) => { + setFit(fit); + }, + createNewFit: (typeId: number) => { + setFit({ + name: "Unnamed Fit", + description: "", + ship_type_id: typeId, + items: [], + }); + }, + setName: (name: string) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + return { + ...oldFit, + name, + }; + }); + }, + + addItem: (typeId: number, slot: StatisticsSlotType | "droneBay" | "charge") => { + setFit((oldFit) => { + if (oldFit === null) return null; + + if (slot === "charge") { + const chargeSize = + eveData.typeDogma[typeId]?.dogmaAttributes.find( + (attr) => attr.attributeID === eveData.attributeMapping?.chargeSize, + )?.value ?? -1; + const groupID = eveData.typeIDs[typeId]?.groupID ?? -1; + + const newItems = []; + for (let item of oldFit.items) { + /* If the module has size restrictions, ensure the charge matches. */ + const moduleChargeSize = eveData.typeDogma[item.type_id]?.dogmaAttributes.find( + (attr) => attr.attributeID === eveData.attributeMapping.chargeSize, + )?.value; + if (moduleChargeSize !== undefined && moduleChargeSize !== chargeSize) { + newItems.push(item); + continue; + } + + /* Check if the charge fits in this module; if so, assign it. */ + for (const attr of eveData.typeDogma[item.type_id]?.dogmaAttributes ?? []) { + switch (attr.attributeID) { + case eveData.attributeMapping.chargeGroup1: + case eveData.attributeMapping.chargeGroup2: + case eveData.attributeMapping.chargeGroup3: + case eveData.attributeMapping.chargeGroup4: + case eveData.attributeMapping.chargeGroup5: + if (attr.value === groupID) { + item = { + ...item, + charge: { + type_id: typeId, + }, + }; + } + break; + } + } + + newItems.push(item); + } + + return { + ...oldFit, + items: newItems, + }; + } + + let flag = undefined; + + /* Find the first free slot for that slot-type. */ + if (slot !== "droneBay") { + const slotsAvailable = statistics?.slots[slot] ?? 0; + for (let i = slotStart[slot]; i < slotStart[slot] + slotsAvailable; i++) { + if (oldFit.items.find((item) => item.flag === i) !== undefined) continue; + + flag = i; + break; + } + console.log(flag); + } else { + flag = 87; + } + + /* Couldn't find a free slot. */ + if (flag === undefined) return oldFit; + + return { + ...oldFit, + items: [ + ...oldFit.items, + { + flag: flag, + type_id: typeId, + quantity: 1, + }, + ], + }; + }); + }, + + setModule: (flag: number, typeId: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + const newItems = oldFit.items + .filter((item) => item.flag !== flag) + .concat({ flag: flag, type_id: typeId, quantity: 1 }); + + return { + ...oldFit, + items: newItems, + }; + }); + }, + setModuleState: (flag: number, state: State) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + return { + ...oldFit, + items: oldFit?.items.map((item) => { + if (item.flag === flag) { + return { + ...item, + state: state, + }; + } + + return item; + }), + }; + }); + }, + removeModule: (flag: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + return { + ...oldFit, + items: oldFit.items.filter((item) => item.flag !== flag), + }; + }); + }, + swapModule: (flagA: number, flagB: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + const newItems = [...oldFit.items]; + + const fromItemIndex = newItems.findIndex((item) => item.flag === flagA); + const fromItem = newItems[fromItemIndex]; + + const toItemIndex = newItems.findIndex((item) => item.flag === flagB); + const toItem = newItems[toItemIndex]; + + fromItem.flag = flagB; + + if (toItem !== undefined) { + /* Target slot is non-empty, swap items. */ + toItem.flag = flagA; + } + + return { + ...oldFit, + items: newItems, + }; + }); + }, + + setCharge: (flag: number, typeId: number) => { + const chargeSize = + eveData.typeDogma[typeId]?.dogmaAttributes.find( + (attr) => attr.attributeID === eveData.attributeMapping?.chargeSize, + )?.value ?? -1; + const groupID = eveData.typeIDs[typeId]?.groupID ?? -1; + + setFit((oldFit) => { + if (oldFit === null) return null; + + const newItems = []; + + for (let item of oldFit.items) { + /* If the module has size restrictions, ensure the charge matches. */ + const moduleChargeSize = eveData.typeDogma[item.type_id]?.dogmaAttributes.find( + (attr) => attr.attributeID === eveData.attributeMapping.chargeSize, + )?.value; + if (moduleChargeSize !== undefined && moduleChargeSize !== chargeSize) { + newItems.push(item); + continue; + } + if (item.flag !== flag) { + newItems.push(item); + continue; + } + + /* Check if the charge fits in this module; if so, assign it. */ + for (const attr of eveData.typeDogma[item.type_id]?.dogmaAttributes ?? []) { + switch (attr.attributeID) { + case eveData.attributeMapping.chargeGroup1: + case eveData.attributeMapping.chargeGroup2: + case eveData.attributeMapping.chargeGroup3: + case eveData.attributeMapping.chargeGroup4: + case eveData.attributeMapping.chargeGroup5: + if (attr.value === groupID) { + item = { + ...item, + charge: { + type_id: typeId, + }, + }; + } + break; + } + } + + newItems.push(item); + } + + return { + ...oldFit, + items: newItems, + }; + }); + }, + removeCharge: (flag: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + return { + ...oldFit, + items: oldFit.items.map((item) => { + if (item.flag === flag) { + return { + ...item, + charge: undefined, + }; + } + + return item; + }), + }; + }); + }, + + activateDrones: (typeId: number, active: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + /* Find the amount of drones in the current fit. */ + const count = oldFit.items + .filter((item) => item.flag === 87 && item.type_id === typeId) + .reduce((acc, item) => acc + item.quantity, 0); + if (count === 0) return oldFit; + + /* If we request the same amount of active than we had, assume we want to deactivate the current. */ + const currentActive = oldFit.items + .filter((item) => item.flag === 87 && item.type_id === typeId && item.state === "Active") + .reduce((acc, item) => acc + item.quantity, 0); + if (currentActive === active) { + active = active - 1; + } + + /* Ensure we never have more active than available. */ + active = Math.min(count, active); + + /* Remove all drones of this type. */ + const newItems = oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId); + + /* Add the active drones. */ + if (active > 0) { + newItems.push({ + flag: 87, + type_id: typeId, + quantity: active, + state: "Active", + }); + } + + /* Add the passive drones. */ + if (active < count) { + newItems.push({ + flag: 87, + type_id: typeId, + quantity: count - active, + state: "Passive", + }); + } + + return { + ...oldFit, + items: newItems, + }; + }); + }, + removeDrones: (typeId: number) => { + setFit((oldFit) => { + if (oldFit === null) return null; + + return { + ...oldFit, + items: oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId), + }; + }); + }, + }; + }, [eveData, statistics?.slots, setFit]); + + return {props.children}; +}; diff --git a/src/providers/FitManagerProvider/index.ts b/src/providers/FitManagerProvider/index.ts new file mode 100644 index 0000000..91a99df --- /dev/null +++ b/src/providers/FitManagerProvider/index.ts @@ -0,0 +1 @@ +export { FitManagerProvider, useFitManager } from "./FitManagerProvider"; diff --git a/src/providers/LocalFitProvider/LocalFitProvider.stories.tsx b/src/providers/LocalFitProvider/LocalFitProvider.stories.tsx deleted file mode 100644 index 206dea9..0000000 --- a/src/providers/LocalFitProvider/LocalFitProvider.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { LocalFitContext, LocalFitProvider } from "./"; - -const meta: Meta = { - component: LocalFitProvider, - tags: ["autodocs"], - title: "Provider/LocalFitProvider", -}; - -export default meta; -type Story = StoryObj; - -const TestLocalFit = () => { - const localFit = React.useContext(LocalFitContext); - - if (!localFit.loaded) { - return ( -
- LocalFit: loading -
-
- ); - } - - return ( -
- LocalFit: loaded -
-
{JSON.stringify(localFit, null, 2)}
-
- ); -}; - -export const Default: Story = { - args: {}, - render: (args) => ( - - - - ), -}; diff --git a/src/providers/LocalFitProvider/LocalFitProvider.tsx b/src/providers/LocalFitProvider/LocalFitProvider.tsx deleted file mode 100644 index 06f31df..0000000 --- a/src/providers/LocalFitProvider/LocalFitProvider.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; - -import { useLocalStorage } from "@/hooks/LocalStorage"; -import { EsiFit } from "@/providers/ShipSnapshotProvider"; - -export interface LocalFit { - loaded?: boolean; - fittings: EsiFit[]; - addFit: (fit: EsiFit) => void; -} - -export const LocalFitContext = React.createContext({ - loaded: undefined, - fittings: [], - addFit: () => {}, -}); - -export interface LocalFitProps { - /** Children that can use this provider. */ - children: React.ReactNode; -} - -/** - * Keeps track (in local storage) of fits. - */ -export const LocalFitProvider = (props: LocalFitProps) => { - const [localFit, setLocalFit] = React.useState({ - loaded: undefined, - fittings: [], - addFit: () => {}, - }); - - const [localFitValue, setLocalFitValue] = useLocalStorage("fits", []); - - const addFit = React.useCallback( - (fit: EsiFit) => { - setLocalFitValue((oldFits) => { - const newFits = oldFits.filter((oldFit) => oldFit.name !== fit.name); - newFits.push(fit); - return newFits; - }); - }, - [setLocalFitValue], - ); - - React.useEffect(() => { - setLocalFit({ - loaded: true, - fittings: localFitValue, - addFit, - }); - }, [localFitValue, addFit]); - - return {props.children}; -}; diff --git a/src/providers/LocalFitProvider/index.ts b/src/providers/LocalFitProvider/index.ts deleted file mode 100644 index f62440c..0000000 --- a/src/providers/LocalFitProvider/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LocalFitContext, LocalFitProvider } from "./LocalFitProvider"; -export type { LocalFit } from "./LocalFitProvider"; diff --git a/src/providers/LocalFitsProvider/LocalFitsProvider.stories.tsx b/src/providers/LocalFitsProvider/LocalFitsProvider.stories.tsx new file mode 100644 index 0000000..52c1cfa --- /dev/null +++ b/src/providers/LocalFitsProvider/LocalFitsProvider.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { LocalFitsProvider, useLocalFits } from "./"; + +const meta: Meta = { + component: LocalFitsProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = () => { + const localFits = useLocalFits(); + + return ( +
+ {Object.values(localFits.fittings).map((fit) => { + return ( +
+ {fit.name} - {Object.keys(fit.items).length} items +
+ ); + })} +
+ ); +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + }, + render: (args) => ( + + + + ), +}; diff --git a/src/providers/LocalFitsProvider/LocalFitsProvider.tsx b/src/providers/LocalFitsProvider/LocalFitsProvider.tsx new file mode 100644 index 0000000..e7c5261 --- /dev/null +++ b/src/providers/LocalFitsProvider/LocalFitsProvider.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +import { useLocalStorage } from "@/hooks/LocalStorage"; +import { EsfFit } from "@/providers/CurrentFitProvider"; + +interface LocalFits { + fittings: EsfFit[]; + addFit: (fit: EsfFit) => void; +} + +const LocalFitsContext = React.createContext({ + fittings: [], + addFit: () => {}, +}); + +export const useLocalFits = () => { + return React.useContext(LocalFitsContext); +}; + +interface LocalFitsProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +/** + * Keeps track (in local storage) of fits. + * + * Use the `useLocalFits` hook to access or change the local fits. + */ +export const LocalFitsProvider = (props: LocalFitsProps) => { + const [localFitsValue, setLocalFitsValue] = useLocalStorage("fits", []); + + const addFit = React.useCallback( + (fit: EsfFit) => { + setLocalFitsValue((oldFits) => { + const newFits = oldFits.filter((oldFit) => oldFit.name !== fit.name); + newFits.push(fit); + return newFits; + }); + }, + [setLocalFitsValue], + ); + + const contextValue = React.useMemo(() => { + return { + fittings: localFitsValue, + addFit, + }; + }, [localFitsValue, addFit]); + + return {props.children}; +}; diff --git a/src/providers/LocalFitsProvider/index.ts b/src/providers/LocalFitsProvider/index.ts new file mode 100644 index 0000000..b7d9282 --- /dev/null +++ b/src/providers/LocalFitsProvider/index.ts @@ -0,0 +1 @@ +export { LocalFitsProvider, useLocalFits } from "./LocalFitsProvider"; diff --git a/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.stories.tsx b/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.stories.tsx deleted file mode 100644 index be8004f..0000000 --- a/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import React from "react"; - -import { fullFit } from "../../../.storybook/fits"; - -import { EveDataContext, EveDataProvider } from "@/providers/EveDataProvider"; -import { DogmaEngineProvider } from "@/providers/DogmaEngineProvider"; -import { ShipSnapshotItemAttribute, ShipSnapshotContext, ShipSnapshotProvider } from "./"; - -const meta: Meta = { - component: ShipSnapshotProvider, - tags: ["autodocs"], - title: "Provider/ShipSnapshotProvider", -}; - -export default meta; -type Story = StoryObj; - -const TestShipSnapshot = () => { - const eveData = React.useContext(EveDataContext); - const shipSnapshot = React.useContext(ShipSnapshotContext); - - if (shipSnapshot?.loaded) { - return ( -
- ShipSnapshot: loaded -
- Hull: -
    - {Array.from(shipSnapshot.hull?.attributes.entries() || []).map( - ([id, attribute]: [number, ShipSnapshotItemAttribute]) => ( -
  • - {eveData?.dogmaAttributes?.[id].name} ({id}): {attribute.value} -
  • - ), - )} -
-
- ); - } - - return ( -
- ShipSnapshot: loading -
-
- ); -}; - -export const Default: Story = { - args: { - initialFit: fullFit, - }, - render: (args) => ( - - - - - - - - ), -}; diff --git a/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.tsx b/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.tsx deleted file mode 100644 index f0c0599..0000000 --- a/src/providers/ShipSnapshotProvider/ShipSnapshotProvider.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import React from "react"; - -import { DogmaEngineContext } from "@/providers/DogmaEngineProvider"; -import { EveDataContext } from "@/providers/EveDataProvider"; - -export interface ShipSnapshotItemAttributeEffect { - operator: string; - penalty: boolean; - source: "Ship" | "Char" | "Structure" | "Target" | { Item?: number; Charge?: number; Skill?: number }; - source_category: string; - source_attribute_id: number; -} - -export interface ShipSnapshotItemAttribute { - base_value: number; - value: number; - effects: ShipSnapshotItemAttributeEffect[]; -} - -export interface ShipSnapshotItem { - type_id: number; - quantity: number; - flag: number; - charge: ShipSnapshotItem | undefined; - state: "Passive" | "Online" | "Active" | "Overload"; - max_state: "Passive" | "Online" | "Active" | "Overload"; - attributes: Map; - effects: number[]; -} - -export interface EsiFit { - name: string; - description: string; - ship_type_id: number; - items: { - type_id: number; - quantity: number; - flag: number; - charge?: { - type_id: number; - }; - state?: string; - }[]; -} - -interface ShipSnapshotSlots { - hislot: number; - medslot: number; - lowslot: number; - subsystem: number; - rig: number; - launcher: number; - turret: number; -} - -export type ShipSnapshotSlotsType = "hislot" | "medslot" | "lowslot" | "subsystem" | "rig" | "droneBay"; - -interface ShipSnapshot { - loaded?: boolean; - - hull?: ShipSnapshotItem; - items?: ShipSnapshotItem[]; - skills?: ShipSnapshotItem[]; - char?: ShipSnapshotItem; - structure?: ShipSnapshotItem; - target?: ShipSnapshotItem; - - slots: ShipSnapshotSlots; - - currentFit?: EsiFit; - currentSkills?: Record; - - moveModule: (fromFlag: number, toFlag: number) => void; - setModule: (typeId: number, flag: number) => void; - addModule: (typeId: number, slot: ShipSnapshotSlotsType) => void; - removeModule: (flag: number) => void; - addCharge: (chargeTypeId: number, flag?: number) => void; - removeCharge: (flag: number) => void; - toggleDrones: (typeId: number, active: number) => void; - removeDrones: (typeId: number) => void; - changeHull: (typeId: number) => void; - changeFit: (fit: EsiFit) => void; - setItemState: (flag: number, state: string) => void; - setName: (name: string) => void; - changeSkills: (skills: Record) => void; -} - -export const ShipSnapshotContext = React.createContext({ - loaded: undefined, - slots: { - hislot: 0, - medslot: 0, - lowslot: 0, - subsystem: 0, - rig: 0, - launcher: 0, - turret: 0, - }, - moveModule: () => {}, - setModule: () => {}, - addModule: () => {}, - removeModule: () => {}, - addCharge: () => {}, - removeCharge: () => {}, - toggleDrones: () => {}, - removeDrones: () => {}, - changeHull: () => {}, - changeFit: () => {}, - setItemState: () => {}, - setName: () => {}, - changeSkills: () => {}, -}); - -const slotStart: Record = { - hislot: 27, - medslot: 19, - lowslot: 11, - subsystem: 125, - rig: 92, - droneBay: 87, -}; - -export interface ShipSnapshotProps { - /** Children that can use this provider. */ - children: React.ReactNode; - /** The initial fit to use. */ - initialFit?: EsiFit; - /** The initial skills to use. */ - initialSkills?: Record; -} - -/** - * Calculates the current attrbitues and applied effects of a ship fit. - */ -export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { - const eveData = React.useContext(EveDataContext); - const dogmaEngine = React.useContext(DogmaEngineContext); - - const [currentFit, setCurrentFit] = React.useState(props.initialFit); - const [currentSkills, setCurrentSkills] = React.useState>(props.initialSkills ?? {}); - const [shipSnapshot, setShipSnapshot] = React.useState({ - loaded: undefined, - slots: { - hislot: 0, - medslot: 0, - lowslot: 0, - subsystem: 0, - rig: 0, - launcher: 0, - turret: 0, - }, - moveModule: () => {}, - setModule: () => {}, - addModule: () => {}, - removeModule: () => {}, - addCharge: () => {}, - removeCharge: () => {}, - toggleDrones: () => {}, - removeDrones: () => {}, - changeHull: () => {}, - setItemState: () => {}, - setName: () => {}, - changeFit: setCurrentFit, - changeSkills: setCurrentSkills, - }); - - const setItemState = React.useCallback((flag: number, state: string) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - return { - ...oldFit, - items: oldFit?.items?.map((item) => { - if (item.flag === flag) { - return { - ...item, - state: state, - }; - } - - return item; - }), - }; - }); - }, []); - - const setName = React.useCallback((name: string) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - return { - ...oldFit, - name: name, - }; - }); - }, []); - - const moveModule = React.useCallback((fromFlag: number, toFlag: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - const newItems = [...oldFit.items]; - - const fromItemIndex = newItems.findIndex((item) => item.flag === fromFlag); - const fromItem = newItems[fromItemIndex]; - - const toItemIndex = newItems.findIndex((item) => item.flag === toFlag); - const toItem = newItems[toItemIndex]; - - fromItem.flag = toFlag; - - if (toItem !== undefined) { - /* Target slot is non-empty, swap items. */ - toItem.flag = fromFlag; - } - - return { - ...oldFit, - items: newItems, - }; - }); - }, []); - - const setModule = React.useCallback((typeId: number, flag: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - const newItems = oldFit.items - .filter((item) => item.flag !== flag) - .concat({ flag: flag, type_id: typeId, quantity: 1 }); - - return { - ...oldFit, - items: newItems, - }; - }); - }, []); - - const addModule = React.useCallback( - (typeId: number, slot: ShipSnapshotSlotsType) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - let flag = 0; - - /* Find the first free slot for that slot-type. */ - if (slot !== "droneBay") { - for (let i = slotStart[slot]; i < slotStart[slot] + shipSnapshot.slots[slot]; i++) { - if (oldFit.items.find((item) => item.flag === i) !== undefined) continue; - - flag = i; - break; - } - } else { - flag = 87; - } - - /* Couldn't find a free slot. */ - if (flag === 0) return oldFit; - - return { - ...oldFit, - items: [ - ...oldFit.items, - { - flag: flag, - type_id: typeId, - quantity: 1, - }, - ], - }; - }); - }, - [shipSnapshot.slots], - ); - - const removeModule = React.useCallback((flag: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - return { - ...oldFit, - items: oldFit.items.filter((item) => item.flag !== flag), - }; - }); - }, []); - - const addCharge = React.useCallback( - (chargeTypeId: number, flag?: number) => { - const chargeSize = - eveData.typeDogma?.[chargeTypeId]?.dogmaAttributes.find( - (attr) => attr.attributeID === eveData.attributeMapping?.chargeSize, - )?.value ?? -1; - const groupID = eveData.typeIDs?.[chargeTypeId]?.groupID ?? -1; - - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - const newItems = []; - - for (let item of oldFit.items) { - /* If the module has size restrictions, ensure the charge matches. */ - const moduleChargeSize = eveData.typeDogma?.[item.type_id]?.dogmaAttributes.find( - (attr) => attr.attributeID === eveData.attributeMapping?.chargeSize, - )?.value; - if (moduleChargeSize !== undefined && moduleChargeSize !== chargeSize) { - newItems.push(item); - continue; - } - if (flag !== undefined && item.flag !== flag) { - newItems.push(item); - continue; - } - - /* Check if the charge fits in this module; if so, assign it. */ - for (const attr of eveData.typeDogma?.[item.type_id]?.dogmaAttributes ?? []) { - switch (attr.attributeID) { - case eveData.attributeMapping?.chargeGroup1: - case eveData.attributeMapping?.chargeGroup2: - case eveData.attributeMapping?.chargeGroup3: - case eveData.attributeMapping?.chargeGroup4: - case eveData.attributeMapping?.chargeGroup5: - if (attr.value === groupID) { - item = { - ...item, - charge: { - type_id: chargeTypeId, - }, - }; - } - break; - } - } - - newItems.push(item); - } - - return { - ...oldFit, - items: newItems, - }; - }); - }, - [eveData], - ); - - const removeCharge = React.useCallback((flag: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - return { - ...oldFit, - items: oldFit.items.map((item) => { - if (item.flag === flag) { - return { - ...item, - charge: undefined, - }; - } - - return item; - }), - }; - }); - }, []); - - const toggleDrones = React.useCallback((typeId: number, active: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - /* Find the amount of drones in the current fit. */ - const count = oldFit.items - .filter((item) => item.flag === 87 && item.type_id === typeId) - .reduce((acc, item) => acc + item.quantity, 0); - if (count === 0) return oldFit; - - /* If we request the same amount of active than we had, assume we want to deactive the current. */ - const currentActive = oldFit.items - .filter((item) => item.flag === 87 && item.type_id === typeId && item.state === "Active") - .reduce((acc, item) => acc + item.quantity, 0); - if (currentActive === active) { - active = active - 1; - } - - /* Ensure we never have more active than available. */ - active = Math.min(count, active); - - /* Remove all drones of this type. */ - const newItems = oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId); - - /* Add the active drones. */ - if (active > 0) { - newItems.push({ - flag: 87, - type_id: typeId, - quantity: active, - state: "Active", - }); - } - - /* Add the passive drones. */ - if (active < count) { - newItems.push({ - flag: 87, - type_id: typeId, - quantity: count - active, - state: "Passive", - }); - } - - return { - ...oldFit, - items: newItems, - }; - }); - }, []); - - const removeDrones = React.useCallback((typeId: number) => { - setCurrentFit((oldFit: EsiFit | undefined) => { - if (oldFit === undefined) return undefined; - - return { - ...oldFit, - items: oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId), - }; - }); - }, []); - - const changeHull = React.useCallback( - (typeId: number) => { - const hullName = eveData?.typeIDs?.[typeId].name; - - setCurrentFit({ - name: `New ${hullName}`, - description: "", - ship_type_id: typeId, - items: [], - }); - }, - [eveData], - ); - - React.useEffect(() => { - setShipSnapshot((oldSnapshot) => ({ - ...oldSnapshot, - moveModule, - setModule, - addModule, - removeModule, - addCharge, - removeCharge, - toggleDrones, - removeDrones, - changeHull, - setItemState, - setName, - })); - }, [ - moveModule, - setModule, - addModule, - removeModule, - addCharge, - removeCharge, - toggleDrones, - removeDrones, - changeHull, - setItemState, - setName, - ]); - - React.useEffect(() => { - if (!dogmaEngine.loaded || !eveData.loaded) return; - if (currentFit === undefined || currentSkills === undefined) return; - - const snapshot = dogmaEngine.engine?.calculate(currentFit, currentSkills); - - const slots = { - hislot: 0, - medslot: 0, - lowslot: 0, - subsystem: 0, - rig: 0, - launcher: 0, - turret: 0, - }; - - slots.hislot = snapshot.hull.attributes.get(eveData?.attributeMapping?.hiSlots || 0)?.value || 0; - slots.medslot = snapshot.hull.attributes.get(eveData?.attributeMapping?.medSlots || 0)?.value || 0; - slots.lowslot = snapshot.hull.attributes.get(eveData?.attributeMapping?.lowSlots || 0)?.value || 0; - slots.subsystem = snapshot.hull.attributes.get(eveData?.attributeMapping?.maxSubSystems || 0)?.value || 0; - slots.rig = snapshot.hull?.attributes.get(eveData?.attributeMapping?.rigSlots || 0)?.value || 0; - slots.launcher = snapshot.hull?.attributes.get(eveData?.attributeMapping?.launcherSlotsLeft || 0)?.value || 0; - slots.turret = snapshot.hull?.attributes.get(eveData?.attributeMapping?.turretSlotsLeft || 0)?.value || 0; - - const items = snapshot.items; - for (const item of items) { - slots.hislot += item.attributes.get(eveData?.attributeMapping?.hiSlotModifier || 0)?.value || 0; - slots.medslot += item.attributes.get(eveData?.attributeMapping?.medSlotModifier || 0)?.value || 0; - slots.lowslot += item.attributes.get(eveData?.attributeMapping?.lowSlotModifier || 0)?.value || 0; - slots.launcher += item.attributes.get(eveData?.attributeMapping?.launcherHardPointModifier || 0)?.value || 0; - slots.turret += item.attributes.get(eveData?.attributeMapping?.turretHardPointModifier || 0)?.value || 0; - } - - setShipSnapshot((oldSnapshot) => { - return { - ...oldSnapshot, - loaded: true, - hull: snapshot.hull, - items: snapshot.items, - skills: snapshot.skills, - char: snapshot.char, - structure: snapshot.structure, - target: snapshot.target, - slots, - currentFit, - currentSkills, - }; - }); - }, [eveData, dogmaEngine, currentFit, currentSkills]); - - return {props.children}; -}; diff --git a/src/providers/ShipSnapshotProvider/index.ts b/src/providers/ShipSnapshotProvider/index.ts deleted file mode 100644 index 47cfa6e..0000000 --- a/src/providers/ShipSnapshotProvider/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { ShipSnapshotContext, ShipSnapshotProvider } from "./ShipSnapshotProvider"; -export type { - EsiFit, - ShipSnapshotItem, - ShipSnapshotItemAttribute, - ShipSnapshotItemAttributeEffect, - ShipSnapshotSlotsType, -} from "./ShipSnapshotProvider"; diff --git a/src/providers/StatisticsProvider/StatisticsProvider.stories.tsx b/src/providers/StatisticsProvider/StatisticsProvider.stories.tsx new file mode 100644 index 0000000..ae95a23 --- /dev/null +++ b/src/providers/StatisticsProvider/StatisticsProvider.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { fitArgType } from "../../../.storybook/fits"; + +import { + CurrentCharacterProvider, + CurrentFitProvider, + DefaultCharactersProvider, + DogmaEngineProvider, + EsfFit, + EsiCharactersProvider, + EveDataProvider, + useCurrentFit, +} from "@/providers"; + +import { StatisticsProvider, useStatistics } from "./"; + +type StoryProps = React.ComponentProps & { fit: EsfFit | null }; + +const meta: Meta = { + component: StatisticsProvider, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const TestStory = ({ fit }: { fit: EsfFit | null }) => { + const currentFit = useCurrentFit(); + const statistics = useStatistics(); + + if (fit != currentFit.fit) { + currentFit.setFit(fit); + } + + if (currentFit.fit === null) { + return
No fit selected
; + } + if (statistics === null) { + return
Loading...
; + } + + return ( +
+
Hull: {statistics.hull.type_id}
+
Items: {statistics.items.map((item) => item.type_id).join(", ")}
+
+ Slots:{" "} + {Object.entries(statistics.slots).map(([slot, value]) => { + return ( +
+ - {slot}: {value} +
+ ); + })} +
+
+ ); +}; + +export const Default: Story = { + argTypes: { + children: { control: false }, + fit: fitArgType, + }, + args: { + fit: null, + }, + decorators: [ + (Story) => ( + + + + + + + + + + + + + + ), + ], + render: ({ fit, ...args }) => ( + + + + ), +}; diff --git a/src/providers/StatisticsProvider/StatisticsProvider.tsx b/src/providers/StatisticsProvider/StatisticsProvider.tsx new file mode 100644 index 0000000..057d3b8 --- /dev/null +++ b/src/providers/StatisticsProvider/StatisticsProvider.tsx @@ -0,0 +1,130 @@ +import React from "react"; + +import { EveData, useEveData } from "@/providers/EveDataProvider"; +import { State, useCurrentFit } from "@/providers/CurrentFitProvider"; +import { useCurrentCharacter } from "@/providers/CurrentCharacterProvider"; +import { useDogmaEngine } from "@/providers/DogmaEngineProvider"; + +export interface StatisticsItemAttributeEffect { + operator: string; + penalty: boolean; + source: "Ship" | "Char" | "Structure" | "Target" | { Item?: number; Charge?: number; Skill?: number }; + source_category: string; + source_attribute_id: number; +} + +export interface StatisticsItemAttribute { + base_value: number; + value: number; + effects: StatisticsItemAttributeEffect[]; +} + +export interface StatisticsItem { + type_id: number; + quantity: number; + flag: number; + charge: StatisticsItem | undefined; + state: State; + max_state: State; + attributes: Map; + effects: number[]; +} + +const StatisticsSlotEntries = ["hislot", "medslot", "lowslot", "subsystem", "rig", "launcher", "turret"] as const; +export type StatisticsSlotType = (typeof StatisticsSlotEntries)[number]; + +type StatisticsSlots = { + [key in StatisticsSlotType]: number; +}; + +interface Statistics { + hull: StatisticsItem; + items: StatisticsItem[]; + skills: StatisticsItem[]; + char: StatisticsItem; + structure: StatisticsItem; + target: StatisticsItem; + slots: StatisticsSlots; +} + +const SlotAttributeMapping: Record = { + hislot: ["hiSlots", "hiSlotModifier"], + medslot: ["medSlots", "medSlotModifier"], + lowslot: ["lowSlots", "lowSlotModifier"], + subsystem: ["maxSubSystems", null], + rig: ["rigSlots", null], + launcher: ["launcherSlotsLeft", "launcherHardPointModifier"], + turret: ["turretSlotsLeft", "turretHardPointModifier"], +}; + +const StatisticsContext = React.createContext(null); + +export const useStatistics = () => { + return React.useContext(StatisticsContext); +}; + +interface StatisticsProps { + /** Children that can use this provider. */ + children: React.ReactNode; +} + +const CalculateSlots = (eveData: EveData, statistics: Statistics) => { + /* Set all slots to zero. */ + statistics.slots = StatisticsSlotEntries.reduce((acc, slot) => { + acc[slot] = 0; + return acc; + }, {} as StatisticsSlots); + + /* Find the statistics of the hull. */ + for (const slot of StatisticsSlotEntries) { + const attributeId = SlotAttributeMapping[slot][0]; + + const attribute = statistics.hull.attributes.get(eveData.attributeMapping[attributeId]); + const value = attribute?.value ?? 0; + + statistics.slots[slot] += value; + } + /* Check if any items modify this value. */ + for (const item of statistics.items) { + for (const slot of StatisticsSlotEntries) { + const attributeId = SlotAttributeMapping[slot][1]; + if (attributeId === null) continue; + + const attribute = item.attributes.get(eveData.attributeMapping[attributeId]); + const value = attribute?.value ?? 0; + + statistics.slots[slot] += value; + } + } +}; + +/** + * Calculates and keeps the statistics of the current fit. + * + * This provider depends on `EveDataProvider`, `CurrentFitProvider`, `CurrentCharacterProvider`, and `DogmaEngineProvider`. + * + * Use the `useStatistics` hook to access the statistics. + */ +export const StatisticsProvider = (props: StatisticsProps) => { + const eveData = useEveData(); + const currentFit = useCurrentFit(); + const currentCharacter = useCurrentCharacter(); + const dogmaEngine = useDogmaEngine(); + + const contextValue = React.useMemo(() => { + const fit = currentFit.fit; + const skills = currentCharacter.character?.skills; + + if (fit === null || skills === undefined || dogmaEngine === null || eveData === null) { + return null; + } + + const statistics: Statistics = dogmaEngine.calculate(fit, skills); + + CalculateSlots(eveData, statistics); + + return statistics; + }, [eveData, dogmaEngine, currentFit.fit, currentCharacter.character?.skills]); + + return {props.children}; +}; diff --git a/src/providers/StatisticsProvider/index.ts b/src/providers/StatisticsProvider/index.ts new file mode 100644 index 0000000..60970f1 --- /dev/null +++ b/src/providers/StatisticsProvider/index.ts @@ -0,0 +1,7 @@ +export { StatisticsProvider, useStatistics } from "./StatisticsProvider"; +export type { + StatisticsItem, + StatisticsItemAttribute, + StatisticsItemAttributeEffect, + StatisticsSlotType, +} from "./StatisticsProvider"; diff --git a/src/providers/index.ts b/src/providers/index.ts index d4e03b9..e69de29 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +0,0 @@ -export * from "./DogmaEngineProvider"; -export * from "./EsiProvider"; -export * from "./EveDataProvider"; -export * from "./LocalFitProvider"; -export * from "./ShipSnapshotProvider"; diff --git a/tsconfig.json b/tsconfig.json index c4de14c..edfd487 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "strict": true, "target": "es2016" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", ".storybook/*.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", ".storybook/*.ts", ".storybook/*.tsx"], "exclude": ["node_modules"] }