feat: ability to rename fits and save them in the browser (#49)
This commit is contained in:
@@ -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",
|
||||
|
||||
87
src/FitButtonBar/FitButtonBar.module.css
Normal file
87
src/FitButtonBar/FitButtonBar.module.css
Normal 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%;
|
||||
}
|
||||
|
||||
48
src/FitButtonBar/FitButtonBar.stories.tsx
Normal file
48
src/FitButtonBar/FitButtonBar.stories.tsx
Normal 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: {},
|
||||
}
|
||||
},
|
||||
};
|
||||
111
src/FitButtonBar/FitButtonBar.tsx
Normal file
111
src/FitButtonBar/FitButtonBar.tsx
Normal 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>
|
||||
};
|
||||
1
src/FitButtonBar/index.ts
Normal file
1
src/FitButtonBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FitButtonBar } from "./FitButtonBar";
|
||||
@@ -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}
|
||||
|
||||
33
src/ModalDialog/ModalDialog.module.css
Normal file
33
src/ModalDialog/ModalDialog.module.css
Normal 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;
|
||||
}
|
||||
41
src/ModalDialog/ModalDialog.stories.tsx
Normal file
41
src/ModalDialog/ModalDialog.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
57
src/ModalDialog/ModalDialog.tsx
Normal file
57
src/ModalDialog/ModalDialog.tsx
Normal 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
1
src/ModalDialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ModalDialog, ModalDialogAnchor } from "./ModalDialog";
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user