chore: refactor hull-listing to use tree-listing (#35)
This simplifies the code, and makes adding module-listing a lot easier.
This commit is contained in:
@@ -13,70 +13,6 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header1, .header2, .header3, .header4 {
|
||||
display: flex;
|
||||
user-select: none;
|
||||
}
|
||||
.header1, .header2, .header4 {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.header1 > span, .header2 > span, .header3 > span, .header4 > span {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.header1 {
|
||||
background-color: #1d1d1d;
|
||||
}
|
||||
|
||||
.header1:hover, .header2:hover, .header3:hover, .header4:hover {
|
||||
background-color: #4f4f4f;
|
||||
}
|
||||
.header4.noitem:hover {
|
||||
background-color: #111111;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.level1, .level2, .level3 {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.hull {
|
||||
display: flex;
|
||||
}
|
||||
.hull > span {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.hull > span:nth-child(3) {
|
||||
flex: 1;
|
||||
}
|
||||
.hull > span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
.hull > span:last-child > img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.hull > span:last-child > img:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.hullSimulate {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fit {
|
||||
cursor: pointer;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.fit.noitem {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EsiContext } from "../EsiProvider";
|
||||
import { EsiFit, ShipSnapshotContext } from "../ShipSnapshotProvider";
|
||||
import { EveDataContext } from "../EveDataProvider";
|
||||
import { Icon } from "../Icon";
|
||||
import { TreeListing, TreeHeader, TreeHeaderAction, TreeLeaf } from "../TreeListing";
|
||||
|
||||
import styles from "./HullListing.module.css";
|
||||
|
||||
@@ -30,112 +31,69 @@ const factionIdToRace: Record<number, string> = {
|
||||
500002: "Minmatar",
|
||||
500003: "Amarr",
|
||||
500004: "Gallente",
|
||||
1: "Non-Empire",
|
||||
} as const;
|
||||
|
||||
const Hull = (props: { typeId: number, entry: ListingFit, changeHull: (typeId: number) => void, changeFit: (fit: EsiFit) => void }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
let children = <></>;
|
||||
if (expanded) {
|
||||
const getChildren = React.useCallback(() => {
|
||||
if (props.entry.fits.length === 0) {
|
||||
children = <>
|
||||
<div className={clsx(styles.header4, styles.fit, styles.noitem)}>
|
||||
No Item
|
||||
</div>
|
||||
</>;
|
||||
return <TreeLeaf level={4} content={"No Item"} />;
|
||||
} else {
|
||||
let index = 0;
|
||||
children = <>{props.entry.fits.map((fit) => {
|
||||
return <>{props.entry.fits.map((fit) => {
|
||||
index += 1;
|
||||
return <div key={`${fit.ship_type_id}-${index}`} className={clsx(styles.header4, styles.fit)} onClick={() => props.changeFit(fit)}>
|
||||
{fit.name}
|
||||
</div>
|
||||
return <TreeLeaf key={`${fit.ship_type_id}-${index}`} level={4} content={fit.name} onClick={() => props.changeFit(fit)} />;
|
||||
})}</>;
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
function onClick(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
e.stopPropagation();
|
||||
props.changeHull(props.typeId);
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className={clsx(styles.header3, styles.hull)} onClick={() => setExpanded((current) => !current)}>
|
||||
<span>
|
||||
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
|
||||
</span>
|
||||
<span>
|
||||
<img src={`https://images.evetech.net/types/${props.typeId}/icon?size=32`} alt="" />
|
||||
</span>
|
||||
<span>
|
||||
{props.entry.name}
|
||||
</span>
|
||||
<span className={styles.hullSimulate} onClick={() => props.changeHull(props.typeId)}>
|
||||
<Icon name="simulate" size={32} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(styles.level3, {[styles.collapsed]: !expanded})}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
const headerAction = <TreeHeaderAction icon="simulate" onClick={onClick} />;
|
||||
const header = <TreeHeader icon={`https://images.evetech.net/types/${props.typeId}/icon?size=32`} text={props.entry.name} action={headerAction} />;
|
||||
return <TreeListing level={3} header={header} height={32} getChildren={getChildren} />;
|
||||
}
|
||||
|
||||
const HullRace = (props: { name: string, entries: ListingHulls, changeHull: (typeId: number) => void, changeFit: (fit: EsiFit) => void }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
if (props.entries === undefined) return null;
|
||||
|
||||
let children = <></>;
|
||||
if (expanded) {
|
||||
const HullRace = (props: { raceId: number, entries: ListingHulls, changeHull: (typeId: number) => void, changeFit: (fit: EsiFit) => void }) => {
|
||||
const getChildren = React.useCallback(() => {
|
||||
const changeProps = {
|
||||
changeHull: props.changeHull,
|
||||
changeFit: props.changeFit,
|
||||
};
|
||||
|
||||
children = <>{Object.keys(props.entries).sort((a, b) => props.entries[a].name.localeCompare(props.entries[b].name)).map((typeId) => {
|
||||
return <>{Object.keys(props.entries).sort((a, b) => props.entries[a].name.localeCompare(props.entries[b].name)).map((typeId) => {
|
||||
const entry = props.entries[typeId];
|
||||
return <Hull key={typeId} typeId={parseInt(typeId)} entry={entry} {...changeProps} />
|
||||
})}</>;
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
return <div>
|
||||
<div className={styles.header2} onClick={() => setExpanded((current) => !current)}>
|
||||
<span>
|
||||
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
|
||||
</span>
|
||||
{props.name} [{Object.keys(props.entries).length}]
|
||||
</div>
|
||||
<div className={clsx(styles.level2, {[styles.collapsed]: !expanded})}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
if (props.entries === undefined) return null;
|
||||
|
||||
const header = <TreeHeader icon={`https://images.evetech.net/corporations/${props.raceId}/logo?size=32`} text={`${factionIdToRace[props.raceId]} [${Object.keys(props.entries).length}]`} />;
|
||||
return <TreeListing level={2} header={header} getChildren={getChildren} />;
|
||||
}
|
||||
|
||||
const HullGroup = (props: { name: string, entries: ListingGroup, changeHull: (typeId: number) => void, changeFit: (fit: EsiFit) => void }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
let children = <></>;
|
||||
if (expanded) {
|
||||
const getChildren = React.useCallback(() => {
|
||||
const changeProps = {
|
||||
changeHull: props.changeHull,
|
||||
changeFit: props.changeFit,
|
||||
};
|
||||
|
||||
children = <>
|
||||
<HullRace name="Amarr" entries={props.entries.Amarr} {...changeProps} />
|
||||
<HullRace name="Caldari" entries={props.entries.Caldari} {...changeProps} />
|
||||
<HullRace name="Gallente" entries={props.entries.Gallente} {...changeProps} />
|
||||
<HullRace name="Minmatar" entries={props.entries.Minmatar} {...changeProps} />
|
||||
<HullRace name="Non-Empire" entries={props.entries.NonEmpire} {...changeProps} />
|
||||
return <>
|
||||
<HullRace raceId={500003} entries={props.entries.Amarr} {...changeProps} />
|
||||
<HullRace raceId={500001} entries={props.entries.Caldari} {...changeProps} />
|
||||
<HullRace raceId={500004} entries={props.entries.Gallente} {...changeProps} />
|
||||
<HullRace raceId={500002} entries={props.entries.Minmatar} {...changeProps} />
|
||||
<HullRace raceId={1} entries={props.entries.NonEmpire} {...changeProps} />
|
||||
</>;
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
return <div>
|
||||
<div className={styles.header1} onClick={() => setExpanded((current) => !current)}>
|
||||
<span>
|
||||
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
|
||||
</span>
|
||||
{props.name}
|
||||
</div>
|
||||
<div className={clsx(styles.level1, {[styles.collapsed]: !expanded})}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
const header = <TreeHeader text={`${props.name}`} />;
|
||||
return <TreeListing level={1} header={header} getChildren={getChildren} />;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -229,7 +187,7 @@ export const HullListing = (props: { changeHull: (typeId: number) => void, chang
|
||||
<Icon name="fitting-local" size={32} title="Not yet implemented" />
|
||||
</span>
|
||||
<span className={clsx({[styles.selected]: filter.esiCharacter})} onClick={() => setFilter({...filter, esiCharacter: !filter.esiCharacter})}>
|
||||
<Icon name="fitting-character" size={32} title="In-game character fits" />
|
||||
<Icon name="fitting-character" size={32} title="Filter: in-game personal fittings" />
|
||||
</span>
|
||||
<span className={styles.disabled}>
|
||||
<Icon name="fitting-corporation" size={32} title="CCP didn't implement this ESI endpoint (yet?)" />
|
||||
@@ -238,7 +196,7 @@ export const HullListing = (props: { changeHull: (typeId: number) => void, chang
|
||||
<Icon name="fitting-alliance" size={32} title="CCP didn't implement this ESI endpoint (yet?)" />
|
||||
</span>
|
||||
<span className={clsx({[styles.selected]: filter.currentHull})} onClick={() => setFilter({...filter, currentHull: !filter.currentHull})}>
|
||||
<Icon name="fitting-hull" size={32} title="Current hull" />
|
||||
<Icon name="fitting-hull" size={32} title="Filter: current hull" />
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.listingContent}>
|
||||
|
||||
38
src/TreeListing/TreeListing.module.css
Normal file
38
src/TreeListing/TreeListing.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.header {
|
||||
display: flex;
|
||||
height: var(--height);
|
||||
padding: 2px 0;
|
||||
line-height: var(--height);
|
||||
user-select: none;
|
||||
}
|
||||
.header > span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.headerHover:hover {
|
||||
background-color: #4f4f4f;
|
||||
}
|
||||
|
||||
.header1 {
|
||||
background-color: #1d1d1d;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.headerAction:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.leaf {
|
||||
cursor: pointer;
|
||||
}
|
||||
49
src/TreeListing/TreeListing.stories.tsx
Normal file
49
src/TreeListing/TreeListing.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Decorator, Meta, StoryObj } from '@storybook/react';
|
||||
import React from "react";
|
||||
|
||||
import { fullFit } from '../../.storybook/fits';
|
||||
|
||||
import { TreeListing } from './';
|
||||
import { EsiProvider } from '../EsiProvider';
|
||||
import { EveDataProvider } from '../EveDataProvider';
|
||||
import { EsiFit, ShipSnapshotProvider } from '../ShipSnapshotProvider';
|
||||
import { DogmaEngineProvider } from '../DogmaEngineProvider';
|
||||
|
||||
const meta: Meta<typeof TreeListing> = {
|
||||
component: TreeListing,
|
||||
tags: ['autodocs'],
|
||||
title: 'Component/TreeListing',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TreeListing>;
|
||||
|
||||
const withEsiProvider: Decorator<{ changeHull: (typeId: number) => void, changeFit: (fit: EsiFit) => void }> = (Story, context) => {
|
||||
return (
|
||||
<EveDataProvider>
|
||||
<EsiProvider setSkills={console.log}>
|
||||
<DogmaEngineProvider>
|
||||
<ShipSnapshotProvider {...context.parameters.snapshot}>
|
||||
<div style={{height: "400px"}}>
|
||||
<Story />
|
||||
</div>
|
||||
</ShipSnapshotProvider>
|
||||
</DogmaEngineProvider>
|
||||
</EsiProvider>
|
||||
</EveDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
changeHull: (typeId: number) => console.log(`changeHull(${typeId})`),
|
||||
changeFit: (fit: EsiFit) => console.log(`changeFit(${fit})`),
|
||||
},
|
||||
decorators: [withEsiProvider],
|
||||
parameters: {
|
||||
snapshot: {
|
||||
fit: fullFit,
|
||||
skills: {},
|
||||
}
|
||||
},
|
||||
};
|
||||
92
src/TreeListing/TreeListing.tsx
Normal file
92
src/TreeListing/TreeListing.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Icon, IconName } from "../Icon";
|
||||
|
||||
import styles from "./TreeListing.module.css";
|
||||
|
||||
interface Tree {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const TreeContext = React.createContext<Tree>({size: 24});
|
||||
|
||||
/**
|
||||
* Action (the icon on the right side of the header) for a header.
|
||||
*/
|
||||
export const TreeHeaderAction = (props: { icon: IconName, onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void }) => {
|
||||
const tree = React.useContext(TreeContext);
|
||||
|
||||
return <div className={styles.headerAction} onClick={props.onClick}>
|
||||
<Icon name={props.icon} size={tree.size} />
|
||||
</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Header for a listing.
|
||||
*/
|
||||
export const TreeHeader = (props: { icon?: string, text: string, action?: React.ReactNode }) => {
|
||||
const tree = React.useContext(TreeContext);
|
||||
|
||||
return <>
|
||||
{props.icon !== undefined && <span>
|
||||
<img src={props.icon} height={tree.size} width={tree.size} alt="" />
|
||||
</span>}
|
||||
<span className={styles.headerText}>
|
||||
{props.text}
|
||||
</span>
|
||||
{props.action && <span>
|
||||
{props.action}
|
||||
</span>}
|
||||
</>
|
||||
}
|
||||
|
||||
export const TreeLeaf = (props: { level: number, height?: number, content: string, onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void }) => {
|
||||
const stylesHeader = styles[`header${props.level}`];
|
||||
|
||||
const height = props.height ?? 20;
|
||||
const style = { "--height": `${height}px` } as React.CSSProperties;
|
||||
|
||||
return <div>
|
||||
<TreeContext.Provider value={{size: height}}>
|
||||
<div style={style} className={clsx(styles.header, stylesHeader, {[styles.headerHover]: props.onClick !== undefined, [styles.leaf]: props.onClick !== undefined})} onClick={props.onClick}>
|
||||
<span className={styles.headerText}>
|
||||
{props.content}
|
||||
</span>
|
||||
</div>
|
||||
</TreeContext.Provider>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree listing for hulls, modules, and charges.
|
||||
*/
|
||||
export const TreeListing = (props: { level: number, header: React.ReactNode, height?: number, getChildren: () => React.ReactNode }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const stylesHeader = styles[`header${props.level}`];
|
||||
const stylesContent = styles[`content${props.level}`];
|
||||
|
||||
const height = props.height ?? 20;
|
||||
const style = { "--height": `${height}px` } as React.CSSProperties;
|
||||
|
||||
let children: React.ReactNode = <></>;
|
||||
/* Speed up rendering by not rendering children if we are collapsed. */
|
||||
if (expanded) {
|
||||
children = props.getChildren();
|
||||
}
|
||||
|
||||
return <div>
|
||||
<TreeContext.Provider value={{size: height}}>
|
||||
<div style={style} className={clsx(styles.header, styles.headerHover, stylesHeader)} onClick={() => setExpanded((current) => !current)}>
|
||||
<span>
|
||||
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
|
||||
</span>
|
||||
{props.header}
|
||||
</div>
|
||||
<div className={clsx(styles.content, stylesContent)}>
|
||||
{children}
|
||||
</div>
|
||||
</TreeContext.Provider>
|
||||
</div>
|
||||
};
|
||||
1
src/TreeListing/index.ts
Normal file
1
src/TreeListing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TreeListing, TreeHeader, TreeHeaderAction, TreeLeaf } from "./TreeListing";
|
||||
Reference in New Issue
Block a user