feat: ability to rename fits and save them in the browser (#49)

This commit is contained in:
Patric Stout
2023-12-22 16:18:34 +01:00
committed by GitHub
parent 4c7be2a79c
commit f79e44ec4e
12 changed files with 416 additions and 7 deletions

View File

@@ -18,7 +18,6 @@
"author": "Patric Stout <eveshipfit@truebrain.nl>",
"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",

View File

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

View File

@@ -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<typeof FitButtonBar> = {
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: "50px"}}>
<ModalDialogAnchor />
<Story />
</div>
</ShipSnapshotProvider>
</LocalFitProvider>
</DogmaEngineProvider>
</EveDataProvider>
);
}
export const Default: Story = {
decorators: [withEveDataProvider],
parameters: {
snapshot: {
fit: fullFit,
skills: {},
}
},
};

View File

@@ -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 <>
<div className={styles.popupButton} onMouseOver={() => setIsPopupOpen(true)} onMouseOut={() => setIsPopupOpen(false)}>
<div className={styles.button}>
Save
</div>
<div className={clsx(styles.popup, {[styles.collapsed]: !isPopupOpen})}>
<div>
<div className={styles.button} onClick={() => saveBrowser()}>
Save in Browser
</div>
</div>
</div>
</div>
<ModalDialog visible={isAlreadyExistsOpen} onClose={() => setIsAlreadyExistsOpen(false)} className={styles.alreadyExists} title="Update Fitting?">
<div>
<div>
You have a fitting with the name {shipSnapshot?.fit?.name}, do you want to update it?
</div>
<div className={styles.alreadyExistsButtons}>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => saveBrowser(true)}>
Yes
</span>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => setIsAlreadyExistsOpen(false)}>
No
</span>
</div>
</div>
</ModalDialog>
</>
}
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 <>
<div className={styles.button} onClick={() => openRename()}>
Rename
</div>
<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)} />
</span>
<span className={clsx(styles.button, styles.buttonSmall)} onClick={() => saveRename()}>
Save
</span>
</div>
</ModalDialog>
</>
}
/**
* Bar with buttons to load/save fits.
*/
export const FitButtonBar = () => {
return <div className={styles.fitButtonBar}>
<SaveButton />
<RenameButton />
</div>
};

View File

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

View File

@@ -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<LocalFit>({
loaded: undefined,
fittings: [],
addFit: () => {},
});
export interface LocalFitProps {
@@ -26,17 +28,26 @@ export const LocalFitProvider = (props: LocalFitProps) => {
const [localFit, setLocalFit] = React.useState<LocalFit>({
loaded: undefined,
fittings: [],
addFit: () => {},
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [localFitValue, setLocalFitValue] = useLocalStorage<EsiFit[]>("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 <LocalFitContext.Provider value={localFit}>
{props.children}

View File

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

View File

@@ -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<typeof ModalDialog> = {
component: ModalDialog,
tags: ['autodocs'],
title: 'Component/ModalDialog',
};
export default meta;
type Story = StoryObj<typeof ModalDialog>;
const TestModalDialog = () => {
const [isOpen, setIsOpen] = React.useState(false);
return <>
<input type="button" value="Open" onClick={() => setIsOpen(true)} />
<ModalDialog visible={isOpen} onClose={() => setIsOpen(false)} title="Test Dialog">
Test
</ModalDialog>
</>;
}
export const Default: Story = {
args: {
},
render: () => (
<div>
<div>
Header not covered by modal dialog
</div>
<div style={{position: "relative", height: "40px"}}>
<ModalDialogAnchor />
<TestModalDialog />
</div>
</div>
),
};

View File

@@ -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 <ModalDialogAnchor /> somewhere in the DOM for this to work.
*/
export const ModalDialog = (props: ModalDialogProps) => {
const [modalDialogAnchor, setModalDialogAnchor] = React.useState<HTMLElement | null>(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(<div className={styles.modalDialog} onClick={() => props.onClose()}>
<div className={clsx(styles.content, props.className)} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>{props.title}</div>
{props.children}
</div>
</div>, modalDialogAnchor);
};
/**
* Anchor for where the Modal Dialogs should be inserted in the DOM.
*
* This should be in a <div> which has a position and dimensions set. The modal
* will always use the full size of this <div> when rendering its content.
*/
export const ModalDialogAnchor = () => {
return <div id="modalDialogAnchor">
</div>
}

1
src/ModalDialog/index.ts Normal file
View File

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

View File

@@ -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<ShipSnapshot>({
@@ -78,6 +79,7 @@ export const ShipSnapshotContext = React.createContext<ShipSnapshot>({
changeHull: () => {},
changeFit: () => {},
setItemState: () => {},
setName: () => {},
});
const slotStart: Record<ShipSnapshotSlotsType, number> = {
@@ -116,6 +118,7 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
changeHull: () => {},
changeFit: () => {},
setItemState: () => {},
setName: () => {},
});
const [currentFit, setCurrentFit] = React.useState<EsiFit | undefined>(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;

View File

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