diff --git a/packages/frontend/src/components/Chain/Chain.css b/packages/frontend/src/components/Chain/Chain.css index 6b67682..5e68c8c 100644 --- a/packages/frontend/src/components/Chain/Chain.css +++ b/packages/frontend/src/components/Chain/Chain.css @@ -65,6 +65,13 @@ bottom: 0; } +.Chain-no-nodes { + font-size: 30px; + padding-top: 20vh; + text-align: center; + font-weight: 300; +} + .Chain-node-list { width: 100%; border-collapse: collapse; diff --git a/packages/frontend/src/components/Chain/Chain.tsx b/packages/frontend/src/components/Chain/Chain.tsx index a92b442..6a16216 100644 --- a/packages/frontend/src/components/Chain/Chain.tsx +++ b/packages/frontend/src/components/Chain/Chain.tsx @@ -1,22 +1,25 @@ import * as React from 'react'; +import { Types, Maybe } from '@dotstats/common'; import { State as AppState } from '../../state'; import { formatNumber, secondsWithPrecision, viewport } from '../../utils'; -import { Tab } from './'; +import { Tab, Filter } from './'; import { Tile, Node, Ago, Setting } from '../'; -import { Types } from '@dotstats/common'; import { PersistentObject, PersistentSet } from '../../persist'; import blockIcon from '../../icons/package.svg'; import blockTimeIcon from '../../icons/history.svg'; import lastTimeIcon from '../../icons/watch.svg'; import listIcon from '../../icons/list-alt-regular.svg'; -import worldIcon from '../../icons/map-pin-solid.svg'; +import worldIcon from '../../icons/location.svg'; import settingsIcon from '../../icons/settings.svg'; const MAP_RATIO = 800 / 350; const MAP_HEIGHT_ADJUST = 400 / 350; const HEADER = 148; +const ESCAPE_KEY = 27; +const BACKSPACE_KEY = 8; + import './Chain.css'; export namespace Chain { @@ -30,6 +33,7 @@ export namespace Chain { export interface State { display: Display; + filter: Maybe; map: { width: number; height: number; @@ -73,6 +77,7 @@ export class Chain extends React.Component { this.state = { display, + filter: null, map: { width: 0, height: 0, @@ -86,10 +91,12 @@ export class Chain extends React.Component { this.calculateMapDimensions(); window.addEventListener('resize', this.calculateMapDimensions); + window.addEventListener('keyup', this.onKeyPress); } public componentWillUnmount() { window.removeEventListener('resize', this.calculateMapDimensions); + window.removeEventListener('keyup', this.onKeyPress); } public render() { @@ -130,42 +137,63 @@ export class Chain extends React.Component { private renderList() { const { settings } = this.props.appState; const { pins } = this.props; + const { filter } = this.state; + const nodeFilter = this.getNodeFilter(); + const nodes = nodeFilter ? this.nodes().filter(nodeFilter) : this.nodes(); + + if (nodeFilter && nodes.length === 0) { + return ( + + {filter != null ? : null} +
¯\_(ツ)_/¯
Nothing matches
+
+ ); + } return ( - - - - { - this - .nodes() - .sort(sortNodes) - .map((node) => ) - } - -
+ + {filter != null ? : null} + + + + { + nodes + .sort(sortNodes) + .map((node) => ) + } + +
+
); } private renderMap() { + const { filter } = this.state; + const nodeFilter = this.getNodeFilter(); + return ( -
- { - this.nodes().map((node) => { - const location = node.location; + + {filter != null ? : null} +
+ { + this.nodes().map((node) => { + const location = node.location; + const focused = nodeFilter == null || nodeFilter(node); - if (!location || location[0] == null || location[1] == null) { - // Skip nodes with unknown location - return null; - } + if (!location || location[0] == null || location[1] == null) { + // Skip nodes with unknown location + return null; + } - const { left, top, quarter } = this.pixelPosition(location[0], location[1]); + const position = this.pixelPosition(location[0], location[1]); - return ( - - ); - }) - } -
+ return ( + + ); + }) + } +
+ ); } @@ -191,7 +219,7 @@ export class Chain extends React.Component { ); } - private nodes() { + private nodes(): AppState.Node[] { return Array.from(this.props.appState.nodes.values()); } @@ -241,4 +269,36 @@ export class Chain extends React.Component { this.setState({ map: { top, left, width, height }}); } + + private onKeyPress = (event: KeyboardEvent) => { + const { filter } = this.state; + const key = event.key; + const code = event.keyCode; + + const escape = filter != null && code === ESCAPE_KEY; + const backspace = filter === '' && code === BACKSPACE_KEY; + const singleChar = filter == null && key.length === 1; + + if (escape || backspace) { + this.setState({ filter: null }); + } else if (singleChar) { + this.setState({ filter: key }); + } + } + + private onFilterChange = (filter: string) => { + this.setState({ filter: filter.toLowerCase() }); + } + + private getNodeFilter(): Maybe<(node: AppState.Node) => boolean> { + const { filter } = this.state; + + if (filter == null) { + return null; + } + + const filterLC = filter.toLowerCase(); + + return ({ nodeDetails }) => nodeDetails[0].indexOf(filterLC) !== -1; + } } diff --git a/packages/frontend/src/components/Chain/Filter.css b/packages/frontend/src/components/Chain/Filter.css new file mode 100644 index 0000000..e3f93b7 --- /dev/null +++ b/packages/frontend/src/components/Chain/Filter.css @@ -0,0 +1,27 @@ +.Chain-Filter { + position: fixed; + z-index: 100; + bottom: 20px; + left: 50%; + width: 400px; + font-size: 30px; + margin-left: -210px; + padding: 10px; + border-radius: 4px; + background: #111; + color: #fff; + box-shadow: 0 2px 10px rgba(0,0,0,0.5); +} + +.Chain-Filter input { + padding: 0; + margin: 0; + border: none; + outline: none; + width: 400px; + font-size: 30px; + background: #111; + color: #fff; + font-family: Roboto, Helvetica, Arial, sans-serif; + font-weight: 300; +} diff --git a/packages/frontend/src/components/Chain/Filter.tsx b/packages/frontend/src/components/Chain/Filter.tsx new file mode 100644 index 0000000..bd0ac4d --- /dev/null +++ b/packages/frontend/src/components/Chain/Filter.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; + +import './Filter.css'; + +export namespace Filter { + export interface Props { + value: string; + onChange: (value: string) => void; + } +} + +export class Filter extends React.Component { + private filterInput: HTMLInputElement; + + public componentDidMount(){ + this.filterInput.focus(); + } + + public render() { + const { value } = this.props; + + return ( +
+ +
+ ); + } + + private onRef = (el: HTMLInputElement) => { + this.filterInput = el; + } + + private onChange = () => { + const { value } = this.filterInput; + + this.props.onChange(value); + } +} diff --git a/packages/frontend/src/components/Chain/index.ts b/packages/frontend/src/components/Chain/index.ts index 03ecb91..a06b623 100644 --- a/packages/frontend/src/components/Chain/index.ts +++ b/packages/frontend/src/components/Chain/index.ts @@ -1,2 +1,3 @@ export * from './Chain'; export * from './Tab'; +export * from './Filter'; diff --git a/packages/frontend/src/components/Node/Location.css b/packages/frontend/src/components/Node/Location.css index 184d230..090f87c 100644 --- a/packages/frontend/src/components/Node/Location.css +++ b/packages/frontend/src/components/Node/Location.css @@ -11,10 +11,20 @@ top: 50%; left: 50%; cursor: pointer; - z-index: 1; + z-index: 2; transition: border-color 0.25s linear; } +.Node-Location-dimmed { + width: 2px; + height: 2px; + margin-left: -1px; + margin-top: -1px; + z-index: 1; + background: #bbb; + border: none; +} + .Node-Location-ping { pointer-events: none; position: absolute; @@ -26,7 +36,7 @@ } .Node-Location-synced { - z-index: 2; + z-index: 3; border-color: #d64ca8; } diff --git a/packages/frontend/src/components/Node/Location.tsx b/packages/frontend/src/components/Node/Location.tsx index 0f72ce3..13eb682 100644 --- a/packages/frontend/src/components/Node/Location.tsx +++ b/packages/frontend/src/components/Node/Location.tsx @@ -20,6 +20,12 @@ import './Location.css'; namespace Location { export type Quarter = 0 | 1 | 2 | 3; + export interface Props { + node: AppState.Node; + position: Position; + focused: boolean; + } + export interface Position { left: number; top: number; @@ -31,13 +37,15 @@ namespace Location { } } -class Location extends React.Component { +class Location extends React.Component { public readonly state = { hover: false }; public render() { - const { left, top, quarter, location } = this.props; - const height = this.props.blockDetails[0]; - const propagationTime = this.props.blockDetails[4]; + const { node, position, focused } = this.props; + const { left, top, quarter } = position; + const { blockDetails, location } = node; + const height = blockDetails[0]; + const propagationTime = blockDetails[4]; if (!location) { return null; @@ -45,10 +53,14 @@ class Location extends React.Component