From 9231982a1765f2a46aebf9525b169fe767eeeb44 Mon Sep 17 00:00:00 2001 From: Nil Amrutlal <112877112+nil-amrutlal-dept@users.noreply.github.com> Date: Mon, 3 Jun 2024 13:17:43 +0200 Subject: [PATCH] Statistics, Search and Logo Components (Landing Page) (#2116) * wip: statistics stack component and main page layout * feat: styled statistics component and kadena logo * chore: changeset file * fix: lint fixes * fix: small corrections and fixes * Search Combobox Component (#2166) * wip: introduced dropdown * wip: search combobox * feast: changes in statistics stack and index * wip: search combobox * feat: search component * fix: linting fixes * chore: changeset file * feat: implement polling on statistics data * fix: small design fixes * Delete .changeset/forty-frogs-fly.md * Update brave-frogs-argue.md * fix: typo fix * feat: remove border from select * wip: added statistics grid for mobile view and added react-responsive * wip: apply getMediaQuery * feat: tweak responsive values for search input and statistics grid * fix: linting * feat: adjust responsiveness * feat: replace library causing hydration issue to another alternative compatible with ssr * fix: lint formatting * fix: remove react-responsive * feat: apply media styling with updated variables * fix: remove lib from devDependencies --- .changeset/brave-frogs-argue.md | 5 + .../src/components/search/search.css.ts | 34 +++ .../explorer/src/components/search/search.tsx | 229 ++++++++++++++++++ .../components/statistics/statistics-grid.tsx | 38 +++ .../statistics/statistics-stack.tsx | 79 ++++++ .../components/statistics/statistics.css.ts | 14 ++ .../src/components/statistics/statistics.tsx | 24 ++ .../apps/explorer/src/constants/search.ts | 17 ++ packages/apps/explorer/src/pages/_app.tsx | 9 +- packages/apps/explorer/src/pages/index.tsx | 42 +++- packages/apps/explorer/src/services/format.ts | 55 +++++ packages/apps/graph/src/services/network.ts | 2 + pnpm-lock.yaml | 2 +- 13 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 .changeset/brave-frogs-argue.md create mode 100644 packages/apps/explorer/src/components/search/search.css.ts create mode 100644 packages/apps/explorer/src/components/search/search.tsx create mode 100644 packages/apps/explorer/src/components/statistics/statistics-grid.tsx create mode 100644 packages/apps/explorer/src/components/statistics/statistics-stack.tsx create mode 100644 packages/apps/explorer/src/components/statistics/statistics.css.ts create mode 100644 packages/apps/explorer/src/components/statistics/statistics.tsx create mode 100644 packages/apps/explorer/src/constants/search.ts create mode 100644 packages/apps/explorer/src/services/format.ts diff --git a/.changeset/brave-frogs-argue.md b/.changeset/brave-frogs-argue.md new file mode 100644 index 0000000000..e16992c713 --- /dev/null +++ b/.changeset/brave-frogs-argue.md @@ -0,0 +1,5 @@ +--- +"@kadena/explorer": patch +--- + +Implemented statistics, search and logo components and necessary logic diff --git a/packages/apps/explorer/src/components/search/search.css.ts b/packages/apps/explorer/src/components/search/search.css.ts new file mode 100644 index 0000000000..dfef7bae06 --- /dev/null +++ b/packages/apps/explorer/src/components/search/search.css.ts @@ -0,0 +1,34 @@ +import { atoms, responsiveStyle } from '@kadena/react-ui/styles'; +import { style } from '@vanilla-extract/css'; + +export const searchBoxClass = style({ + ...responsiveStyle({ + md: { + width: 525, + }, + sm: { + width: 475, + }, + xs: { + width: 325, + }, + }), +}); + +export const searchInputClass = style([ + atoms({ + backgroundColor: 'base.default', + fontSize: 'md', + fontFamily: 'primaryFont', + outline: 'none', + }), + { + height: 55, + border: 'none', + width: '75%', + }, +]); + +export const searchBadgeBoxClass = style({ + width: '20%', +}); diff --git a/packages/apps/explorer/src/components/search/search.tsx b/packages/apps/explorer/src/components/search/search.tsx new file mode 100644 index 0000000000..4af31ffb86 --- /dev/null +++ b/packages/apps/explorer/src/components/search/search.tsx @@ -0,0 +1,229 @@ +import { truncateValues } from '@/services/format'; +import { MonoSearch } from '@kadena/react-icons/system'; +import { Badge, Box } from '@kadena/react-ui'; +import { atoms } from '@kadena/react-ui/styles'; +import React, { useState } from 'react'; +import { + searchBadgeBoxClass, + searchBoxClass, + searchInputClass, +} from './search.css'; + +export type SearchItemTitle = + | 'Account' + | 'Request Key' + | 'Block Height' + | 'Block Hash' + | 'Events'; + +export interface ISearchItem { + title: SearchItemTitle; + disabled?: boolean; +} +interface ISearchComponentProps { + placeholder: string; + searchItems: ISearchItem[]; +} + +const SearchCombobox: React.FC = ({ + placeholder, + searchItems, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [searchOption, setSearchOption] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [optionClicked, setOptionClicked] = useState(false); + const [escapePressed, setEscapePressed] = useState(false); + + const setOptionsDisabledExcept = (exceptIndex: number): void => { + searchItems.forEach((item, index) => { + if (index !== exceptIndex) { + item.disabled = true; + } + }); + }; + + const inferOption = (value: string): SearchItemTitle | undefined => { + if ( + value.toLocaleLowerCase().startsWith('k:') || + value.toLocaleLowerCase().startsWith('w:') + ) { + return 'Account'; + } else if (value.includes('.')) { + return 'Events'; + } else if (value.length === 43) { + return 'Request Key'; + } else if (/^\d+$/.test(value)) { + return 'Block Height'; + } + + return undefined; + }; + + const enableAllOptions = (): void => { + searchItems.forEach((item) => { + item.disabled = false; + }); + }; + + const handleSearch = (value: string, option: number | null): void => {}; + + const handleSearchValueChange = ( + e: React.ChangeEvent, + ): void => { + setSearchValue(e.target.value); + + if (escapePressed || optionClicked) return; + + const inferedOption = inferOption(e.target.value); + if (inferedOption === 'Account') { + setSearchOption(0); + setOptionsDisabledExcept(0); + } + if (inferedOption === 'Request Key') { + setSearchOption(1); + setOptionsDisabledExcept(1); + } + + if (inferedOption === 'Block Height') { + setSearchOption(2); + setOptionsDisabledExcept(2); + } + + if (!inferedOption || inferedOption === undefined) { + setSearchOption(null); + enableAllOptions(); + } + }; + + const handleSearchValueKeyDown = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSearchOption((prev) => + prev === null ? 0 : Math.min(prev + 1, searchItems.length - 1), + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSearchOption((prev) => (prev === null ? 0 : Math.max(prev - 1, 0))); + } else if (e.key === 'Enter') { + e.preventDefault(); + setIsEditing(false); + setEscapePressed(false); + setOptionClicked(false); + handleSearch(searchValue, searchOption); + } else if (e.key === 'Escape') { + setOptionClicked(false); + setSearchOption(null); + setEscapePressed(true); + enableAllOptions(); + } + }; + + return ( + <> + handleSearchValueKeyDown(e)} + onBlur={() => { + if (!optionClicked) { + setIsEditing(false); + } + }} + > + + + + handleSearchValueChange(e)} + onFocus={() => setIsEditing(true)} + className={searchInputClass} + /> + + {searchOption !== null && ( + + {searchItems[searchOption].title} + + )} + + + {isEditing && ( +
+ {searchItems.map((item, index) => ( + setOptionClicked(true)} + onClick={() => { + if (!item.disabled) { + setSearchOption(index); + setIsEditing(false); + } + }} + style={{ + gridTemplateColumns: '1fr 3fr', + borderLeft: index === searchOption ? 'solid' : 'none', + }} + className={atoms({ + display: 'grid', + alignItems: 'flex-start', + paddingInlineStart: 'md', + cursor: item.disabled ? 'not-allowed' : 'pointer', + backgroundColor: + index === searchOption ? 'base.@active' : 'base.default', + width: '100%', + })} + > +
+ {item.title} +
+
+ {truncateValues(searchValue)} +
+
+ ))} +
+ )} +
+ + ); +}; + +export default SearchCombobox; diff --git a/packages/apps/explorer/src/components/statistics/statistics-grid.tsx b/packages/apps/explorer/src/components/statistics/statistics-grid.tsx new file mode 100644 index 0000000000..ec42dc4433 --- /dev/null +++ b/packages/apps/explorer/src/components/statistics/statistics-grid.tsx @@ -0,0 +1,38 @@ +import { Grid, Stack, Text } from '@kadena/react-ui'; +import { atoms } from '@kadena/react-ui/styles'; +import React from 'react'; + +interface ISearchComponentProps { + data: { label: string; value: string }[]; +} + +const StatisticsGrid: React.FC = ({ data }) => { + return ( + + {data.map((item) => ( + + {item.value} + + {item.label} + + + ))} + + ); +}; + +export default StatisticsGrid; diff --git a/packages/apps/explorer/src/components/statistics/statistics-stack.tsx b/packages/apps/explorer/src/components/statistics/statistics-stack.tsx new file mode 100644 index 0000000000..d4c1cab94f --- /dev/null +++ b/packages/apps/explorer/src/components/statistics/statistics-stack.tsx @@ -0,0 +1,79 @@ +import { SpireKeyKdacolorLogoWhite } from '@kadena/react-icons/product'; +import { MonoHub } from '@kadena/react-icons/system'; +import { Button, Select, SelectItem, Stack, Text } from '@kadena/react-ui'; +import { atoms } from '@kadena/react-ui/styles'; +import React, { useState } from 'react'; +import { Media } from '../layout/media'; +import { borderStyleClass, statisticsSpireKeyClass } from './statistics.css'; + +interface IStatisticsStackProps { + data: { label: string; value: string }[]; +} + +const StatisticsStack: React.FC = ({ data }) => { + const [selectedNetwork, setSelectedNetwork] = useState('Mainnet'); + + return ( + + + {data.map((item) => ( + + {item.value} + + {item.label} + + + ))} + + +
+ + + + +
+ +
+ +
+
+
+
+
+
+ ); +}; + +export default StatisticsStack; diff --git a/packages/apps/explorer/src/components/statistics/statistics.css.ts b/packages/apps/explorer/src/components/statistics/statistics.css.ts new file mode 100644 index 0000000000..6cd098cdd5 --- /dev/null +++ b/packages/apps/explorer/src/components/statistics/statistics.css.ts @@ -0,0 +1,14 @@ +import { atoms } from '@kadena/react-ui/styles'; + +export const borderStyleClass = atoms({ + borderStyle: 'solid', + borderWidth: 'hairline', + display: 'flex', +}); + +export const statisticsSpireKeyClass = atoms({ + borderStyle: 'solid', + borderWidth: 'hairline', + display: 'flex', + backgroundColor: 'base.inverse.default', +}); diff --git a/packages/apps/explorer/src/components/statistics/statistics.tsx b/packages/apps/explorer/src/components/statistics/statistics.tsx new file mode 100644 index 0000000000..15b2ab7db3 --- /dev/null +++ b/packages/apps/explorer/src/components/statistics/statistics.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Media } from '../layout/media'; +import StatisticsGrid from './statistics-grid'; +import StatisticsStack from './statistics-stack'; + +interface IStatisticsProps { + data: { label: string; value: string }[]; +} + +const Statistics: React.FC = ({ data }) => { + return ( + <> + + + + + + + + + ); +}; + +export default Statistics; diff --git a/packages/apps/explorer/src/constants/search.ts b/packages/apps/explorer/src/constants/search.ts new file mode 100644 index 0000000000..ad2e594f6e --- /dev/null +++ b/packages/apps/explorer/src/constants/search.ts @@ -0,0 +1,17 @@ +import type { ISearchItem } from '@/components/search/search'; + +export const getSearchData = (): { + searchItems: ISearchItem[]; + placeholder: string; +} => { + const searchItems: ISearchItem[] = [ + { title: 'Account' }, + { title: 'Request Key' }, + { title: 'Block Height' }, + { title: 'Block Hash' }, + { title: 'Events' }, + ]; + const placeholder = 'Search the Kadena Blockchain on'; + + return { searchItems, placeholder }; +}; diff --git a/packages/apps/explorer/src/pages/_app.tsx b/packages/apps/explorer/src/pages/_app.tsx index 17d1bc4225..e0daf01a4b 100644 --- a/packages/apps/explorer/src/pages/_app.tsx +++ b/packages/apps/explorer/src/pages/_app.tsx @@ -9,17 +9,16 @@ import { InMemoryCache, split, } from '@apollo/client'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { getMainDefinition } from '@apollo/client/utilities'; import { RouterProvider } from '@kadena/react-ui'; +import { createClient } from 'graphql-ws'; import type { AppProps } from 'next/app'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import type { ComponentType } from 'react'; import React from 'react'; -import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; -import { getMainDefinition } from '@apollo/client/utilities'; -import { createClient } from 'graphql-ws'; -import Head from 'next/head'; - // next/apollo-link bug: https://github.com/dotansimha/graphql-yoga/issues/2194 // eslint-disable-next-line @typescript-eslint/no-var-requires const { YogaLink } = require('@graphql-yoga/apollo-link'); diff --git a/packages/apps/explorer/src/pages/index.tsx b/packages/apps/explorer/src/pages/index.tsx index 760e119658..bec1b47d87 100644 --- a/packages/apps/explorer/src/pages/index.tsx +++ b/packages/apps/explorer/src/pages/index.tsx @@ -1,9 +1,49 @@ +import { useNetworkInfoQuery } from '@/__generated__/sdk'; +import { Media } from '@/components/layout/media'; +import Search from '@/components/search/search'; +import Statistics from '@/components/statistics/statistics'; +import { getSearchData } from '@/constants/search'; +import { formatStatisticsData } from '@/services/format'; +import { LogoKdacolorLight } from '@kadena/react-icons/brand'; +import { Stack } from '@kadena/react-ui'; +import { atoms } from '@kadena/react-ui/styles'; import React from 'react'; const Home: React.FC = () => { + // Ideally we would pull this data once and then make calcs client-side + const { data: statisticsData } = useNetworkInfoQuery({ + pollInterval: 5000, + }); + + const statisticsGridData = formatStatisticsData(statisticsData?.networkInfo); + const searchData = getSearchData(); return ( <> -

K:Explorer

+ + + + + + + + + + + + + + + + ); }; diff --git a/packages/apps/explorer/src/services/format.ts b/packages/apps/explorer/src/services/format.ts new file mode 100644 index 0000000000..56de9f1576 --- /dev/null +++ b/packages/apps/explorer/src/services/format.ts @@ -0,0 +1,55 @@ +import type { NetworkInfo } from '@/__generated__/sdk'; + +export function formatNumberWithUnit(number: number, unit?: string): string { + if (number === 0) { + return `0 ${unit ? unit : ''}`; + } + const units = ['', 'K', 'M', 'B', 'T', 'P', 'E']; + const unitIndex = Math.floor(Math.log10(Math.abs(number)) / 3); + const formattedNumber = (number / Math.pow(1000, unitIndex)).toFixed(2); + return `${formattedNumber} ${units[unitIndex]}${unit ? unit : ''}`; +} + +export function formatStatisticsData( + networkInfo: NetworkInfo | null | undefined, +): { label: string; value: string }[] { + if (!networkInfo) { + return [ + { label: 'Est. Network Hash', value: '0 H/s' }, + { label: 'Total Difficulty', value: '0 H' }, + { label: 'Transactions', value: '0' }, + { label: 'Circulating Coins', value: '0' }, + ]; + } + + return [ + { + label: 'Est. Network Hash', + value: formatNumberWithUnit(networkInfo.networkHashRate, 'H/s'), + }, + { + label: 'Total Difficulty', + value: formatNumberWithUnit(networkInfo.totalDifficulty, 'H'), + }, + { + label: 'Transactions', + value: formatNumberWithUnit(networkInfo.transactionCount), + }, + { + label: 'Circulating Coins', + value: formatNumberWithUnit(networkInfo.coinsInCirculation), + }, + ]; +} + +export function truncateValues( + value: string, + minLength: number = 15, + startChars: number = 5, + endChars: number = 4, +): string { + if (value.length > minLength) { + return `${value.slice(0, startChars)}...${value.slice(-endChars)}`; + } + return value; +} diff --git a/packages/apps/graph/src/services/network.ts b/packages/apps/graph/src/services/network.ts index e8c81e5f3b..9b83e302f2 100644 --- a/packages/apps/graph/src/services/network.ts +++ b/packages/apps/graph/src/services/network.ts @@ -28,6 +28,8 @@ export async function getNetworkStatistics(): Promise { await fetch(`${dotenv.NETWORK_STATISTICS_URL}`) ).json(); + if (stats.transactionCount < 0) stats.transactionCount = 0; + return stats; } catch (error) { throw new NetworkError('Unable to parse response data.', error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68b282fc23..f12531308a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16382,7 +16382,7 @@ packages: chalk: 4.1.2 debug: 4.3.4(supports-color@5.5.0) loader-utils: 2.0.4 - webpack: 5.88.2(webpack-cli@4.9.2) + webpack: 5.88.2(@swc/core@1.3.80) transitivePeerDependencies: - '@types/node' - less