diff --git a/.eslintrc.yml b/.eslintrc.yml index 36fb0c3..39eafe8 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,14 +1,36 @@ --- +parser: '@typescript-eslint/parser' +parserOptions: + project: tsconfig.json + sourceType: module + extends: + - 'plugin:@typescript-eslint/recommended' + - 'prettier' + - prettier/@typescript-eslint - react-app - - eslint:recommended + +env: + node: true + jest: true globals: __DEV__: true - # Promise: true rules: - quotes: ["error", "single"] - strict: ["error", "never"] - no-console: "warn" + '@typescript-eslint/interface-name-prefix': 'off' + '@typescript-eslint/explicit-function-return-type': 'off' + '@typescript-eslint/no-explicit-any': 'off' + '@typescript-eslint/camelcase': 'off' + '@typescript-eslint/no-unused-vars': + - 'error' + - { argsIgnorePattern: '^_' } + '@typescript-eslint/no-use-before-define': + - error + - functions: false + react-hooks/rules-of-hooks: error + +# quotes: ["error", "single"] +# strict: ["error", "never"] +# no-console: "warn" diff --git a/package.json b/package.json index 5ab5521..6482830 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.13", "description": "Yet another Clash dashboard", "scripts": { - "lint": "eslint --cache src", + "lint": "eslint --fix --cache src", "start": "NODE_ENV=development node server.js", "build": "NODE_ENV=production webpack -p --progress", "pretty": "prettier --single-quote --write 'src/**/*.{js,scss}'" @@ -37,8 +37,9 @@ "@sentry/browser": "^5.15.0", "chart.js": "^2.9.2", "classnames": "^2.2.6", + "clsx": "^1.1.0", "core-js": "^3.6.2", - "date-fns": "^2.8.1", + "date-fns": "^2.13.0", "framer-motion": "^1.10.0", "history": "^4.7.2", "immer": "^5.1.0", @@ -57,7 +58,7 @@ "react-switch": "^5.0.1", "react-table": "7.0.0-rc.15", "react-tabs": "^3.1.0", - "react-tiny-fab": "^3.4.1", + "react-tiny-fab": "^3.5.0", "react-window": "^1.8.5", "regenerator-runtime": "^0.13.2", "reselect": "^4.0.0" @@ -74,9 +75,10 @@ "@hsjs/react-refresh-webpack-plugin": "^0.1.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.2.0", "@types/jest": "^25.2.1", - "@types/react": "^16.9.34", - "@typescript-eslint/eslint-plugin": "^2.30.0", - "@typescript-eslint/parser": "^2.30.0", + "@types/react": "^16.9.35", + "@types/react-dom": "^16.9.8", + "@typescript-eslint/eslint-plugin": "2.32.1-alpha.2", + "@typescript-eslint/parser": "2.32.1-alpha.2", "autoprefixer": "^9.7.3", "babel-eslint": "10.x", "babel-loader": "^8.0.5", @@ -84,21 +86,24 @@ "copy-webpack-plugin": "^5.1.1", "css-loader": "^3.4.2", "cssnano": "^4.1.7", - "eslint": "6.x", + "eslint": "^7.0.0", + "eslint-config-airbnb-base": "^14.1.0", + "eslint-config-prettier": "^6.11.0", "eslint-config-react-app": "^5.2.1", "eslint-import-resolver-webpack": "^0.12.0", "eslint-plugin-flowtype": "4.x", - "eslint-plugin-import": "2.x", - "eslint-plugin-jest": "^23.6.0", + "eslint-plugin-import": "^2.20.2", + "eslint-plugin-jest": "^23.10.0", "eslint-plugin-jsx-a11y": "6.x", "eslint-plugin-react": "7.x", - "eslint-plugin-react-hooks": "2.x", + "eslint-plugin-react-hooks": "^4.0.0", + "eslint-plugin-simple-import-sort": "^5.0.3", "file-loader": "^6.0.0", "fork-ts-checker-notifier-webpack-plugin": "^2.0.0", "fork-ts-checker-webpack-plugin": "^4.1.3", - "html-webpack-plugin": "^4.2.1", + "html-webpack-plugin": "^4.3.0", "husky": "^4.0.0", - "lint-staged": "^10.2.1", + "lint-staged": "^10.2.2", "mini-css-extract-plugin": "^0.9.0", "postcss-custom-media": "^7.0.8", "postcss-extend-rule": "^3.0.0", @@ -107,11 +112,11 @@ "postcss-nested": "^4.2.0", "postcss-simple-vars": "^5.0.2", "prettier": "^2.0.4", - "react-refresh": "^0.8.1", + "react-refresh": "^0.8.2", "resize-observer-polyfill": "^1.5.1", "style-loader": "^1.2.1", - "terser-webpack-plugin": "^2.3.1", - "ts-loader": "^7.0.2", + "terser-webpack-plugin": "^3.0.1", + "ts-loader": "^7.0.4", "typescript": "^3.8.3", "webpack": "^4.41.6", "webpack-bundle-analyzer": "^3.6.0", diff --git a/src/api/connections.js b/src/api/connections.js index e385266..528b2fc 100644 --- a/src/api/connections.js +++ b/src/api/connections.js @@ -2,8 +2,8 @@ import { getURLAndInit } from '../misc/request-helper'; const endpoint = '/connections'; -let fetched = false; -let subscribers = []; +const fetched = false; +const subscribers = []; // see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41 type UUID = string; @@ -16,7 +16,7 @@ type ConnectionItem = { destinationIP: string, sourcePort: string, destinationPort: string, - host: string + host: string, }, upload: number, download: number, @@ -24,12 +24,12 @@ type ConnectionItem = { start: string, chains: Array, // e.g. 'Match', 'DomainKeyword' - rule: string + rule: string, }; type ConnectionsData = { downloadTotal: number, uploadTotal: number, - connections: Array + connections: Array, }; function appendData(s) { @@ -40,7 +40,7 @@ function appendData(s) { // eslint-disable-next-line no-console console.log('JSON.parse error', JSON.parse(s)); } - subscribers.forEach(f => f(o)); + subscribers.forEach((f) => f(o)); } function getWsUrl(apiConfig) { @@ -60,10 +60,10 @@ function fetchData(apiConfig, listener) { wsState = 1; const url = getWsUrl(apiConfig); const ws = new WebSocket(url); - ws.addEventListener('error', function(_ev) { + ws.addEventListener('error', function (_ev) { wsState = 3; }); - ws.addEventListener('message', function(event) { + ws.addEventListener('message', function (event) { appendData(event.data); }); if (listener) return subscribe(listener); diff --git a/src/api/traffic.js b/src/api/traffic.js index 569f347..4aa8ff6 100644 --- a/src/api/traffic.js +++ b/src/api/traffic.js @@ -22,17 +22,16 @@ const traffic = { if (this.down.length > this.size) this.down.shift(); if (this.labels.length > this.size) this.labels.shift(); - this.subscribers.forEach(f => f(o)); + this.subscribers.forEach((f) => f(o)); }, subscribe(listener) { - const me = this; this.subscribers.push(listener); - return function unsubscribe() { - const idx = me.subscribers.indexOf(listener); - me.subscribers.splice(idx, 1); + return () => { + const idx = this.subscribers.indexOf(listener); + this.subscribers.splice(idx, 1); }; - } + }, }; let fetched = false; @@ -89,14 +88,14 @@ function fetchData(apiConfig) { wsState = 1; const url = getWsUrl(apiConfig); const ws = new WebSocket(url); - ws.addEventListener('error', function(_ev) { + ws.addEventListener('error', function (_ev) { wsState = 3; }); - ws.addEventListener('close', function(_ev) { + ws.addEventListener('close', function (_ev) { wsState = 3; fetchDataWithFetch(apiConfig); }); - ws.addEventListener('message', function(event) { + ws.addEventListener('message', function (event) { parseAndAppend(event.data); }); return traffic; @@ -107,7 +106,7 @@ function fetchDataWithFetch(apiConfig) { fetched = true; const { url, init } = getURLAndInit(apiConfig); fetch(url + endpoint, init).then( - response => { + (response) => { if (response.ok) { const reader = response.body.getReader(); pump(reader); @@ -115,7 +114,7 @@ function fetchDataWithFetch(apiConfig) { fetched = false; } }, - err => { + (err) => { // eslint-disable-next-line no-console console.log('fetch /traffic error', err); fetched = false; diff --git a/src/components/APIConfig.js b/src/components/APIConfig.js index 7be1f12..62e55eb 100644 --- a/src/components/APIConfig.js +++ b/src/components/APIConfig.js @@ -11,8 +11,8 @@ import { getClashAPIConfig, updateClashAPIConfig } from '../store/app'; const { useState, useEffect, useRef, useCallback } = React; -const mapState = s => ({ - apiConfig: getClashAPIConfig(s) +const mapState = (s) => ({ + apiConfig: getClashAPIConfig(s), }); function APIConfig({ apiConfig, dispatch }) { @@ -46,11 +46,11 @@ function APIConfig({ apiConfig, dispatch }) { detectApiServer(); }, []); - const handleInputOnChange = useCallback(e => { + const handleInputOnChange = useCallback((e) => { userTouchedFlagRef.current = true; const target = e.target; const { name } = target; - let value = target.value; + const value = target.value; switch (name) { case 'port': setPort(value); @@ -71,7 +71,7 @@ function APIConfig({ apiConfig, dispatch }) { }, [hostname, port, secret, dispatch]); const handleContentOnKeyDown = useCallback( - e => { + (e) => { // enter keyCode is 13 if (e.keyCode !== 13) return; updateConfig(); diff --git a/src/components/Button.js b/src/components/Button.tsx similarity index 66% rename from src/components/Button.js rename to src/components/Button.tsx index 2ec0c22..8c0acc6 100644 --- a/src/components/Button.js +++ b/src/components/Button.tsx @@ -1,7 +1,5 @@ -import React from 'react'; -import cx from 'classnames'; - -import type { Node, Element, SyntheticEvent } from 'react'; +import * as React from 'react'; +import cx from 'clsx'; import { LoadingDot } from './shared/Basic'; @@ -9,17 +7,21 @@ import s0 from './Button.module.css'; const { memo, forwardRef, useCallback } = React; -type ButtonProps = { - children?: Node, - label?: string, - text?: string, - isLoading?: boolean, - start?: Element | (() => Element), - onClick?: (SyntheticEvent) => mixed, - kind?: 'primary' | 'minimal', - className?: string +type ButtonInternalProps = { + children?: React.ReactChildren; + label?: string; + text?: string; + start?: React.ReactElement | (() => React.ReactElement); }; -function Button(props: ButtonProps, ref) { + +type ButtonProps = { + isLoading?: boolean; + onClick?: (e: React.MouseEvent) => unknown; + kind?: 'primary' | 'minimal'; + className?: string; +} & ButtonInternalProps; + +function Button(props: ButtonProps, ref: React.Ref) { const { onClick, isLoading, @@ -28,7 +30,7 @@ function Button(props: ButtonProps, ref) { ...restProps } = props; const internalOnClick = useCallback( - e => { + (e) => { if (isLoading) return; onClick && onClick(e); }, @@ -37,7 +39,7 @@ function Button(props: ButtonProps, ref) { const btnClassName = cx( s0.btn, { - [s0.minimal]: kind === 'minimal' + [s0.minimal]: kind === 'minimal', }, className ); @@ -48,7 +50,7 @@ function Button(props: ButtonProps, ref) { @@ -64,7 +66,7 @@ function Button(props: ButtonProps, ref) { ); } -function ButtonInternal({ children, label, text, start }) { +function ButtonInternal({ children, label, text, start }: ButtonInternalProps) { return ( <> {start ? ( diff --git a/src/components/Config.js b/src/components/Config.js index b98bcb6..46c480d 100644 --- a/src/components/Config.js +++ b/src/components/Config.js @@ -7,7 +7,7 @@ import { getClashAPIConfig, getSelectedChartStyleIndex, getLatencyTestUrl, - clearStorage + clearStorage, } from '../store/app'; import ContentHeader from './ContentHeader'; @@ -27,50 +27,50 @@ const propsList = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]; const optionsRule = [ { label: 'Global', - value: 'Global' + value: 'Global', }, { label: 'Rule', - value: 'Rule' + value: 'Rule', }, { label: 'Direct', - value: 'Direct' - } + value: 'Direct', + }, ]; const optionsLogLevel = [ { label: 'info', - value: 'info' + value: 'info', }, { label: 'warning', - value: 'warning' + value: 'warning', }, { label: 'error', - value: 'error' + value: 'error', }, { label: 'debug', - value: 'debug' + value: 'debug', }, { label: 'silent', - value: 'silent' - } + value: 'silent', + }, ]; -const mapState = s => ({ +const mapState = (s) => ({ configs: getConfigs(s), - apiConfig: getClashAPIConfig(s) + apiConfig: getClashAPIConfig(s), }); -const mapState2 = s => ({ +const mapState2 = (s) => ({ selectedChartStyleIndex: getSelectedChartStyleIndex(s), latencyTestUrl: getLatencyTestUrl(s), - apiConfig: getClashAPIConfig(s) + apiConfig: getClashAPIConfig(s), }); const Config = connect(mapState2)(ConfigImpl); @@ -88,7 +88,7 @@ function ConfigImpl({ configs, selectedChartStyleIndex, latencyTestUrl, - apiConfig + apiConfig, }) { const [configState, setConfigStateInternal] = useState(configs); const refConfigs = useRef(configs); @@ -103,14 +103,14 @@ function ConfigImpl({ (name, val) => { setConfigStateInternal({ ...configState, - [name]: val + [name]: val, }); }, [configState] ); const handleSwitchOnChange = useCallback( - checked => { + (checked) => { const name = 'allow-lan'; const value = checked; setConfigState(name, value); @@ -120,10 +120,10 @@ function ConfigImpl({ ); const handleInputOnChange = useCallback( - e => { + (e) => { const target = e.target; const { name } = target; - let { value } = target; + const { value } = target; switch (target.name) { case 'mode': case 'log-level': @@ -149,7 +149,7 @@ function ConfigImpl({ const { selectChartStyleIndex, updateAppConfig } = useStoreActions(); const handleInputOnBlur = useCallback( - e => { + (e) => { const target = e.target; const { name, value } = target; switch (name) { @@ -269,5 +269,5 @@ function ConfigImpl({ } Config.propTypes = { - configs: PropTypes.object + configs: PropTypes.object, }; diff --git a/src/components/Connections.js b/src/components/Connections.js index bb58c5a..f58c15c 100644 --- a/src/components/Connections.js +++ b/src/components/Connections.js @@ -3,18 +3,18 @@ import ContentHeader from './ContentHeader'; import ConnectionTable from './ConnectionTable'; import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import { getClashAPIConfig } from '../store/app'; -import { X as IconClose } from 'react-feather'; +import { X as IconClose, Pause, Play } from 'react-feather'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import SvgYacd from './SvgYacd'; -import Button from './Button'; import ModalCloseAllConnections from './ModalCloseAllConnections'; import { connect } from './StateProvider'; import * as connAPI from '../api/connections'; +import { Fab, Action, position as fabPosition } from './shared/Fab'; import './Connections.css'; import s from './Connections.module.css'; -const { useEffect, useState, useRef, useCallback, useMemo } = React; +const { useEffect, useState, useRef, useCallback } = React; const paddingBottom = 30; @@ -29,13 +29,14 @@ function arrayToIdKv(items) { function formatConnectionDataItem(i, prevKv) { const { id, metadata, upload, download, start, chains, rule } = i; + // eslint-disable-next-line prefer-const let { host, destinationPort, destinationIP } = metadata; // host could be an empty string if it's direct IP connection if (host === '') host = destinationIP; const metadataNext = { ...metadata, // merge host and destinationPort into one column - host: host + ':' + destinationPort + host: host + ':' + destinationPort, }; // const started = formatDistance(new Date(start), now); const ret = { @@ -45,7 +46,7 @@ function formatConnectionDataItem(i, prevKv) { start: 0 - new Date(start), chains: chains.reverse().join(' / '), rule, - ...metadataNext + ...metadataNext, }; const prev = prevKv[id]; ret.downloadSpeedCurr = download - (prev ? prev.download : 0); @@ -77,35 +78,44 @@ function Conn({ apiConfig }) { () => setIsCloseAllModalOpen(false), [] ); + const [isRefreshPaused, setIsRefreshPaused] = useState(false); + const toggleIsRefreshPaused = useCallback(() => { + setIsRefreshPaused((x) => !x); + }, []); const closeAllConnections = useCallback(() => { connAPI.closeAllConnections(apiConfig); closeCloseAllModal(); }, [apiConfig, closeCloseAllModal]); - const iconClose = useMemo(() => , []); const prevConnsRef = useRef(conns); const read = useCallback( ({ connections }) => { const prevConnsKv = arrayToIdKv(prevConnsRef.current); - const x = connections.map(c => formatConnectionDataItem(c, prevConnsKv)); + const x = connections.map((c) => + formatConnectionDataItem(c, prevConnsKv) + ); const closed = []; for (const c of prevConnsRef.current) { - const idx = x.findIndex(conn => conn.id === c.id); + const idx = x.findIndex((conn) => conn.id === c.id); if (idx < 0) closed.push(c); } - setClosedConns(prev => { + setClosedConns((prev) => { // keep max 100 entries return [...closed, ...prev].slice(0, 101); }); // if previous connections and current connections are both empty // arrays, we wont update state to avaoid rerender - if (x && (x.length !== 0 || prevConnsRef.current.length !== 0)) { + if ( + x && + (x.length !== 0 || prevConnsRef.current.length !== 0) && + !isRefreshPaused + ) { prevConnsRef.current = x; setConns(x); } else { prevConnsRef.current = x; } }, - [setConns] + [setConns, isRefreshPaused] ); useEffect(() => { return connAPI.fetchData(apiConfig, read); @@ -135,18 +145,33 @@ function Conn({ apiConfig }) {
<>{renderTableOrPlaceholder(conns)} -
-
+ > + + +
{renderTableOrPlaceholder(closedConns)}
@@ -161,8 +186,8 @@ function Conn({ apiConfig }) { ); } -const mapState = s => ({ - apiConfig: getClashAPIConfig(s) +const mapState = (s) => ({ + apiConfig: getClashAPIConfig(s), }); export default connect(mapState)(Conn); diff --git a/src/components/Proxies.js b/src/components/Proxies.js index 729b57a..23b1bd8 100644 --- a/src/components/Proxies.js +++ b/src/components/Proxies.js @@ -11,9 +11,8 @@ import Equalizer from './svg/Equalizer'; import { Zap } from 'react-feather'; import ProxyProviderList from './ProxyProviderList'; -import { Fab } from 'react-tiny-fab'; +import { Fab, position as fabPosition } from './shared/Fab'; -import './rtf.css'; import s0 from './Proxies.module.css'; import { @@ -96,7 +95,8 @@ function Proxies({ dispatch, groupNames, delay, proxyProviders, apiConfig }) { icon={} onClick={requestDelayAllFn} text="Test Latency" - > + position={fabPosition} + /> ); } diff --git a/src/components/Proxy.js b/src/components/Proxy.js index 65bed4e..c694e55 100644 --- a/src/components/Proxy.js +++ b/src/components/Proxy.js @@ -18,10 +18,10 @@ const colorMap = { // orange bad: '#e67f3c', // bad: '#F56C6C', - na: '#909399' + na: '#909399', }; -function getLabelColor({ number, error } = {}) { +function getLabelColor({ number } = {}) { if (number < 200) { return colorMap.good; } else if (number < 400) { @@ -52,10 +52,10 @@ type ProxyProps = { // connect injected // TODO refine type proxy: any, - latency: any + latency: any, }; -function ProxySmallImpl({ now, name, proxy, latency }: ProxyProps) { +function ProxySmallImpl({ now, name, latency }: ProxyProps) { const color = useMemo(() => getLabelColor(latency), [latency]); const title = useMemo(() => { let ret = name; @@ -79,7 +79,7 @@ function Proxy({ now, name, proxy, latency }: ProxyProps) {
{name}
@@ -100,7 +100,7 @@ const mapState = (s, { name }) => { const delay = getDelay(s); return { proxy: proxies[name], - latency: delay[name] + latency: delay[name], }; }; diff --git a/src/components/Rules.js b/src/components/Rules.js index bd1766f..cdb86c7 100644 --- a/src/components/Rules.js +++ b/src/components/Rules.js @@ -1,8 +1,8 @@ import React from 'react'; -import Button from './Button'; import { FixedSizeList as List, areEqual } from 'react-window'; import { RotateCw } from 'react-feather'; +import { Fab, position as fabPosition } from './shared/Fab'; import { connect } from './StateProvider'; import { getClashAPIConfig } from '../store/app'; import ContentHeader from './ContentHeader'; @@ -31,9 +31,9 @@ const Row = memo(({ index, style, data }) => { ); }, areEqual); -const mapState = s => ({ +const mapState = (s) => ({ apiConfig: getClashAPIConfig(s), - rules: getRules(s) + rules: getRules(s), }); export default connect(mapState)(Rules); @@ -63,9 +63,13 @@ function Rules({ dispatch, apiConfig, rules }) { {Row}
-
-
+ + ); } diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js index e675f89..d7360fb 100644 --- a/src/components/StateProvider.js +++ b/src/components/StateProvider.js @@ -9,7 +9,7 @@ const { useEffect, useCallback, useContext, - useState + useState, } = React; export { immer }; @@ -58,7 +58,7 @@ export default function Provider({ initialState, actions = {}, children }) { ); const boundActions = useMemo(() => bindActions(actions, dispatch), [ actions, - dispatch + dispatch, ]); return ( @@ -73,7 +73,7 @@ export default function Provider({ initialState, actions = {}, children }) { } export function connect(mapStateToProps) { - return Component => { + return (Component) => { const MemoComponent = memo(Component); function Connected(props) { const state = useContext(StateContext); @@ -88,8 +88,8 @@ export function connect(mapStateToProps) { // steal from https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts function bindAction(action, dispatch) { - return function() { - return dispatch(action.apply(this, arguments)); + return function (...args) { + return dispatch(action.apply(this, args)); }; } diff --git a/src/components/StyleGuide.js b/src/components/StyleGuide.js index 4cb473d..0d71fc4 100644 --- a/src/components/StyleGuide.js +++ b/src/components/StyleGuide.js @@ -8,23 +8,27 @@ import Input from './Input'; import Button from './Button'; import { LoadingDot } from './shared/Basic'; +const noop = () => { + /* empty */ +}; + const paneStyle = { - padding: '20px 0' + padding: '20px 0', }; const optionsRule = [ { label: 'Global', - value: 'Global' + value: 'Global', }, { label: 'Rule', - value: 'Rule' + value: 'Rule', }, { label: 'Direct', - value: 'Direct' - } + value: 'Direct', + }, ]; const Pane = ({ children, style }) => ( @@ -34,7 +38,7 @@ const Pane = ({ children, style }) => ( function useToggle(initialState = false) { const [onoff, setonoff] = React.useState(initialState); const handleChange = React.useCallback(() => { - setonoff(x => !x); + setonoff((x) => !x); }, []); return [onoff, handleChange]; } @@ -59,7 +63,7 @@ class StyleGuide extends PureComponent { name="test" options={optionsRule} value="Rule" - onChange={() => {}} + onChange={noop} /> diff --git a/src/components/ToggleSwitch.js b/src/components/ToggleSwitch.js index f04407d..b71b49d 100644 --- a/src/components/ToggleSwitch.js +++ b/src/components/ToggleSwitch.js @@ -4,10 +4,10 @@ import PropTypes from 'prop-types'; import s0 from './ToggleSwitch.module.css'; function ToggleSwitch({ options, value, name, onChange }) { - const idxSelected = useMemo(() => options.map(o => o.value).indexOf(value), [ - options, - value - ]); + const idxSelected = useMemo( + () => options.map((o) => o.value).indexOf(value), + [options, value] + ); const w = (100 / options.length).toPrecision(3); return (
@@ -16,12 +16,12 @@ function ToggleSwitch({ options, value, name, onChange }) { className={s0.slider} style={{ width: w + '%', - left: idxSelected * w + '%' + left: idxSelected * w + '%', }} /> {options.map((o, idx) => { const id = `${name}-${o.label}`; - let className = idx === 0 ? '' : 'border-left'; + const className = idx === 0 ? '' : 'border-left'; return (