refactor: reworked all providers, so they are more well defined in their actions (#128)

This commit is contained in:
Patric Stout
2024-05-20 12:55:21 +02:00
committed by GitHub
parent 20630b59db
commit 955e884c16
113 changed files with 3121 additions and 2633 deletions

View File

@@ -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 }],
},

View File

@@ -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<string, EsfFit | null>, fit: EsfFit | null) => {
acc[fit?.name ?? "(empty)"] = fit;
return acc;
}, {}),
};

44
.storybook/helpers.tsx Normal file
View File

@@ -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) => (
<EveDataProvider>
<DogmaEngineProvider>
<CurrentFitProvider>
<LocalFitsProvider>
<DefaultCharactersProvider>
<EsiCharactersProvider>
<CurrentCharacterProvider>
<StatisticsProvider>
<FitManagerProvider>
<ModalDialogAnchor />
<Story />
</FitManagerProvider>
</StatisticsProvider>
</CurrentCharacterProvider>
</EsiCharactersProvider>
</DefaultCharactersProvider>
</LocalFitsProvider>
</CurrentFitProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
export const useFitSelection = (fit: EsfFit | null) => {
const currentFit = useCurrentFit();
const setFit = currentFit.setFit;
React.useEffect(() => {
setFit(fit);
}, [setFit, fit]);
};

View File

@@ -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",

View File

@@ -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<typeof CalculationDetail> = {
type StoryProps = React.ComponentProps<typeof CalculationDetail> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: CalculationDetail,
tags: ["autodocs"],
title: "Component/CalculationDetail",
};
export default meta;
type Story = StoryObj<typeof CalculationDetail>;
const useShipSnapshotProvider: Decorator<{
source: "Ship" | "Char" | "Structure" | "Target" | { Item?: number; Cargo?: number };
}> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<EsiProvider>
<Story {...context.args} />
</EsiProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 <CalculationDetail {...args} />;
},
};

View File

@@ -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();
const eveAttribute = eveData.dogmaAttributes?.[props.attributeId];
if (eveData === null) return <></>;
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()];
}

View File

@@ -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<typeof CharacterSelection> = {
component: CharacterSelection,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CharacterSelection>;
export const Default: Story = {
render: () => (
<EveDataProvider>
<DefaultCharactersProvider>
<EsiCharactersProvider>
<CurrentCharacterProvider>
<CharacterSelection />
</CurrentCharacterProvider>
</EsiCharactersProvider>
</DefaultCharactersProvider>
</EveDataProvider>
),
};

View File

@@ -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 (
<div className={styles.character}>
<select onChange={(e) => currentCharacter.setCharacterId(e.target.value)} value={currentCharacter.characterId}>
{Object.entries(characters)
.sort()
.map(([id, name]) => {
return (
<option key={id} value={id}>
{name.name} {name.expired ? "(access expired)" : ""}
</option>
);
})}
</select>
{isExpired && (
<button onClick={esiCharactersProvider.refresh} title="Refresh access">
R
</button>
)}
{!isExpired && (
<button onClick={esiCharactersProvider.login} title="Add another character">
+
</button>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export { CharacterSelection } from "./CharacterSelection";

View File

@@ -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<typeof DroneBay> = {
type StoryProps = React.ComponentProps<typeof DroneBay> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: DroneBay,
tags: ["autodocs"],
title: "Component/DroneBay",
};
export default meta;
type Story = StoryObj<typeof DroneBay>;
const useShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<EsiProvider>
<div style={{ width: context.args.width, height: context.args.width }}>
<Story />
</div>
</EsiProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<DroneBay {...args} />
</div>
);
},
};

View File

@@ -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 (
<div
@@ -36,21 +29,28 @@ const DroneBayEntrySelected = ({
);
};
const DroneBayEntry = ({ name, drones }: { name: string; drones: ShipSnapshotItem[] }) => {
const eveData = React.useContext(EveDataContext);
const snapshot = React.useContext(ShipSnapshotContext);
const DroneBayEntry = ({ name, drones }: { name: string; drones: StatisticsItem[] }) => {
const eveData = useEveData();
const statistics = useStatistics();
const fitManager = useFitManager();
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 onRemove = React.useCallback(() => {
fitManager.removeDrones(drones[0].type_id);
}, [fitManager, drones]);
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;
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 (
<div className={styles.droneBayEntry}>
<div className={styles.amount}>{drones.length} x</div>
@@ -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 eveData = useEveData();
const statistics = useStatistics();
const [drones, setDrones] = React.useState<Record<string, ShipSnapshotItem[]>>({});
if (eveData === null || statistics === null) return <></>;
React.useEffect(() => {
if (snapshot === undefined || !snapshot.loaded || snapshot.items === undefined) return;
if (eveData === undefined || !eveData.loaded || eveData.typeIDs === undefined) return;
/* Group drones by type_id */
const dronesGrouped: Record<string, StatisticsItem[]> = {};
for (const drone of statistics.items.filter((item) => item.flag == 87)) {
const name = eveData.typeIDs?.[drone.type_id].name ?? "";
/* Group drones by type_id */
const dronesGrouped: Record<string, ShipSnapshotItem[]> = {};
for (const drone of snapshot.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 (
<div className={styles.droneBay}>
@@ -120,7 +109,7 @@ export const DroneBay = () => {
Active drones: <ShipAttribute name="droneActive" fixed={0} /> /{" "}
<CharAttribute name="maxActiveDrones" fixed={0} />
</div>
{Object.entries(drones)
{Object.entries(dronesGrouped)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, droneList]) => {
return <DroneBayEntry key={name} name={name} drones={droneList} />;

View File

@@ -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<typeof EsiCharacterSelection> = {
component: EsiCharacterSelection,
tags: ["autodocs"],
title: "Component/EsiCharacterSelection",
};
export default meta;
type Story = StoryObj<typeof EsiCharacterSelection>;
const withEsiProvider: Decorator<Record<string, never>> = (Story) => {
return (
<EveDataProvider>
<EsiProvider>
<Story />
</EsiProvider>
</EveDataProvider>
);
};
export const Default: Story = {
args: {},
decorators: [withEsiProvider],
};

View File

@@ -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 (
<div className={styles.character}>
<select onChange={(e) => esi.changeCharacter(e.target.value)} value={esi.currentCharacter}>
{Object.entries(esi.characters)
.sort()
.map(([id, name]) => {
return (
<option key={id} value={id}>
{name.name} {name.expired ? "(access expired)" : ""}
</option>
);
})}
</select>
{isExpired && (
<button onClick={esi.refresh} title="Refresh access">
R
</button>
)}
{!isExpired && (
<button onClick={esi.login} title="Add another character">
+
</button>
)}
</div>
);
};

View File

@@ -1 +0,0 @@
export { EsiCharacterSelection } from "./EsiCharacterSelection";

View File

@@ -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<string | undefined>(undefined);
const textAreaRef = React.useRef<HTMLTextAreaElement>(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 = () => {
<div className={styles.button}>{copied ? "In Clipboard" : "Clipboard"}</div>
<div className={clsx(styles.popup, { [styles.collapsed]: !isPopupOpen })}>
<div>
<div className={styles.button} onClick={() => setIsPasteOpen(true)}>
<div
className={styles.button}
onClick={() => {
setError(undefined);
setIsPasteOpen(true);
}}
>
Import from Clipboard
</div>
<div className={clsx(styles.button, styles.buttonMax)} onClick={() => copyToClipboard()}>

View File

@@ -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<typeof FitButtonBar> = {
type StoryProps = React.ComponentProps<typeof FitButtonBar> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: FitButtonBar,
tags: ["autodocs"],
title: "Component/FitButtonBar",
};
export default meta;
type Story = StoryObj<typeof FitButtonBar>;
const withEveDataProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<LocalFitProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<div style={{ marginTop: "100px" }}>
<ModalDialogAnchor />
<Story />
</div>
</ShipSnapshotProvider>
</LocalFitProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width, marginTop: 100 }}>
<FitButtonBar {...args} />
</div>
);
},
};

View File

@@ -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<string>(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 = () => {
<ModalDialog visible={isRenameOpen} onClose={() => setIsRenameOpen(false)} title="Fit Name">
<div>
<span className={styles.renameEdit}>
<input type="text" autoFocus value={rename} onChange={(e) => setRename(e.target.value)} />
<input type="text" autoFocus value={name} onChange={(e) => setName(e.target.value)} />
</span>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => saveRename()}>
Save

View File

@@ -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?"
>
<div>
<div>You have a fitting with the name {shipSnapshot?.currentFit?.name}, do you want to update it?</div>
<div>
You have a local fitting with the name &quot;{currentFit.fit?.name}&quot;; do you want to update it?
</div>
<div className={styles.alreadyExistsButtons}>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => saveBrowser(true)}>
Yes

View File

@@ -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<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
if (link === null) return;
copy(link);
},
[copy, link],

View File

@@ -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<typeof HardwareListing> = {
type StoryProps = React.ComponentProps<typeof HardwareListing> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: HardwareListing,
tags: ["autodocs"],
title: "Component/HardwareListing",
};
export default meta;
type Story = StoryObj<typeof HardwareListing>;
const useShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<EsiProvider>
<div style={{ width: context.args.width, height: context.args.width }}>
<Story />
</div>
</EsiProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<HardwareListing {...args} />
</div>
);
},
};

View File

@@ -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 OnItemDragStart = (
typeId: number,
slotType: StatisticsSlotType | "droneBay" | "charge",
): ((e: React.DragEvent<HTMLDivElement>) => void) => {
return (e: React.DragEvent<HTMLDivElement>) => {
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 = (typeId: number): ((e: React.MouseEvent<HTMLDivElement, 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 shipSnapShot = React.useContext(ShipSnapshotContext);
const onItemDragStart = React.useCallback(
(
typeId: ListingItem["typeId"],
slotType: ListingItem["slotType"],
): ((e: React.DragEvent<HTMLDivElement>) => void) => {
return (e: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement, MouseEvent>) => void) => {
return () => {
const img = new Image();
img.src = `https://images.evetech.net/types/${typeId}/icon?size=64`;
};
},
[],
);
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 (
<TreeLeaf
key={item.typeId}
level={2}
content={item.name}
onDoubleClick={() => shipSnapShot.addCharge(item.typeId)}
onDragStart={onItemDragStart(item.typeId, "charge")}
onMouseEnter={preloadImage(item.typeId)}
/>
);
} else {
const slotType = item.slotType;
return (
<TreeLeaf
key={item.typeId}
level={2}
content={item.name}
onDoubleClick={() => shipSnapShot.addModule(item.typeId, slotType)}
onDragStart={onItemDragStart(item.typeId, slotType)}
onMouseEnter={preloadImage(item.typeId)}
/>
);
}
const slotType = item.slotType;
return (
<TreeLeaf
key={item.typeId}
level={2}
content={item.name}
onDoubleClick={() => 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 <TreeListing level={props.level} getChildren={getChildren} />;
@@ -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 eveData = useEveData();
const statistics = useStatistics();
const [moduleGroups, setModuleGroups] = React.useState<ListingGroup>({
name: "Modules",
meta: 0,
groups: {},
items: [],
});
const [chargeGroups, setChageGroups] = React.useState<ListingGroup>({
name: "Charges",
meta: 0,
groups: {},
items: [],
});
const [search, setSearch] = React.useState<string>("");
const [filter, setFilter] = React.useState<Filter>({
lowslot: false,
@@ -155,21 +125,19 @@ export const HardwareListing = () => {
moduleWithCharge: undefined,
});
const [selection, setSelection] = React.useState<"modules" | "charges">("modules");
const [modulesWithCharges, setModulesWithCharges] = React.useState<ModuleCharge[]>([]);
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<number>();
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);
return modules;
}, [eveData, statistics]);
/* 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]);
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 (
<div className={styles.listing}>
<div className={styles.topbar}>
@@ -451,10 +425,10 @@ export const HardwareListing = () => {
</div>
</div>
<div className={clsx(styles.listingContent, { [styles.collapsed]: selection !== "modules" })}>
<ModuleGroup key="modules" level={0} group={moduleGroups} hideGroup={true} />
<ModuleGroup key="modules" level={0} group={modules} hideGroup={true} />
</div>
<div className={clsx(styles.listingContent, { [styles.collapsed]: selection !== "charges" })}>
<ModuleGroup key="charges" level={0} group={chargeGroups} hideGroup={true} />
<ModuleGroup key="charges" level={0} group={charges} hideGroup={true} />
</div>
</div>
);

View File

@@ -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<typeof HullListing> = {
type StoryProps = React.ComponentProps<typeof HullListing> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: HullListing,
tags: ["autodocs"],
title: "Component/HullListing",
};
export default meta;
type Story = StoryObj<typeof HullListing>;
const withEsiProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<LocalFitProvider>
<EsiProvider>
<div style={{ height: "400px" }}>
<Story />
</div>
</EsiProvider>
</LocalFitProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<HullListing {...args} />
</div>
);
},
};

View File

@@ -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<number, string> = {
} 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<HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
shipSnapShot.changeHull(props.typeId);
fitManager.createNewFit(props.typeId);
},
[props, shipSnapShot],
[fitManager, props.typeId],
);
const headerAction = <TreeHeaderAction icon="simulate" onClick={onClick} />;
@@ -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 }) => {
<HullRace raceId={1} entries={props.entries.NonEmpire} />
</>
);
}, [props]);
}, [props.entries]);
const header = <TreeHeader text={`${props.name}`} />;
return <TreeListing level={1} header={header} getChildren={getChildren} />;
@@ -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<ListingGroups>({});
const [search, setSearch] = React.useState<string>("");
const [filter, setFilter] = React.useState({
localCharacter: false,
esiCharacter: false,
localFits: false,
characterFits: false,
currentHull: false,
});
const [localCharacterFits, setLocalCharacterFits] = React.useState<Record<string, ListingFit[]>>({});
const [esiCharacterFits, setEsiCharacterFits] = React.useState<Record<string, ListingFit[]>>({});
React.useEffect(() => {
if (!localFit.loaded) return;
if (!localFit.fittings) return;
const newLocalCharacterFits: Record<string, ListingFit[]> = {};
for (const fit of localFit.fittings) {
const localFitsGrouped = React.useMemo(() => {
const grouped: Record<string, ListingFit[]> = {};
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]);
return grouped;
}, [localFits]);
React.useEffect(() => {
if (!esi.loaded) return;
if (!esi.currentCharacter) return;
const characterFitsGrouped = React.useMemo(() => {
const characterFittings = currentCharacter.character?.fittings ?? [];
const charFittings = esi.characters[esi.currentCharacter].charFittings || [];
const newEsiCharacterFits: Record<string, ListingFit[]> = {};
for (const fit of charFittings) {
const grouped: Record<string, ListingFit[]> = {};
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 (
<div className={styles.listing}>
@@ -266,14 +256,14 @@ export const HullListing = () => {
</div>
<div className={styles.filter}>
<span
className={clsx({ [styles.selected]: filter.localCharacter })}
onClick={() => setFilter({ ...filter, localCharacter: !filter.localCharacter })}
className={clsx({ [styles.selected]: filter.localFits })}
onClick={() => setFilter({ ...filter, localFits: !filter.localFits })}
>
<Icon name="fitting-local" size={32} title="Filter: Browser-stored fittings" />
</span>
<span
className={clsx({ [styles.selected]: filter.esiCharacter })}
onClick={() => setFilter({ ...filter, esiCharacter: !filter.esiCharacter })}
className={clsx({ [styles.selected]: filter.characterFits })}
onClick={() => setFilter({ ...filter, characterFits: !filter.characterFits })}
>
<Icon name="fitting-character" size={32} title="Filter: in-game personal fittings" />
</span>
@@ -291,10 +281,10 @@ export const HullListing = () => {
</span>
</div>
<div className={styles.listingContent}>
{Object.keys(hullGroups)
{Object.keys(hullGrouped)
.sort()
.map((groupName) => {
const groupData = hullGroups[groupName];
const groupData = hullGrouped[groupName];
return <HullGroup key={groupName} name={groupName} entries={groupData} />;
})}
</div>

View File

@@ -5,7 +5,6 @@ import { Icon } from "./";
const meta: Meta<typeof Icon> = {
component: Icon,
tags: ["autodocs"],
title: "Component/Icon",
};
export default meta;

View File

@@ -7,7 +7,6 @@ import { ModalDialogAnchor } from "./ModalDialog";
const meta: Meta<typeof ModalDialog> = {
component: ModalDialog,
tags: ["autodocs"],
title: "Component/ModalDialog",
};
export default meta;

View File

@@ -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<typeof ShipAttribute> = {
type StoryProps = React.ComponentProps<typeof ShipAttribute> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: ShipAttribute,
tags: ["autodocs"],
title: "Component/ShipAttribute",
};
export default meta;
type Story = StoryObj<typeof ShipAttribute>;
const withShipSnapshotProvider: Decorator<{ name: string }> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
cpuUsage: <Story />
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 <ShipAttribute {...args} />;
},
};

View File

@@ -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 "?";
}
if (props.isResistance) {
value = 100 - value * 100;
}
if (props.divideBy) {
value /= props.divideBy;
}
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;
}
}
/* 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,
});
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 (value === undefined) {
return "?";
}
if (props.isResistance) {
value = 100 - value * 100;
}
if (props.divideBy) {
value /= props.divideBy;
}
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;
}
}
/* 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,
});
}
/**

View File

@@ -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<HTMLAnchorElement, 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 (
<div className={styles.fitLink}>
<svg viewBox="0 0 730 730" xmlns="http://www.w3.org/2000/svg">

View File

@@ -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 (
<div className={styles.hull}>
<img src={`https://images.evetech.net/types/${hull}/render?size=1024`} />
<img src={`https://images.evetech.net/types/${shipTypeId}/render?size=1024`} />
</div>
);
};

View File

@@ -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<HTMLDivElement>) => {
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 <div className={styles.hullDraggable} onDragOver={onDragOver} onDrop={onDragEnd} />;

View File

@@ -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<typeof ShipFit> = {
type StoryProps = React.ComponentProps<typeof ShipFit> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: ShipFit,
tags: ["autodocs"],
title: "Component/ShipFit",
};
export default meta;
type Story = StoryObj<typeof ShipFit>;
const withShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<div style={{ width: context.args.width, height: context.args.width }}>
<Story />
</div>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<ShipFit {...args} />
</div>
);
},
};

View File

@@ -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 }) => {
<Icon name="hardpoint-turret" size={16} />
</div>
</RingTopItem>
{Array.from({ length: slots?.turret }, (_, i) => {
{Array.from({ length: slots.turret }, (_, i) => {
turretSlotsUsed--;
return (
<RingTopItem key={i} rotation={-40 + i * 3} background>
@@ -74,7 +85,7 @@ export const ShipFit = (props: { withStats?: boolean }) => {
<Icon name="hardpoint-launcher" size={16} />
</div>
</RingTopItem>
{Array.from({ length: slots?.launcher }, (_, i) => {
{Array.from({ length: slots.launcher }, (_, i) => {
launcherSlotsUsed--;
return (
<RingTopItem key={i} rotation={39 - i * 3} background>
@@ -112,28 +123,28 @@ export const ShipFit = (props: { withStats?: boolean }) => {
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 0}>
<Slot type="hislot" index={1} fittable={slots?.hislot >= 1} main />
<Slot type="hislot" index={1} fittable={slots.hislot >= 1} main />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 1}>
<Slot type="hislot" index={2} fittable={slots?.hislot >= 2} />
<Slot type="hislot" index={2} fittable={slots.hislot >= 2} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 2}>
<Slot type="hislot" index={3} fittable={slots?.hislot >= 3} />
<Slot type="hislot" index={3} fittable={slots.hislot >= 3} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 3}>
<Slot type="hislot" index={4} fittable={slots?.hislot >= 4} />
<Slot type="hislot" index={4} fittable={slots.hislot >= 4} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 4}>
<Slot type="hislot" index={5} fittable={slots?.hislot >= 5} />
<Slot type="hislot" index={5} fittable={slots.hislot >= 5} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 5}>
<Slot type="hislot" index={6} fittable={slots?.hislot >= 6} />
<Slot type="hislot" index={6} fittable={slots.hislot >= 6} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 6}>
<Slot type="hislot" index={7} fittable={slots?.hislot >= 7} />
<Slot type="hislot" index={7} fittable={slots.hislot >= 7} />
</RingTopItem>
<RingTopItem rotation={-36.5 + (71 / 7) * 7}>
<Slot type="hislot" index={8} fittable={slots?.hislot >= 8} />
<Slot type="hislot" index={8} fittable={slots.hislot >= 8} />
</RingTopItem>
<RingTopItem rotation={43} background>
@@ -141,28 +152,28 @@ export const ShipFit = (props: { withStats?: boolean }) => {
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 0}>
<Slot type="medslot" index={1} fittable={slots?.medslot >= 1} />
<Slot type="medslot" index={1} fittable={slots.medslot >= 1} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 1}>
<Slot type="medslot" index={2} fittable={slots?.medslot >= 2} />
<Slot type="medslot" index={2} fittable={slots.medslot >= 2} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 2}>
<Slot type="medslot" index={3} fittable={slots?.medslot >= 3} />
<Slot type="medslot" index={3} fittable={slots.medslot >= 3} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 3}>
<Slot type="medslot" index={4} fittable={slots?.medslot >= 4} />
<Slot type="medslot" index={4} fittable={slots.medslot >= 4} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 4}>
<Slot type="medslot" index={5} fittable={slots?.medslot >= 5} />
<Slot type="medslot" index={5} fittable={slots.medslot >= 5} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 5}>
<Slot type="medslot" index={6} fittable={slots?.medslot >= 6} />
<Slot type="medslot" index={6} fittable={slots.medslot >= 6} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 6}>
<Slot type="medslot" index={7} fittable={slots?.medslot >= 7} />
<Slot type="medslot" index={7} fittable={slots.medslot >= 7} />
</RingTopItem>
<RingTopItem rotation={53 + (72 / 7) * 7}>
<Slot type="medslot" index={8} fittable={slots?.medslot >= 8} />
<Slot type="medslot" index={8} fittable={slots.medslot >= 8} />
</RingTopItem>
<RingTopItem rotation={133} background>
@@ -170,51 +181,51 @@ export const ShipFit = (props: { withStats?: boolean }) => {
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 0}>
<Slot type="lowslot" index={1} fittable={slots?.lowslot >= 1} />
<Slot type="lowslot" index={1} fittable={slots.lowslot >= 1} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 1}>
<Slot type="lowslot" index={2} fittable={slots?.lowslot >= 2} />
<Slot type="lowslot" index={2} fittable={slots.lowslot >= 2} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 2}>
<Slot type="lowslot" index={3} fittable={slots?.lowslot >= 3} />
<Slot type="lowslot" index={3} fittable={slots.lowslot >= 3} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 3}>
<Slot type="lowslot" index={4} fittable={slots?.lowslot >= 4} />
<Slot type="lowslot" index={4} fittable={slots.lowslot >= 4} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 4}>
<Slot type="lowslot" index={5} fittable={slots?.lowslot >= 5} />
<Slot type="lowslot" index={5} fittable={slots.lowslot >= 5} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 5}>
<Slot type="lowslot" index={6} fittable={slots?.lowslot >= 6} />
<Slot type="lowslot" index={6} fittable={slots.lowslot >= 6} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 6}>
<Slot type="lowslot" index={7} fittable={slots?.lowslot >= 7} />
<Slot type="lowslot" index={7} fittable={slots.lowslot >= 7} />
</RingTopItem>
<RingTopItem rotation={142 + (72 / 7) * 7}>
<Slot type="lowslot" index={8} fittable={slots?.lowslot >= 8} />
<Slot type="lowslot" index={8} fittable={slots.lowslot >= 8} />
</RingTopItem>
<RingTopItem rotation={-74 + (21 / 2) * 0}>
<Slot type="rig" index={1} fittable={slots?.rig >= 1} />
<Slot type="rig" index={1} fittable={slots.rig >= 1} />
</RingTopItem>
<RingTopItem rotation={-74 + (21 / 2) * 1}>
<Slot type="rig" index={2} fittable={slots?.rig >= 2} />
<Slot type="rig" index={2} fittable={slots.rig >= 2} />
</RingTopItem>
<RingTopItem rotation={-74 + (21 / 2) * 2}>
<Slot type="rig" index={3} fittable={slots?.rig >= 3} />
<Slot type="rig" index={3} fittable={slots.rig >= 3} />
</RingTopItem>
<RingTopItem rotation={-128 + (38 / 3) * 0}>
<Slot type="subsystem" index={1} fittable={slots?.subsystem >= 1} />
<Slot type="subsystem" index={1} fittable={slots.subsystem >= 1} />
</RingTopItem>
<RingTopItem rotation={-128 + (38 / 3) * 1}>
<Slot type="subsystem" index={2} fittable={slots?.subsystem >= 2} />
<Slot type="subsystem" index={2} fittable={slots.subsystem >= 2} />
</RingTopItem>
<RingTopItem rotation={-128 + (38 / 3) * 2}>
<Slot type="subsystem" index={3} fittable={slots?.subsystem >= 3} />
<Slot type="subsystem" index={3} fittable={slots.subsystem >= 3} />
</RingTopItem>
<RingTopItem rotation={-128 + (38 / 3) * 3}>
<Slot type="subsystem" index={4} fittable={slots?.subsystem >= 4} />
<Slot type="subsystem" index={4} fittable={slots.subsystem >= 4} />
</RingTopItem>
</RingTop>

View File

@@ -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<string, number[]> = {
subsystem: [125, 126, 127, 128],
};
const stateRotation: Record<string, string[]> = {
const stateRotation: Record<string, State[]> = {
Passive: ["Passive"],
Online: ["Passive", "Online"],
Active: ["Passive", "Online", "Active"],
@@ -22,15 +24,125 @@ const stateRotation: Record<string, string[]> = {
};
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";
const offlineState = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (esiItem === undefined) return;
if (esiItem.state === "Passive") {
fitManager.setModuleState(esiItem.flag, "Online");
} else {
fitManager.setModuleState(esiItem.flag, "Passive");
}
},
[fitManager, esiItem],
);
const cycleState = React.useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (esiItem === undefined) return;
const states = stateRotation[esiItem.max_state];
const stateIndex = states.indexOf(esiItem.state);
let newState;
if (e.shiftKey) {
newState = states[(stateIndex - 1 + states.length) % states.length];
} else {
newState = states[(stateIndex + 1) % states.length];
}
fitManager.setModuleState(esiItem.flag, newState);
},
[fitManager, esiItem],
);
const unfitModule = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (esiItem === undefined) return;
fitManager.removeModule(esiItem.flag);
},
[fitManager, esiItem],
);
const unfitCharge = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (esiItem === undefined) return;
fitManager.removeCharge(esiItem.flag);
},
[fitManager, esiItem],
);
const onDragStart = React.useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (esiItem === undefined) return;
e.dataTransfer.setData("application/type_id", esiItem.type_id.toString());
e.dataTransfer.setData("application/slot_id", esiFlag.toString());
e.dataTransfer.setData("application/slot_type", esiFlagType);
},
[esiItem, esiFlag, esiFlagType],
);
const onDragOver = React.useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const onDragEnd = React.useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const parseNumber = (maybeNumber: string): number | undefined => {
const num = parseInt(maybeNumber);
return Number.isInteger(num) ? num : undefined;
};
const draggedTypeId: number | undefined = parseNumber(e.dataTransfer.getData("application/type_id"));
const draggedSlotId: number | undefined = parseNumber(e.dataTransfer.getData("application/slot_id"));
const draggedSlotType: string = e.dataTransfer.getData("application/slot_type");
if (draggedTypeId === undefined) {
return;
}
if (draggedSlotType === "charge") {
fitManager.setCharge(esiFlag, draggedTypeId);
return;
}
const isValidSlotGroup = draggedSlotType === esiFlagType;
if (!isValidSlotGroup) {
return;
}
const isDraggedFromAnotherSlot = draggedSlotId !== undefined;
if (isDraggedFromAnotherSlot) {
fitManager.swapModule(esiFlag, draggedSlotId);
} else {
fitManager.setModule(esiFlag, draggedTypeId);
}
},
[fitManager, esiFlag, esiFlagType],
);
if (eveData === null || statistics === null) return <></>;
let item = <></>;
let svg = <></>;
let imageStyle = styles.slotImage;
@@ -96,111 +208,6 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma
</>
);
const offlineState = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (!shipSnapshot?.loaded || !esiItem) return;
if (esiItem.state === "Passive") {
shipSnapshot.setItemState(esiItem.flag, "Online");
} else {
shipSnapshot.setItemState(esiItem.flag, "Passive");
}
},
[shipSnapshot, esiItem],
);
const cycleState = React.useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (!shipSnapshot?.loaded || !esiItem) return;
const states = stateRotation[esiItem.max_state];
const stateIndex = states.indexOf(esiItem.state);
let newState;
if (e.shiftKey) {
newState = states[(stateIndex - 1 + states.length) % states.length];
} else {
newState = states[(stateIndex + 1) % states.length];
}
shipSnapshot.setItemState(esiItem.flag, newState);
},
[shipSnapshot, esiItem],
);
const unfitModule = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (!shipSnapshot?.loaded || !esiItem) return;
shipSnapshot.removeModule(esiItem.flag);
},
[shipSnapshot, esiItem],
);
const unfitCharge = React.useCallback(
(e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
e.stopPropagation();
if (!shipSnapshot?.loaded || !esiItem) return;
shipSnapshot.removeCharge(esiItem.flag);
},
[shipSnapshot, esiItem],
);
const onDragStart = React.useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (esiItem === undefined) return;
e.dataTransfer.setData("application/type_id", esiItem.type_id.toString());
e.dataTransfer.setData("application/slot_id", esiFlag.toString());
e.dataTransfer.setData("application/slot_type", esiFlagType);
},
[esiItem, esiFlag, esiFlagType],
);
const onDragOver = React.useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const onDragEnd = React.useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const parseNumber = (maybeNumber: string): number | undefined => {
const num = parseInt(maybeNumber);
return Number.isInteger(num) ? num : undefined;
};
const draggedTypeId: number | undefined = parseNumber(e.dataTransfer.getData("application/type_id"));
const draggedSlotId: number | undefined = parseNumber(e.dataTransfer.getData("application/slot_id"));
const draggedSlotType: string = e.dataTransfer.getData("application/slot_type");
if (draggedTypeId === undefined) {
return;
}
if (draggedSlotType === "charge") {
shipSnapshot.addCharge(draggedTypeId, esiFlag);
return;
}
const isValidSlotGroup = draggedSlotType === esiFlagType;
if (!isValidSlotGroup) {
return;
}
const isDraggedFromAnotherSlot = draggedSlotId !== undefined;
if (isDraggedFromAnotherSlot) {
shipSnapshot.moveModule(draggedSlotId, esiFlag);
} else {
shipSnapshot.setModule(draggedTypeId, esiFlag);
}
},
[shipSnapshot, esiFlag, esiFlagType],
);
/* Not fittable and nothing fitted; no need to render the slot. */
if (esiItem === undefined && !props.fittable) {
return (
@@ -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 = (
<img
src={`https://images.evetech.net/types/${esiItem.charge.type_id}/icon?size=64`}
title={`${eveData?.typeIDs?.[esiItem.type_id].name}\n${eveData?.typeIDs?.[esiItem.charge.type_id].name}`}
title={`${eveData.typeIDs[esiItem.type_id].name}\n${eveData.typeIDs[esiItem.charge.type_id].name}`}
draggable={true}
onDragStart={onDragStart}
/>
@@ -226,7 +233,7 @@ export const Slot = (props: { type: string; index: number; fittable: boolean; ma
item = (
<img
src={`https://images.evetech.net/types/${esiItem.type_id}/icon?size=64`}
title={eveData?.typeIDs?.[esiItem.type_id].name}
title={eveData.typeIDs[esiItem.type_id].name}
draggable={true}
onDragStart={onDragStart}
/>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<typeof ShipFitExtended> = {
type StoryProps = React.ComponentProps<typeof ShipFitExtended> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: ShipFitExtended,
tags: ["autodocs"],
title: "Component/ShipFitExtended",
};
export default meta;
type Story = StoryObj<typeof ShipFitExtended>;
const useShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<EsiProvider>
<div style={{ width: context.args.width, height: context.args.width }}>
<Story />
</div>
</EsiProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<ShipFitExtended {...args} />
</div>
);
},
};
export const WithHardwareListing: Story = {
argTypes: {
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [withDecoratorFull],
render: ({ fit, width, ...args }) => {
useFitSelection(fit);
return (
<div style={{ width: 1230, height: 730, display: "flex" }}>
<div style={{ width: 400 }}>
<HardwareListing />
</div>
<div style={{ width: 100 }}></div>
<div style={{ width: 730, height: 730 }}>
<ShipFitExtended {...args} />
</div>
</div>
);
},
};
export const WithHullListing: Story = {
argTypes: {
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [withDecoratorFull],
render: ({ fit, width, ...args }) => {
useFitSelection(fit);
return (
<div style={{ width: 1230, height: 730, display: "flex" }}>
<div style={{ width: 400 }}>
<HullListing />
</div>
<div style={{ width: 100 }}></div>
<div style={{ width: 730, height: 730 }}>
<ShipFitExtended {...args} />
</div>
</div>
);
},
};

View File

@@ -1,12 +1,12 @@
import clsx from "clsx";
import React from "react";
import { ShipSnapshotContext } from "@/providers/ShipSnapshotProvider";
import { EveDataContext } from "@/providers/EveDataProvider";
import { Icon } from "@/components/Icon";
import { ShipFit } from "@/components/ShipFit";
import { ShipAttribute } from "@/components/ShipAttribute";
import { DroneBay } from "@/components/DroneBay";
import { Icon } from "@/components/Icon";
import { ShipAttribute } from "@/components/ShipAttribute";
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 (
<>
<div className={styles.fitNameTitle}>Name</div>
<div className={styles.fitNameContent}>{shipSnapshot?.currentFit?.name}</div>
<div className={styles.fitNameContent}>{currentFit.fit?.name}</div>
</>
);
};
@@ -87,6 +89,8 @@ const FitName = () => {
* bottom of the fit.
*/
export const ShipFitExtended = () => {
const currentFit = useCurrentFit();
return (
<div className={styles.fit}>
<ShipFit withStats />
@@ -108,6 +112,8 @@ export const ShipFitExtended = () => {
<ShipAttribute name="powerUnused" fixed={1} />/<ShipAttribute name="powerOutput" fixed={1} />
</CpuPg>
</div>
{currentFit.fit === null && <div className={styles.empty}>To start, select a hull on the left.</div>}
</div>
);
};

View File

@@ -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";

View File

@@ -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<typeof ShipStatistics> = {
type StoryProps = React.ComponentProps<typeof ShipStatistics> & { fit: EsfFit | null; width: number };
const meta: Meta<StoryProps> = {
component: ShipStatistics,
tags: ["autodocs"],
title: "Component/ShipStatistics",
};
export default meta;
type Story = StoryObj<typeof ShipStatistics>;
const useShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<EsiProvider>
<Story />
</EsiProvider>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
type Story = StoryObj<StoryProps>;
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 (
<div style={{ width: width, height: width }}>
<ShipStatistics {...args} />
</div>
);
},
};

View File

@@ -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 (

View File

@@ -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<typeof TreeListing> = {
component: TreeListing,
tags: ["autodocs"],
title: "Component/TreeListing",
};
export default meta;
@@ -20,9 +17,4 @@ export const Default: Story = {
header: <TreeHeader text="Header" />,
level: 0,
},
parameters: {
snapshot: {
initialFit: fullFit,
},
},
};

View File

@@ -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";

View File

@@ -5,17 +5,26 @@ export function useClipboard({ timeout = 2000 } = {}) {
const [copied, setCopied] = React.useState(false);
const [copyTimeout, setCopyTimeout] = React.useState<number | undefined>(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 };
}

View File

@@ -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<typeof EveShipFitHash> = {
component: EveShipFitHash,
tags: ["autodocs"],
title: "Function/EveShipFitHash",
};
const withEveDataProvider: Decorator<{ fitHash: string }> = (Story) => {
return (
<EveDataProvider>
<Story />
</EveDataProvider>
);
};
export default meta;
type Story = StoryObj<typeof EveShipFitHash>;
export const Default: Story = {
args: {
fitHash: hashFit,
},
decorators: [withEveDataProvider],
};

View File

@@ -1 +0,0 @@
export { useEveShipFitHash } from "./EveShipFitHash";

View File

@@ -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<typeof EveShipFitLink> = {
component: EveShipFitLink,
tags: ["autodocs"],
title: "Function/EveShipFitLink",
};
const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<Story />
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
export default meta;
type Story = StoryObj<typeof EveShipFitLink>;
export const Default: Story = {
args: {},
decorators: [withShipSnapshotProvider],
parameters: {
snapshot: {
initialFit: fullFit,
},
},
};

View File

@@ -1,94 +0,0 @@
import React from "react";
import { EsiFit, ShipSnapshotContext } from "@/providers/ShipSnapshotProvider";
async function compress(str: string): Promise<string> {
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<string> {
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 (
<pre>
Hash: {eveShipFitLinkHash}
<br />
Link: {eveShipFitLink}
</pre>
);
};

View File

@@ -1 +0,0 @@
export { useEveShipFitLink, useEveShipFitLinkHash } from "./EveShipFitLink";

View File

@@ -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<typeof ExportEft> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: ExportEft,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<StoryProps>;
export const Default: Story = {
argTypes: {
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [withDecoratorFull],
render: ({ fit, ...args }) => {
useFitSelection(fit);
return <ExportEft {...args} />;
},
};

View File

@@ -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 <pre>{exportEft()}</pre>;
};

View File

@@ -0,0 +1 @@
export { useExportEft } from "./ExportEft";

View File

@@ -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<typeof ExportEveShipFitHash> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: ExportEveShipFitHash,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<StoryProps>;
export const Default: Story = {
argTypes: {
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [withDecoratorFull],
render: ({ fit, ...args }) => {
useFitSelection(fit);
return <ExportEveShipFitHash {...args} />;
},
};

View File

@@ -0,0 +1,74 @@
import React from "react";
import { EsfFit, useCurrentFit } from "@/providers/CurrentFitProvider";
async function compress(str: string): Promise<string> {
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<string> {
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<string | null>(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 <pre>{exportEveShipFitHash}</pre>;
};

View File

@@ -0,0 +1 @@
export { useExportEveShipFitHash } from "./ExportEveShipFitHash";

View File

@@ -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<typeof FormatAsEft> = {
component: FormatAsEft,
tags: ["autodocs"],
title: "Function/FormatAsEft",
};
const withEveDataProvider: Decorator<Record<string, never>> = (Story, context) => {
return (
<EveDataProvider>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot}>
<Story />
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
};
export default meta;
type Story = StoryObj<typeof FormatAsEft>;
export const Default: Story = {
decorators: [withEveDataProvider],
parameters: {
snapshot: {
initialFit: fullFit,
},
},
};

View File

@@ -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<ShipSnapshotSlotsType, 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<ShipSnapshotSlotsType, 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 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 <pre>{toEft()}</pre>;
};

View File

@@ -1 +0,0 @@
export { useFormatAsEft } from "./FormatAsEft";

View File

@@ -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<typeof FormatEftToEsi> = {
component: FormatEftToEsi,
tags: ["autodocs"],
title: "Function/FormatEftToEsi",
};
const withEveDataProvider: Decorator<{ eft: string }> = (Story) => {
return (
<EveDataProvider>
<Story />
</EveDataProvider>
);
};
export default meta;
type Story = StoryObj<typeof FormatEftToEsi>;
export const Default: Story = {
args: {
eft: eftFit,
},
decorators: [withEveDataProvider],
};

View File

@@ -1 +0,0 @@
export { useFormatEftToEsi } from "./FormatEftToEsi";

View File

@@ -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<typeof ImportEft> = {
component: ImportEft,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ImportEft>;
export const Default: Story = {
argTypes: {
eft: {
control: "select",
options: Object.keys(eftFits),
mapping: eftFits,
},
},
decorators: [
(Story) => (
<EveDataProvider>
<Story />
</EveDataProvider>
),
],
render: (args) => <ImportEft {...args} />,
};

View File

@@ -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<string, number[]> = {
@@ -25,15 +25,17 @@ const attributeIdMapping: Record<number, string> = {
};
/**
* 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<string, number> = {
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();
export const ImportEft = (props: FormatEftToEsiProps) => {
const importEft = useImportEft();
return <pre>{JSON.stringify(esiFit(props.eft), null, 2)}</pre>;
try {
const fit = importEft(props.eft);
return <pre>{JSON.stringify(fit, null, 2)}</pre>;
} catch (e: unknown) {
return <pre>Failed to import EFT fit: {(e as Error).message}</pre>;
}
};

View File

@@ -0,0 +1 @@
export { useImportEft } from "./ImportEft";

View File

@@ -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<typeof ImportEveShipFitHash> = {
component: ImportEveShipFitHash,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof ImportEveShipFitHash>;
export const Default: Story = {
argTypes: {
fitHash: {
control: "select",
options: Object.keys(hashFits),
mapping: hashFits,
},
},
decorators: [
(Story) => (
<EveDataProvider>
<Story />
</EveDataProvider>
),
],
render: (args) => <ImportEveShipFitHash {...args} />,
};

View File

@@ -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<string> {
const stream = new Blob([Uint8Array.from(atob(base64compressedBytes), (c) => c.charCodeAt(0))]).stream();
@@ -20,7 +20,7 @@ async function decompress(base64compressedBytes: string): Promise<string> {
return result;
}
async function decodeEsiFitV1(fitCompressed: string): Promise<EsiFit | undefined> {
async function decodeEsfFitV1(fitCompressed: string): Promise<EsfFit | null> {
const fitEncoded = await decompress(fitCompressed);
const fitLines = fitEncoded.trim().split("\n");
@@ -43,7 +43,7 @@ async function decodeEsiFitV1(fitCompressed: string): Promise<EsiFit | undefined
};
}
async function decodeEsiFitV2(fitCompressed: string): Promise<EsiFit | undefined> {
async function decodeEsfFitV2(fitCompressed: string): Promise<EsfFit | null> {
const fitEncoded = await decompress(fitCompressed);
const fitLines = fitEncoded.trim().split("\n");
@@ -77,20 +77,22 @@ async function decodeEsiFitV2(fitCompressed: string): Promise<EsiFit | undefined
}
function useFetchKillMail() {
const eveData = React.useContext(EveDataContext);
const eveData = useEveData();
return async (killMailHash: string): Promise<EsfFit | null> => {
if (eveData === null) return null;
return async (killMailHash: string): Promise<EsiFit | undefined> => {
/* 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<EsiFit | undefined> => {
return async (eftCompressed: string): Promise<EsfFit | null> => {
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<EsiFit | undefined> => {
if (!fitHash) return undefined;
return async (fitHash: string): Promise<EsfFit | undefined | null> => {
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 decodeEsfFitV1(fitEncoded);
break;
case "v2":
esiFit = await decodeEsiFitV2(fitEncoded);
fit = await decodeEsfFitV2(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<EsiFit | undefined>(undefined);
export const ImportEveShipFitHash = (props: ImportEveShipFitHashProps) => {
const importEveShipFitHash = useImportEveShipFitHash();
const [fit, setFit] = React.useState<EsfFit | null | undefined>(undefined);
React.useEffect(() => {
async function getFit(fitHash: string) {
setEsiFit(await eveShipFitHash(fitHash));
setFit(await importEveShipFitHash(fitHash));
}
getFit(props.fitHash);
}, [props.fitHash, eveShipFitHash]);
}, [props.fitHash, importEveShipFitHash]);
return <pre>{JSON.stringify(esiFit, null, 2)}</pre>;
return (
<div>
Hash: <pre>{props.fitHash}</pre>
Fit: <pre>{JSON.stringify(fit, null, 2)}</pre>
</div>
);
};

View File

@@ -0,0 +1 @@
export { useImportEveShipFitHash } from "./ImportEveShipFitHash";

View File

@@ -8,12 +8,15 @@ export const useLocalStorage = function <T>(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 <T>(key: string, initialValue: T) {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
},
[key, storedValue],
[key],
);
return [storedValue, setValue] as const;

View File

@@ -1,6 +0,0 @@
export * from "./Clipboard";
export * from "./EveShipFitHash";
export * from "./EveShipFitLink";
export * from "./FormatEftToEsi";
export * from "./FormatAsEft";
export * from "./LocalStorage";

View File

@@ -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";

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Character } from "@/providers/CurrentCharacterProvider";
interface Characters {
characters: Record<string, Character>;
/** 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>({
characters: {},
onCharacterIdChange: () => {},
characterIdChangeRequest: null,
});
export const useCharacters = () => {
return React.useContext(CharactersContext).characters;
};
export const useCharactersInternal = () => {
return React.useContext(CharactersContext);
};

View File

@@ -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<typeof DefaultCharactersProvider> = {
component: DefaultCharactersProvider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DefaultCharactersProvider>;
const TestStory = () => {
const characters = useCharacters();
return (
<div>
{Object.values(characters).map((character) => {
return (
<div key={character.name}>
{character.name} - {Object.keys(character.skills).length} skills
</div>
);
})}
</div>
);
};
export const Default: Story = {
argTypes: {
children: { control: false },
},
decorators: [
(Story) => {
return (
<EveDataProvider>
<Story />
</EveDataProvider>
);
},
],
render: (args) => (
<DefaultCharactersProvider {...args}>
<TestStory />
</DefaultCharactersProvider>
),
};

View File

@@ -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 <CharactersContext.Provider value={contextValue}>{props.children}</CharactersContext.Provider>;
};

View File

@@ -0,0 +1 @@
export { DefaultCharactersProvider } from "./DefaultCharactersProvider";

View File

@@ -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<typeof EsiCharactersProvider> = {
component: EsiCharactersProvider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof EsiCharactersProvider>;
const TestStory = () => {
const characters = useCharacters();
const charactersInternal = useCharactersInternal();
return (
<div>
{Object.values(characters).map((character) => {
return (
<div key={character.name}>
{character.name} - {Object.keys(character.skills).length} skills, {Object.keys(character.fittings).length}{" "}
fits {character.expired && " (expired)"}
</div>
);
})}
<div>
<br />
Press button to load character:
<br />
{Object.keys(characters).map((characterId) => (
<div key={characterId}>
<button onClick={() => charactersInternal.onCharacterIdChange(characterId)}>
{characters[characterId].name}
</button>
</div>
))}
</div>
</div>
);
};
export const Default: Story = {
argTypes: {
children: { control: false },
},
decorators: [
(Story) => {
return (
<EveDataProvider>
<Story />
</EveDataProvider>
);
},
],
render: (args) => (
<EsiCharactersProvider {...args}>
<TestStory />
</EsiCharactersProvider>
),
};

View File

@@ -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<EsiCharacters>({
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<Record<string, Character>>({});
const [accessTokens, setAccessTokens] = React.useState<Record<string, string>>({});
const [characterIdChangeRequest, setCharacterIdChangeRequest] = React.useState<string | null>(null);
const [storedCharacters, setStoredCharacters] = useLocalStorage<Record<string, EsiLocalCharacters>>("characters", {});
const [refreshTokens, setRefreshTokens] = useLocalStorage<Record<string, string>>("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<string | undefined> => {
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<string, Character>) => {
return {
...oldEsiCharacters,
[characterId]: {
...oldEsiCharacters[characterId],
expired: true,
},
};
});
return undefined;
}
setAccessTokens((oldAccessTokens: Record<string, string>) => {
return {
...oldAccessTokens,
[characterId]: accessToken,
};
});
setRefreshTokens((oldRefreshTokens: Record<string, string>) => {
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<string, Character>) => {
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<string, string>) => {
return {
...oldAccessTokens,
[character.characterId]: character.accessToken,
};
});
setRefreshTokens((oldRefreshTokens: Record<string, string>) => {
return {
...oldRefreshTokens,
[character.characterId]: character.refreshToken,
};
});
setStoredCharacters((oldStoredCharacters: Record<string, EsiLocalCharacters>) => {
return {
...oldStoredCharacters,
[character.characterId]: {
name: character.name,
},
};
});
setEsiCharacters((oldEsiCharacters: Record<string, Character>) => {
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<string, Character> = {};
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 <CharactersContext.Provider value={contextValue}>{props.children}</CharactersContext.Provider>;
};

View File

@@ -1,6 +1,6 @@
import { EsiFit } from "@/providers/ShipSnapshotProvider";
import { EsfFit } from "@/providers/CurrentFitProvider";
export async function getCharFittings(characterId: string, accessToken: string): Promise<EsiFit[] | undefined> {
export async function getCharFittings(characterId: string, accessToken: string): Promise<EsfFit[] | undefined> {
let response;
try {
response = await fetch(`https://esi.evetech.net/v1/characters/${characterId}/fittings/`, {

View File

@@ -1,4 +1,6 @@
export async function getSkills(characterId: string, accessToken: string): Promise<Record<string, number> | undefined> {
import { Skills } from "@/providers/CurrentCharacterProvider";
export async function getSkills(characterId: string, accessToken: string): Promise<Skills | undefined> {
let response;
try {
response = await fetch(`https://esi.evetech.net/v4/characters/${characterId}/skills/`, {

View File

@@ -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<JwtPayload>(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,
};
}

View File

@@ -0,0 +1 @@
export { useEsiCharacters, EsiCharactersProvider } from "./EsiCharactersProvider";

View File

@@ -0,0 +1,3 @@
export { useCharacters } from "./CharactersContext";
export * from "./DefaultCharactersProvider";
export * from "./EsiCharactersProvider";

View File

@@ -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<typeof CurrentCharacterProvider> = {
component: CurrentCharacterProvider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CurrentCharacterProvider>;
const TestStory = () => {
const currentCharacter = useCurrentCharacter();
const characters = useCharacters();
return (
<div>
<pre>{JSON.stringify(currentCharacter.character, null, 2)}</pre>
Press button to load character:
<br />
{Object.keys(characters).map((characterId) => (
<div key={characterId}>
<button onClick={() => currentCharacter.setCharacterId(characterId)}>{characters[characterId].name}</button>
</div>
))}
</div>
);
};
export const Default: Story = {
argTypes: {
children: { control: false },
},
decorators: [
(Story) => {
return (
<EveDataProvider>
<DefaultCharactersProvider>
<EsiCharactersProvider>
<Story />
</EsiCharactersProvider>
</DefaultCharactersProvider>
</EveDataProvider>
);
},
],
render: (args) => (
<CurrentCharacterProvider {...args}>
<TestStory />
</CurrentCharacterProvider>
),
};

View File

@@ -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<string, number>;
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<CurrentCharacter>({
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<string>(
"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 <CurrentCharacterContext.Provider value={contextValue}>{props.children}</CurrentCharacterContext.Provider>;
};

View File

@@ -0,0 +1,2 @@
export { useCurrentCharacter, CurrentCharacterProvider } from "./CurrentCharacterProvider";
export type { Character, Skills } from "./CurrentCharacterProvider";

View File

@@ -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<typeof CurrentFitProvider> = {
component: CurrentFitProvider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CurrentFitProvider>;
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 <pre>{JSON.stringify(currentFit.fit, null, 2)}</pre>;
};
export const Default: Story = {
argTypes: {
children: { control: false },
initialFit: fitArgType,
},
args: {
initialFit: null,
},
render: (args) => {
const [{ initialFit }] = useArgs();
return (
<CurrentFitProvider {...args}>
<TestStory fit={initialFit} />
</CurrentFitProvider>
);
},
};

View File

@@ -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<React.SetStateAction<EsfFit | null>>;
}
const CurrentFitContext = React.createContext<CurrentFit>({
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<EsfFit | null>(props.initialFit ?? null);
const contextValue = React.useMemo(() => {
return {
fit: currentFit,
setFit: setCurrentFit,
};
}, [currentFit, setCurrentFit]);
return <CurrentFitContext.Provider value={contextValue}>{props.children}</CurrentFitContext.Provider>;
};

View File

@@ -0,0 +1,2 @@
export { useCurrentFit, CurrentFitProvider } from "./CurrentFitProvider";
export type { EsfFit, State } from "./CurrentFitProvider";

View File

@@ -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<typeof DogmaEngineProvider> = {
import { DogmaEngineProvider, useDogmaEngine } from "./";
type StoryProps = React.ComponentProps<typeof DogmaEngineProvider> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: DogmaEngineProvider,
tags: ["autodocs"],
title: "Provider/DogmaEngineProvider",
};
export default meta;
type Story = StoryObj<typeof DogmaEngineProvider>;
type Story = StoryObj<StoryProps>;
/** 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 (
<div>
DogmaEngine: loaded
<br />
Stats: {JSON.stringify(stats, MapToDict)}
</div>
);
const TestStory = ({ fit }: { fit: EsfFit | null }) => {
const dogmaEngine = useDogmaEngine();
if (dogmaEngine === null) {
return <div>Loading...</div>;
}
if (fit === null) {
return <div>No fit selected</div>;
}
return (
<div>
DogmaEngine: loading
<br />
</div>
);
return <div>Stats: {JSON.stringify(dogmaEngine.calculate(fit, {}), MapToDict)}</div>;
};
export const Default: Story = {
render: () => (
<EveDataProvider>
<DogmaEngineProvider>
<TestDogmaEngine />
</DogmaEngineProvider>
</EveDataProvider>
argTypes: {
children: { control: false },
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [
(Story) => {
return (
<EveDataProvider>
<Story />
</EveDataProvider>
);
},
],
render: ({ fit, ...args }) => (
<DogmaEngineProvider {...args}>
<TestStory fit={fit} />
</DogmaEngineProvider>
),
};

View File

@@ -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<DogmaEngine | null>(null);
export const DogmaEngineContext = React.createContext<DogmaEngine>({});
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<string, number>)
* 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<DogmaEngine>({});
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<DogmaEngine | null>(null);
window.get_dogma_attributes = (type_id: number): TypeDogmaAttribute[] | undefined => {
return eveData.typeDogma?.[type_id].dogmaAttributes;
};
window.get_dogma_attribute = (attribute_id: number): DogmaAttribute | undefined => {
return eveData.dogmaAttributes?.[attribute_id];
};
window.get_dogma_effects = (type_id: number): TypeDogmaEffect[] | undefined => {
return eveData.typeDogma?.[type_id].dogmaEffects;
};
window.get_dogma_effect = (effect_id: number): DogmaEffect | undefined => {
return eveData.dogmaEffects?.[effect_id];
};
window.get_type_id = (type_id: number): TypeID | undefined => {
return eveData.typeIDs?.[type_id];
};
}, [eveData]);
if (firstLoad) {
setFirstLoad(false);
React.useEffect(() => {
import("@eveshipfit/dogma-engine").then((newDogmaEngine) => {
newDogmaEngine.init();
setDogmaEngine((prevDogmaEngine: DogmaEngine) => {
return {
...prevDogmaEngine,
engine: newDogmaEngine,
loaded: prevDogmaEngine.loadedData,
};
});
setDogmaEngine(newDogmaEngine);
});
}, []);
}
return <DogmaEngineContext.Provider value={dogmaEngine}>{props.children}</DogmaEngineContext.Provider>;
if (eveData !== null) {
window.get_dogma_attributes = (type_id: number): TypeDogmaAttribute[] | undefined => {
return eveData.typeDogma[type_id].dogmaAttributes;
};
window.get_dogma_attribute = (attribute_id: number): DogmaAttribute | undefined => {
return eveData.dogmaAttributes[attribute_id];
};
window.get_dogma_effects = (type_id: number): TypeDogmaEffect[] | undefined => {
return eveData.typeDogma[type_id].dogmaEffects;
};
window.get_dogma_effect = (effect_id: number): DogmaEffect | undefined => {
return eveData.dogmaEffects[effect_id];
};
window.get_type_id = (type_id: number): TypeID | undefined => {
return eveData.typeIDs[type_id];
};
}
const contextValue = React.useMemo(() => {
return eveData === null ? null : dogmaEngine;
}, [eveData, dogmaEngine]);
return <DogmaEngineContext.Provider value={contextValue}>{props.children}</DogmaEngineContext.Provider>;
};

View File

@@ -1 +1 @@
export { DogmaEngineContext, DogmaEngineProvider } from "./DogmaEngineProvider";
export { DogmaEngineProvider, useDogmaEngine } from "./DogmaEngineProvider";

View File

@@ -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<typeof EsiProvider> = {
component: EsiProvider,
tags: ["autodocs"],
title: "Provider/EsiProvider",
};
export default meta;
type Story = StoryObj<typeof EsiProvider>;
const TestEsi = () => {
const esi = React.useContext(EsiContext);
if (!esi.loaded) {
return (
<div>
Esi: loading
<br />
</div>
);
}
return (
<div>
Esi: loaded
<br />
<pre>{JSON.stringify(esi, null, 2)}</pre>
</div>
);
};
export const Default: Story = {
render: () => (
<EveDataProvider>
<ShipSnapshotProvider initialFit={fullFit}>
<EsiProvider>
<TestEsi />
</EsiProvider>
</ShipSnapshotProvider>
</EveDataProvider>
),
};

View File

@@ -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<string, number>;
charFittings?: EsiFit[];
}
export interface Esi {
loaded?: boolean;
characters: Record<string, EsiCharacter>;
currentCharacter?: string;
changeCharacter: (character: string) => void;
login: () => void;
refresh: () => void;
}
interface EsiPrivate {
loaded?: boolean;
refreshTokens: Record<string, string>;
accessTokens: Record<string, string>;
}
interface JwtPayload {
name: string;
sub: string;
}
export const EsiContext = React.createContext<Esi>({
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<Esi>({
loaded: undefined,
characters: {},
changeCharacter: () => {},
login: () => {},
refresh: () => {},
});
const [esiPrivate, setEsiPrivate] = React.useState<EsiPrivate>({
loaded: undefined,
refreshTokens: {},
accessTokens: {},
});
const [characters, setCharacters] = useLocalStorage<Record<string, EsiCharacter>>("characters", {});
const [refreshTokens, setRefreshTokens] = useLocalStorage("refreshTokens", {});
const [currentCharacter, setCurrentCharacter] = useLocalStorage<string | undefined>("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<string | undefined> => {
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<string, number> = {};
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<JwtPayload>(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<string, EsiCharacter>) => {
return {
...oldCharacters,
[characterId]: {
name: name,
expired: false,
},
};
});
setRefreshTokens((oldRefreshTokens: Record<string, string>) => {
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 <EsiContext.Provider value={esi}>{props.children}</EsiContext.Provider>;
};

View File

@@ -1,2 +0,0 @@
export { EsiContext, EsiProvider } from "./EsiProvider";
export type { EsiCharacter, Esi } from "./EsiProvider";

View File

@@ -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<typeof EveDataProvider> = {
component: EveDataProvider,
tags: ["autodocs"],
title: "Provider/EveDataProvider",
};
export default meta;
type Story = StoryObj<typeof EveDataProvider>;
const TestEveData = () => {
const eveData = React.useContext(EveDataContext);
const eveData = useEveData();
if (eveData === null) {
return <div>Loading...</div>;
}
return (
<div>
TypeIDs: {eveData.typeIDs ? Object.keys(eveData.typeIDs).length : "loading"}
TypeIDs: {Object.keys(eveData.typeIDs).length}
<br />
GroupIDs: {eveData.groupIDs ? Object.keys(eveData.groupIDs).length : "loading"}
GroupIDs: {Object.keys(eveData.groupIDs).length}
<br />
MarketGroups: {eveData.marketGroups ? Object.keys(eveData.marketGroups).length : "loading"}
MarketGroups: {Object.keys(eveData.marketGroups).length}
<br />
TypeDogma: {eveData.typeDogma ? Object.keys(eveData.typeDogma).length : "loading"}
TypeDogma: {Object.keys(eveData.typeDogma).length}
<br />
DogmaEffects: {eveData.dogmaEffects ? Object.keys(eveData.dogmaEffects).length : "loading"}
DogmaEffects: {Object.keys(eveData.dogmaEffects).length}
<br />
DogmaAttributes: {eveData.dogmaAttributes ? Object.keys(eveData.dogmaAttributes).length : "loading"}
DogmaAttributes: {Object.keys(eveData.dogmaAttributes).length}
<br />
AttributeMapper: {eveData.attributeMapping ? Object.keys(eveData.attributeMapping).length : "loading"}
<br />
<br />
All loaded: {eveData.loaded ? "yes" : "no"}
AttributeMapper: {Object.keys(eveData.attributeMapping).length}
</div>
);
};
export const Default: Story = {
render: () => (
<EveDataProvider>
argTypes: {
children: { control: false },
dataUrl: { control: false },
},
render: (args) => (
<EveDataProvider {...args}>
<TestEveData />
</EveDataProvider>
),

View File

@@ -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<string, TypeID>;
groupIDs?: Record<string, GroupID>;
marketGroups?: Record<string, MarketGroup>;
typeDogma?: Record<string, TypeDogma>;
dogmaEffects?: Record<string, DogmaEffect>;
dogmaAttributes?: Record<string, DogmaAttribute>;
effectMapping?: Record<string, number>;
attributeMapping?: Record<string, number>;
export interface EveData {
typeIDs: Record<string, TypeID>;
groupIDs: Record<string, GroupID>;
marketGroups: Record<string, MarketGroup>;
typeDogma: Record<string, TypeDogma>;
dogmaEffects: Record<string, DogmaEffect>;
dogmaAttributes: Record<string, DogmaAttribute>;
effectMapping: Record<string, number>;
attributeMapping: Record<string, number>;
}
export const EveDataContext = React.createContext<DogmaData>({});
const EveDataContext = React.createContext<EveData | null>(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<o
return result.entries;
}
function isLoaded(dogmaData: DogmaData): boolean | undefined {
if (dogmaData.typeIDs === undefined) return undefined;
if (dogmaData.groupIDs === undefined) return undefined;
if (dogmaData.marketGroups === undefined) return undefined;
if (dogmaData.typeDogma === undefined) return undefined;
if (dogmaData.dogmaEffects === undefined) return undefined;
if (dogmaData.dogmaAttributes === undefined) return undefined;
if (dogmaData.effectMapping === undefined) return undefined;
if (dogmaData.attributeMapping === undefined) return undefined;
function isLoaded(dogmaData: EveData): boolean {
if (Object.keys(dogmaData.typeIDs).length === 0) return false;
if (Object.keys(dogmaData.groupIDs).length === 0) return false;
if (Object.keys(dogmaData.marketGroups).length === 0) return false;
if (Object.keys(dogmaData.typeDogma).length === 0) return false;
if (Object.keys(dogmaData.dogmaEffects).length === 0) return false;
if (Object.keys(dogmaData.dogmaAttributes).length === 0) return false;
return true;
}
@@ -56,28 +61,37 @@ function isLoaded(dogmaData: DogmaData): boolean | undefined {
* Provides information like TypeIDs, Dogma information, etc.
*
* ```typescript
* const eveData = React.useContext(EveDataContext);
* const eveData = useEveData();
*
* if (eveData?.loaded) {
* if (eveData !== null) {
* console.log(eveData.typeIDs.length);
* }
* ```
*/
export const EveDataProvider = (props: DogmaDataProps) => {
export const EveDataProvider = (props: EveDataProps) => {
const dataUrl = props.dataUrl ?? `${defaultDataUrl}sde/`;
const [dogmaData, setDogmaData] = React.useState<DogmaData>({});
/* Initialize with empty data; we never set the context till everything is loaded. */
const [dogmaData, setDogmaData] = React.useState<EveData>({
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;
/* Only fire on first load of this component. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* Create a reverse mapping to quickly lookup attribute/effect name to attribute/effect ID. */
const attributeMapping: Record<string, number> = {};
for (const id in dogmaData.dogmaAttributes) {
const name = dogmaData.dogmaAttributes[id].name;
attributeMapping[name] = parseInt(id);
}
const effectMapping: Record<string, number> = {};
for (const id in dogmaData.dogmaEffects) {
const name = dogmaData.dogmaEffects[id].name;
effectMapping[name] = parseInt(id);
}
if (!isLoaded(dogmaData)) return <></>;
setDogmaData((prevDogmaData: DogmaData) => {
const newDogmaData = {
...prevDogmaData,
attributeMapping: attributeMapping,
effectMapping: effectMapping,
};
/* Create a reverse mapping to quickly lookup attribute/effect name to attribute/effect ID. */
const attributeMapping: Record<string, number> = {};
for (const id in dogmaData.dogmaAttributes) {
const name = dogmaData.dogmaAttributes[id].name;
attributeMapping[name] = parseInt(id);
}
const effectMapping: Record<string, number> = {};
for (const id in dogmaData.dogmaEffects) {
const name = dogmaData.dogmaEffects[id].name;
effectMapping[name] = parseInt(id);
}
newDogmaData.loaded = isLoaded(newDogmaData);
return newDogmaData;
});
}, [dogmaData.dogmaAttributes, dogmaData.dogmaEffects]);
const contextValue = {
...dogmaData,
attributeMapping: attributeMapping,
effectMapping: effectMapping,
};
return <EveDataContext.Provider value={dogmaData}>{props.children}</EveDataContext.Provider>;
return <EveDataContext.Provider value={contextValue}>{props.children}</EveDataContext.Provider>;
};

View File

@@ -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";

View File

@@ -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<typeof FitManagerProvider> & { fit: EsfFit | null };
const meta: Meta<StoryProps> = {
component: FitManagerProvider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<StoryProps>;
const TestStory = () => {
return <div></div>;
};
export const Default: Story = {
argTypes: {
children: { control: false },
fit: fitArgType,
},
args: {
fit: null,
},
decorators: [withDecoratorFull],
render: ({ fit, ...args }) => {
useFitSelection(fit);
return (
<FitManagerProvider {...args}>
<TestStory />
</FitManagerProvider>
);
},
};

View File

@@ -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<StatisticsSlotType, number> = {
hislot: 27,
medslot: 19,
lowslot: 11,
subsystem: 125,
rig: 92,
launcher: 0,
turret: 0,
};
const FitManagerContext = React.createContext<FitManager>({
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 <FitManagerContext.Provider value={contextValue}>{props.children}</FitManagerContext.Provider>;
};

View File

@@ -0,0 +1 @@
export { FitManagerProvider, useFitManager } from "./FitManagerProvider";

View File

@@ -1,43 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { LocalFitContext, LocalFitProvider } from "./";
const meta: Meta<typeof LocalFitProvider> = {
component: LocalFitProvider,
tags: ["autodocs"],
title: "Provider/LocalFitProvider",
};
export default meta;
type Story = StoryObj<typeof LocalFitProvider>;
const TestLocalFit = () => {
const localFit = React.useContext(LocalFitContext);
if (!localFit.loaded) {
return (
<div>
LocalFit: loading
<br />
</div>
);
}
return (
<div>
LocalFit: loaded
<br />
<pre>{JSON.stringify(localFit, null, 2)}</pre>
</div>
);
};
export const Default: Story = {
args: {},
render: (args) => (
<LocalFitProvider {...args}>
<TestLocalFit />
</LocalFitProvider>
),
};

Some files were not shown because too many files have changed in this diff Show More