diff --git a/package.json b/package.json index a5ec2d6..af7943e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "author": "Patric Stout ", "license": "MIT", "dependencies": { - "@eveshipfit/dogma-engine": "^2.3.2", "clsx": "^2.0.0", "jwt-decode": "^4.0.0" }, @@ -37,6 +36,7 @@ "@storybook/react-webpack5": "^7.5.3", "@storybook/testing-library": "^0.2.2", "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "eslint": "^8.53.0", @@ -62,8 +62,9 @@ "typescript-plugin-css-modules": "^5.0.2" }, "peerDependencies": { - "@eveshipfit/dogma-engine": "^2.2.1", - "react": "^18.2.0" + "@eveshipfit/dogma-engine": "^2.3.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/src/FitButtonBar/FitButtonBar.module.css b/src/FitButtonBar/FitButtonBar.module.css new file mode 100644 index 0000000..b213c95 --- /dev/null +++ b/src/FitButtonBar/FitButtonBar.module.css @@ -0,0 +1,87 @@ + +.fitButtonBar { + padding: 10px 0; + width: 100%; +} + +.fitButtonBar > div { + height: 40px; + margin-right: 10px; +} + +.button { + background-color: #321d1d; + border: 1px solid #9f462f; + color: #c5c5c5; + cursor: pointer; + display: inline-block; + height: 40px; + line-height: 40px; + padding: 0 20px; + user-select: none; + white-space: nowrap; +} +.buttonSmall { + height: 20px; + line-height: 20px; +} + +.button:hover { + background-color: #864735; + border-color: #c87d5e; +} + +.collapsed { + display: none; +} + +.popupButton { + display: inline-block; + position: relative; +} + +.popup { + bottom: 40px; + left: 0px; + position: absolute; + z-index: 10; +} +.popup:before { + background-color: #111111; + border-bottom: 1px solid #303030; + border-right: 1px solid #303030; + bottom: 5px; + content: ""; + display: block; + height: 10px; + left: 30px; + position: absolute; + transform: rotate(45deg); + width: 10px; +} + +.popup > div { + background-color: #111111; + border: 1px solid #303030; + margin-bottom: 10px; + padding: 10px; +} + +.renameEdit { + margin-right: 10px; +} + +.alreadyExists { + text-align: center; + max-width: 50%; +} + +.alreadyExistsButtons { + margin-top: 10px; + text-align: center; +} +.alreadyExistsButtons .button { + margin-right: 10px; + width: 35%; +} + diff --git a/src/FitButtonBar/FitButtonBar.stories.tsx b/src/FitButtonBar/FitButtonBar.stories.tsx new file mode 100644 index 0000000..1d17245 --- /dev/null +++ b/src/FitButtonBar/FitButtonBar.stories.tsx @@ -0,0 +1,48 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { fullFit } from '../../.storybook/fits'; + +import { DogmaEngineProvider } from '../DogmaEngineProvider'; +import { EveDataProvider } from '../EveDataProvider'; +import { LocalFitProvider } from '../LocalFitProvider'; +import { ModalDialogAnchor } from '../ModalDialog/ModalDialog'; +import { ShipSnapshotProvider } from '../ShipSnapshotProvider'; + +import { FitButtonBar } from './'; + +const meta: Meta = { + component: FitButtonBar, + tags: ['autodocs'], + title: 'Component/FitButtonBar', +}; + +export default meta; +type Story = StoryObj; + +const withEveDataProvider: Decorator> = (Story, context) => { + return ( + + + + +
+ + +
+
+
+
+
+ ); +} + +export const Default: Story = { + decorators: [withEveDataProvider], + parameters: { + snapshot: { + fit: fullFit, + skills: {}, + } + }, +}; diff --git a/src/FitButtonBar/FitButtonBar.tsx b/src/FitButtonBar/FitButtonBar.tsx new file mode 100644 index 0000000..8996060 --- /dev/null +++ b/src/FitButtonBar/FitButtonBar.tsx @@ -0,0 +1,111 @@ +import clsx from "clsx"; +import React from "react"; + +import { LocalFitContext } from "../LocalFitProvider"; +import { ModalDialog } from "../ModalDialog"; +import { ShipSnapshotContext } from "../ShipSnapshotProvider"; + +import styles from "./FitButtonBar.module.css"; + +const SaveButton = () => { + const shipSnapshot = React.useContext(ShipSnapshotContext); + const localFit = React.useContext(LocalFitContext); + + 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?.fit) return; + + setIsPopupOpen(false); + + if (!force) { + for (const fit of localFit.fittings) { + if (fit.name === shipSnapshot.fit.name) { + setIsAlreadyExistsOpen(true); + return; + } + } + } + + setIsAlreadyExistsOpen(false); + + localFit.addFit(shipSnapshot.fit); + }, [localFit, shipSnapshot]); + + return <> +
setIsPopupOpen(true)} onMouseOut={() => setIsPopupOpen(false)}> +
+ Save +
+
+
+
saveBrowser()}> + Save in Browser +
+
+
+
+ + setIsAlreadyExistsOpen(false)} className={styles.alreadyExists} title="Update Fitting?"> +
+
+ You have a fitting with the name {shipSnapshot?.fit?.name}, do you want to update it? +
+
+ saveBrowser(true)}> + Yes + + setIsAlreadyExistsOpen(false)}> + No + +
+
+
+ +} + +const RenameButton = () => { + const shipSnapshot = React.useContext(ShipSnapshotContext); + + const [isRenameOpen, setIsRenameOpen] = React.useState(false); + const [rename, setRename] = React.useState(""); + + const saveRename = React.useCallback(() => { + shipSnapshot?.setName(rename); + setIsRenameOpen(false); + }, [rename, shipSnapshot]); + + const openRename = React.useCallback(() => { + setRename(shipSnapshot?.fit?.name ?? ""); + setIsRenameOpen(true); + }, [shipSnapshot]); + + return <> +
openRename()}> + Rename +
+ + setIsRenameOpen(false)} title="Fit Name"> +
+ + setRename(e.target.value)} /> + + saveRename()}> + Save + +
+
+ +} + +/** + * Bar with buttons to load/save fits. + */ +export const FitButtonBar = () => { + return
+ + +
+}; diff --git a/src/FitButtonBar/index.ts b/src/FitButtonBar/index.ts new file mode 100644 index 0000000..4b6f449 --- /dev/null +++ b/src/FitButtonBar/index.ts @@ -0,0 +1 @@ +export { FitButtonBar } from "./FitButtonBar"; diff --git a/src/LocalFitProvider/LocalFitProvider.tsx b/src/LocalFitProvider/LocalFitProvider.tsx index ad0e534..133a072 100644 --- a/src/LocalFitProvider/LocalFitProvider.tsx +++ b/src/LocalFitProvider/LocalFitProvider.tsx @@ -7,11 +7,13 @@ import { useLocalStorage } from "../Helpers/LocalStorage"; export interface LocalFit { loaded?: boolean; fittings: EsiFit[]; + addFit: (fit: EsiFit) => void; } export const LocalFitContext = React.createContext({ loaded: undefined, fittings: [], + addFit: () => {}, }); export interface LocalFitProps { @@ -26,17 +28,26 @@ export const LocalFitProvider = (props: LocalFitProps) => { const [localFit, setLocalFit] = React.useState({ loaded: undefined, fittings: [], + addFit: () => {}, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [localFitValue, setLocalFitValue] = useLocalStorage("fits", []); + const addFit = React.useCallback((fit: EsiFit) => { + setLocalFitValue((oldFits) => { + const newFits = oldFits.filter((oldFit) => oldFit.name !== fit.name); + newFits.push(fit); + return newFits; + }); + }, [setLocalFitValue]); + React.useEffect(() => { setLocalFit({ loaded: true, fittings: localFitValue, + addFit, }); - }, [localFitValue]); + }, [localFitValue, addFit]); return {props.children} diff --git a/src/ModalDialog/ModalDialog.module.css b/src/ModalDialog/ModalDialog.module.css new file mode 100644 index 0000000..c52d891 --- /dev/null +++ b/src/ModalDialog/ModalDialog.module.css @@ -0,0 +1,33 @@ +.modalDialog { + bottom: 0px; + background-color: rgba(200, 200, 200, 0.3); + left: 0px; + margin: 0 auto; + position: absolute; + right: 0px; + text-align: center; + top: 0px; + z-index: 100; +} +.modalDialog:before { + content: ""; + display: inline-block; + height: 100%; + vertical-align: middle; +} + +.content { + background-color: #111111; + border: 1px solid #303030; + border-radius: 7px; + color: #c5c5c5; + display: inline-block; + padding: 10px; + text-align: left; + vertical-align: middle; +} + +.header { + font-size: 24px; + margin-bottom: 10px; +} diff --git a/src/ModalDialog/ModalDialog.stories.tsx b/src/ModalDialog/ModalDialog.stories.tsx new file mode 100644 index 0000000..257a931 --- /dev/null +++ b/src/ModalDialog/ModalDialog.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { ModalDialog } from './'; +import { ModalDialogAnchor } from './ModalDialog'; + +const meta: Meta = { + component: ModalDialog, + tags: ['autodocs'], + title: 'Component/ModalDialog', +}; + +export default meta; +type Story = StoryObj; + +const TestModalDialog = () => { + const [isOpen, setIsOpen] = React.useState(false); + + return <> + setIsOpen(true)} /> + setIsOpen(false)} title="Test Dialog"> + Test + + ; +} + +export const Default: Story = { + args: { + }, + render: () => ( +
+
+ Header not covered by modal dialog +
+
+ + +
+
+ ), +}; diff --git a/src/ModalDialog/ModalDialog.tsx b/src/ModalDialog/ModalDialog.tsx new file mode 100644 index 0000000..cf16eee --- /dev/null +++ b/src/ModalDialog/ModalDialog.tsx @@ -0,0 +1,57 @@ +import clsx from "clsx"; +import React, { useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; + +import styles from "./ModalDialog.module.css"; + +export interface ModalDialogProps { + /** Children that build up the modal dialog. */ + children: React.ReactNode; + /** Classname to add to the content of the dialog. */ + className?: string; + /** Whether the dialog should be visible. */ + visible: boolean; + /** Callback called when the dialog should be closed. */ + onClose: () => void; + /** Title of the modal dialog. */ + title: string; +} + +/** + * Create a modal dialog on top of all content. + * + * You need to set an somewhere in the DOM for this to work. + */ +export const ModalDialog = (props: ModalDialogProps) => { + const [modalDialogAnchor, setModalDialogAnchor] = React.useState(null); + + useLayoutEffect(() => { + if (modalDialogAnchor !== null) return; + + const newModalDialogAnchor = document.getElementById("modalDialogAnchor"); + if (newModalDialogAnchor === null) return; + + setModalDialogAnchor(newModalDialogAnchor); + }, [modalDialogAnchor]); + + if (!props.visible) return null; + if (modalDialogAnchor === null) return null; + + return createPortal(
props.onClose()}> +
e.stopPropagation()}> +
{props.title}
+ {props.children} +
+
, modalDialogAnchor); +}; + +/** + * Anchor for where the Modal Dialogs should be inserted in the DOM. + * + * This should be in a
which has a position and dimensions set. The modal + * will always use the full size of this
when rendering its content. + */ +export const ModalDialogAnchor = () => { + return
+
+} diff --git a/src/ModalDialog/index.ts b/src/ModalDialog/index.ts new file mode 100644 index 0000000..1de3be3 --- /dev/null +++ b/src/ModalDialog/index.ts @@ -0,0 +1 @@ +export { ModalDialog, ModalDialogAnchor } from "./ModalDialog"; diff --git a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx index 4ff5015..8baab0b 100644 --- a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx +++ b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx @@ -62,6 +62,7 @@ interface ShipSnapshot { changeHull: (typeId: number) => void; changeFit: (fit: EsiFit) => void; setItemState: (flag: number, state: string) => void; + setName: (name: string) => void; } export const ShipSnapshotContext = React.createContext({ @@ -78,6 +79,7 @@ export const ShipSnapshotContext = React.createContext({ changeHull: () => {}, changeFit: () => {}, setItemState: () => {}, + setName: () => {}, }); const slotStart: Record = { @@ -116,6 +118,7 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { changeHull: () => {}, changeFit: () => {}, setItemState: () => {}, + setName: () => {}, }); const [currentFit, setCurrentFit] = React.useState(undefined); const dogmaEngine = React.useContext(DogmaEngineContext); @@ -140,6 +143,17 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { }) }, []); + const setName = React.useCallback((name: string) => { + setCurrentFit((oldFit: EsiFit | undefined) => { + if (oldFit === undefined) return undefined; + + return { + ...oldFit, + name: name, + }; + }) + }, []); + const addModule = React.useCallback((typeId: number, slot: ShipSnapshotSlotsType | "dronebay") => { setCurrentFit((oldFit: EsiFit | undefined) => { if (oldFit === undefined) return undefined; @@ -205,8 +219,9 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { changeHull, changeFit: setCurrentFit, setItemState, + setName, })); - }, [addModule, removeModule, changeHull, setItemState]); + }, [addModule, removeModule, changeHull, setItemState, setName]); React.useEffect(() => { if (!dogmaEngine.loaded) return; diff --git a/src/index.ts b/src/index.ts index 2e95e7b..10fd17f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,17 @@ -export * from './DogmaEngineProvider'; export * from './CalculationDetail'; +export * from './DogmaEngineProvider'; export * from './EsiCharacterSelection'; export * from './EsiProvider'; export * from './EveDataProvider'; export * from './EveShipFitHash'; export * from './EveShipFitLink'; +export * from './FitButtonBar'; export * from './FormatEftToEsi'; export * from './HardwareListing'; export * from './HullListing'; export * from './Icon'; +export * from './LocalFitProvider'; +export * from './ModalDialog'; export * from './ShipAttribute'; export * from './ShipFit'; export * from './ShipFitExtended';