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:
Patric Stout
2023-12-03 15:59:05 +01:00
committed by GitHub
parent 20ef2c6656
commit c0ecc3f183
6 changed files with 214 additions and 140 deletions

View File

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

View File

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

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

View 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: {},
}
},
};

View 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
View File

@@ -0,0 +1 @@
export { TreeListing, TreeHeader, TreeHeaderAction, TreeLeaf } from "./TreeListing";