diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index fbfcbdf0d..10ec286e4 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -3,12 +3,11 @@ import React, { useEffect, useState } from "react"; import { Helmet } from "react-helmet"; import { RouteComponentProps } from "react-router-dom"; import { AppRouteProps } from "./AppRouteProps"; -import { buildMetaLabel, buildUtilities, getFooterItems, getPages, getFaviconHelmet, networkContextWrapper } from "./AppUtils"; +import { buildMetaLabel, getFooterItems, getPages, getFaviconHelmet, networkContextWrapper } from "./AppUtils"; import Disclaimer from "./components/Disclaimer"; import Footer from "./components/footer/Footer"; import ShimmerFooter from "./components/footer/ShimmerFooter"; import Header from "./components/header/Header"; -import SearchInput from "./components/SearchInput"; import buildAppRoutes from "./routes"; import { ServiceFactory } from "~factories/serviceFactory"; import { isShimmerUiTheme } from "~helpers/networkHelper"; @@ -62,6 +61,7 @@ const App: React.FC> = ({ } const routes = buildAppRoutes(networkConfig?.protocolVersion ?? "", withNetworkContext); + const pages = getPages(networkConfig, networks); const metaLabel = buildMetaLabel(currentNetworkName); const faviconHelmet = getFaviconHelmet(isShimmer); @@ -81,14 +81,8 @@ const App: React.FC> = ({ networks={networks} action={action} history={history} - search={ - history.push(`/${currentNetworkName}/search/${query}`)} - protocolVersion={networkConfig?.protocolVersion ?? STARDUST} - /> - } - pages={getPages(networkConfig, networks)} - utilities={buildUtilities(network ?? "", networks, identityResolverEnabled)} + protocolVersion={protocolVersion} + pages={pages} />
{networks.length > 0 ? ( diff --git a/client/src/app/AppUtils.tsx b/client/src/app/AppUtils.tsx index b53930ab1..26fdab134 100644 --- a/client/src/app/AppUtils.tsx +++ b/client/src/app/AppUtils.tsx @@ -5,6 +5,7 @@ import { INetwork } from "~models/config/INetwork"; import { ALPHANET, CHRYSALIS_MAINNET, DEVNET, LEGACY_MAINNET, MAINNET, NetworkType, SHIMMER, TESTNET } from "~models/config/networkType"; import { IOTA_UI, Theme } from "~models/config/uiTheme"; import { IReducedNodeInfo } from "~services/nodeInfoService"; +import { NavigationRoute } from "./lib/interfaces"; export const networkContextWrapper = (currentNetwork: string | undefined, nodeInfo: IReducedNodeInfo | null, uiTheme: Theme | undefined) => function withNetworkContext(wrappedComponent: ReactNode) { @@ -24,30 +25,57 @@ export const networkContextWrapper = (currentNetwork: string | undefined, nodeIn ) : null; }; -export const getPages = (currentNetwork: INetwork | undefined, networks: INetwork[]) => { - const pages = []; - if (networks.length > 0 && currentNetwork !== undefined) { - pages.push({ label: "Explorer", url: `/${currentNetwork.network}/` }); - pages.push({ label: "Visualizer", url: `/${currentNetwork.network}/visualizer/` }); +export const getPages = (currentNetwork: INetwork | undefined, networks: INetwork[]): NavigationRoute[] => { + const hasNetworks = networks.length > 0 && currentNetwork !== undefined; - if (currentNetwork.hasStatisticsSupport) { - pages.push({ label: "Statistics", url: `/${currentNetwork.network}/statistics/` }); - } - } - - return pages; -}; + const { network, hasStatisticsSupport } = currentNetwork ?? { network: "", hasStatisticsSupport: false }; -export const buildUtilities = (currentNetwork: string, networks: INetwork[], identityResolverEnabled: boolean) => { - const utilities = []; - if (networks.length > 0) { - utilities.push({ label: "Streams v0", url: `/${currentNetwork}/streams/0/` }); - if (identityResolverEnabled) { - utilities.push({ label: "Decentralized Identifier", url: `/${currentNetwork}/identity-resolver/` }); - } - } + const routes: NavigationRoute[] = [ + { + label: "Explorer", + url: `/${network}/`, + disabled: !hasNetworks, + }, + { + label: "Visualizer", + url: `/${network}/visualizer/`, + disabled: !hasNetworks, + }, + { + label: "Statistics", + url: `/${network}/statistics/`, + disabled: !hasStatisticsSupport || !hasNetworks, + }, + { + label: "Utilities", + disabled: network !== CHRYSALIS_MAINNET || !hasNetworks, + routes: [ + { label: "Streams v0", url: `/${network}/streams/0/` }, + { + label: "Decentralized Identifier", + url: `/${network}/identity-resolver/`, + disabled: network !== CHRYSALIS_MAINNET, + }, + ], + }, + { + label: "EVM", + routes: [ + { + label: "ShimmerEVM Explorer", + url: "https://explorer.evm.shimmer.network/", + isExternal: true, + }, + { + label: "ShimmerEVM Testnet Testnet", + url: "https://explorer.evm.testnet.shimmer.network/", + isExternal: true, + }, + ], + }, + ]; - return utilities; + return routes; }; /** diff --git a/client/src/app/components/header/Header.scss b/client/src/app/components/header/Header.scss index 339ff9022..d3deb62fb 100644 --- a/client/src/app/components/header/Header.scss +++ b/client/src/app/components/header/Header.scss @@ -150,21 +150,25 @@ header { } .navigation--item, - .utilities--wrapper { + .header-dropdown--wrapper { @include font-size(14px, 21px); display: flex; align-items: center; height: 100%; - margin-left: 40px; color: var(--navbar-color); font-family: $metropolis; font-weight: 600; letter-spacing: 0.01em; + margin-left: 32px; + + &:first-child { + margin-left: 40px; + } } - .utilities--wrapper { - .utilities--dropdown { + .header-dropdown--wrapper { + .header-dropdown--dropdown { display: flex; align-items: center; font-family: $metropolis; @@ -176,12 +180,13 @@ header { color: var(--navbar-color); font-weight: 600; letter-spacing: 0.01em; + text-wrap: nowrap; } .icon { display: flex; align-items: center; - margin-left: 8px; + margin-left: 4px; span { margin-bottom: 3px; @@ -204,14 +209,14 @@ header { } } - .utilities { + .header-dropdown { padding: 64px 120px 120px 120px; & * { margin-bottom: 8px; } - .utilities--label { + .header-dropdown--label { color: var(--navbar-color); font-family: $metropolis; font-weight: 700; @@ -221,7 +226,7 @@ header { @include font-size(14px, 21px); } - .utilities--item a { + .header-dropdown--item a { color: $gray-7; font-family: $inter; letter-spacing: 0.5px; @@ -231,11 +236,14 @@ header { } } - .utilities--mobile { + .header-dropdown--mobile { transition: opacity 0.3s ease-in-out; opacity: 0; + height: 0; + overflow: hidden; &.opened { + height: auto; opacity: 1; } } @@ -296,7 +304,7 @@ header { @include desktop-down { .navigation--item, - .utilities--wrapper, + .header-dropdown--wrapper, .search-input { display: none; } diff --git a/client/src/app/components/header/Header.tsx b/client/src/app/components/header/Header.tsx index 88e647d0f..61f50a265 100644 --- a/client/src/app/components/header/Header.tsx +++ b/client/src/app/components/header/Header.tsx @@ -1,324 +1,225 @@ -/* eslint-disable react/jsx-closing-tag-location */ +import React, { useEffect, useState } from "react"; +import * as H from "history"; import classNames from "classnames"; -import React, { Component, ReactNode } from "react"; import { Link } from "react-router-dom"; -import { HeaderProps } from "./HeaderProps"; -import { HeaderState } from "./HeaderState"; import Logo from "~assets/logo-header.svg?react"; +import { IDropdownRoute, IRoute } from "~/app/lib/interfaces"; import mainChrysalisMessage from "~assets/modals/chrysalis/search/main-header.json"; import mainLegacyMessage from "~assets/modals/legacy/search/main-header.json"; import mainStardustMessage from "~assets/modals/stardust/search/main-header.json"; import ShimmerLogo from "~assets/shimmer-logo-header.svg?react"; import { ServiceFactory } from "~factories/serviceFactory"; import { isMarketedNetwork, isShimmerUiTheme } from "~helpers/networkHelper"; -import { CHRYSALIS, LEGACY, STARDUST } from "~models/config/protocolVersion"; +import { CHRYSALIS, LEGACY, ProtocolVersion, STARDUST } from "~models/config/protocolVersion"; import { SettingsService } from "~services/settingsService"; import FiatSelector from "../FiatSelector"; -import "./Header.scss"; import Modal from "../Modal"; import NetworkSwitcher from "../NetworkSwitcher"; +import { INetwork } from "~/models/config/INetwork"; +import SearchInput from "../SearchInput"; +import HeaderDropdown from "./HeaderDropdown"; +import "./Header.scss"; -/** - * Component which will show the header. - */ -class Header extends Component { - /** - * Settings service. - */ - private readonly _settingsService: SettingsService; +const NETWORK_DROPDOWN_LABEL = "Network Switcher"; - /** - * Create a new instance of Header. - * @param props The props. - */ - constructor(props: HeaderProps) { - super(props); +const MODAL_MESSAGE: Record = { + [LEGACY]: mainLegacyMessage, + [CHRYSALIS]: mainChrysalisMessage, + [STARDUST]: mainStardustMessage, +}; - this._settingsService = ServiceFactory.get("settings"); +interface IHeader { + rootPath: string; + currentNetwork?: INetwork; + networks: INetwork[]; + history?: H.History; + action?: string; + protocolVersion: ProtocolVersion; + pages?: (IRoute | IDropdownRoute)[]; +} - this.state = { - isNetworkSwitcherExpanded: false, - isUtilitiesExpanded: false, - isMenuExpanded: false, - darkMode: this._settingsService.get().darkMode ?? false, - show: false, - }; - } +export default function Header({ rootPath, currentNetwork, networks, history, action, protocolVersion, pages: routes }: IHeader) { + const settingsService = ServiceFactory.get("settings"); + + const [isMenuExpanded, setIsMenuExpanded] = useState(false); + const [darkMode, setDarkMode] = useState(settingsService.get().darkMode ?? false); + const [show, setShow] = useState(false); + const [expandedDropdownLabel, setExpandedDropdownLabel] = useState(); + + const isNetworkSwitcherExpanded = expandedDropdownLabel === NETWORK_DROPDOWN_LABEL; + const isShimmerUi = isShimmerUiTheme(currentNetwork?.uiTheme); + const isMarketed = isMarketedNetwork(currentNetwork?.network); + + useEffect(() => { + toggleModeClass(); + }, []); /** - * The component mounted. + * Toggle the display mode. */ - public componentDidMount(): void { - if (this.state.darkMode) { - this.toggleModeClass(); + function toggleMode(): void { + setDarkMode((darkMode) => !darkMode); + settingsService.saveSingle("darkMode", darkMode); + const event = new CustomEvent("theme-change", { detail: { darkMode: darkMode } }); + window.dispatchEvent(event); + toggleModeClass(); + } + + function toggleModeClass(): void { + const body = document.querySelector("body"); + if (body) { + body.classList.toggle("darkmode", darkMode); } } - /** - * Render the component. - * @returns The node to render. - */ - public render(): ReactNode { - const { rootPath, currentNetwork, networks, history, action, search, utilities, pages } = this.props; - const isShimmerUi = isShimmerUiTheme(currentNetwork?.uiTheme); - const isMarketed = isMarketedNetwork(currentNetwork?.network); + function resetExpandedDropdowns(e?: React.MouseEvent): void { + setIsMenuExpanded(false); + closeDropdowns(); + } - return ( -
- +
+ ); } - -export default Header; diff --git a/client/src/app/components/header/HeaderDropdown.tsx b/client/src/app/components/header/HeaderDropdown.tsx new file mode 100644 index 000000000..7a2e6f116 --- /dev/null +++ b/client/src/app/components/header/HeaderDropdown.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { IDropdownRoute } from "~/app/lib/interfaces"; +import classNames from "classnames"; +import NavigationRouteHelper from "./NavigationRouteHelper"; + +interface INavigationDropdown extends IDropdownRoute { + isExpanded: boolean; + setExpandedDropdownId: (label?: string) => void; + setIsMenuExpanded?: (isExpanded: boolean) => void; +} + +interface IDropdownProps extends INavigationDropdown { + toggleDropdown: () => void; +} + +/** + * Dropdown component for header. + */ +export default function HeaderDropdown(props: INavigationDropdown & { mobileOnly?: boolean }): React.JSX.Element { + const { isExpanded, setExpandedDropdownId, mobileOnly, label } = props; + const DropdownComponent = mobileOnly ? MobileDropdown : DesktopDropdown; + + const toggleDropdown = (): void => setExpandedDropdownId(isExpanded ? undefined : label); + + return ; +} + +/** + * Dropdown component for desktop. + */ +const DesktopDropdown = ({ + label, + disabled, + routes, + isExpanded, + toggleDropdown, + setExpandedDropdownId, +}: IDropdownProps): React.JSX.Element => { + const closeDropdown = (e?: React.MouseEvent): void => setExpandedDropdownId(); + + return ( + <> + {!disabled && ( +
+
+
{label}
+
+ expand_more +
+
+ +
+
+
{label}
+ {routes + .filter(({ disabled }) => !disabled) + .map((route) => ( +
+ + {route.label} + +
+ ))} +
+
+ {isExpanded &&
} +
+ )} + + ); +}; + +/** + * Dropdown component for mobile. + */ +const MobileDropdown = ({ + label, + disabled, + routes, + isExpanded, + toggleDropdown, + setExpandedDropdownId, + setIsMenuExpanded, +}: IDropdownProps): React.JSX.Element => { + function handleRouteClick(e?: React.MouseEvent): void { + setExpandedDropdownId(); + setIsMenuExpanded?.(false); + } + + return ( + <> + {disabled && ( + <> +
  • +
    {label}
    +
    + expand_more +
    +
  • +
    + {routes + .filter(({ disabled }) => !disabled) + .map((route) => ( + +
  • + {route.label} +
  • +
    + ))} +
    + + )} + + ); +}; diff --git a/client/src/app/components/header/HeaderProps.ts b/client/src/app/components/header/HeaderProps.ts deleted file mode 100644 index 035e55794..000000000 --- a/client/src/app/components/header/HeaderProps.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as H from "history"; -import { ReactNode } from "react"; -import { INetwork } from "~models/config/INetwork"; - -/** - * The props for the Header component. - */ -export interface HeaderProps { - /** - * The root path. - */ - rootPath: string; - - /** - * The currently selected network. - */ - currentNetwork?: INetwork; - - /** - * The networks available. - */ - networks: INetwork[]; - - /** - * History for navigation. - */ - history?: H.History; - - /** - * Action for navigation. - */ - action?: string; - - /** - * The search elements to display as content. - */ - search?: ReactNode; - - /** - * Utilities menu - */ - utilities?: { - /** - * The label for the utility. - */ - label: string; - - /** - * The link for the utility. - */ - url: string; - }[]; - - /** - * Pages menu - */ - pages?: { - /** - * The label for the page. - */ - label: string; - - /** - * The link for the page. - */ - url: string; - }[]; -} diff --git a/client/src/app/components/header/HeaderState.ts b/client/src/app/components/header/HeaderState.ts deleted file mode 100644 index a03913efb..000000000 --- a/client/src/app/components/header/HeaderState.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The state for the Header component. - */ -export interface HeaderState { - /** - * Is the network switcher menu expanded. - */ - isNetworkSwitcherExpanded: boolean; - - /** - * Is the utilities menu expanded. - */ - isUtilitiesExpanded: boolean; - - /** - * Is the hamburger menu expanded. - */ - isMenuExpanded: boolean; - - /** - * Darkmode theme - */ - darkMode: boolean; - - /** - * Show info modal on full page. - */ - show: boolean; -} diff --git a/client/src/app/components/header/NavigationRouteHelper.tsx b/client/src/app/components/header/NavigationRouteHelper.tsx new file mode 100644 index 000000000..39ccc2128 --- /dev/null +++ b/client/src/app/components/header/NavigationRouteHelper.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { IRoute } from "~/app/lib/interfaces"; +import { Link } from "react-router-dom"; + +type InternalRouteProps = React.ComponentPropsWithoutRef; +type ExternalRouteProps = React.DetailedHTMLProps, HTMLAnchorElement>; + +type Route = (InternalRouteProps | ExternalRouteProps) & { route: IRoute }; + +export default function NavigationRouteHelper({ children, route, ...linkProps }: React.PropsWithChildren) { + if (route.isExternal) { + const externalProps: ExternalRouteProps = { ...linkProps, href: route.url, target: "_blank", rel: "noopener noreferrer" }; + return {children}; + } else { + const internalProps: InternalRouteProps = { ...linkProps, to: route.url }; + return {children}; + } +} diff --git a/client/src/app/lib/interfaces/index.ts b/client/src/app/lib/interfaces/index.ts new file mode 100644 index 000000000..204986b69 --- /dev/null +++ b/client/src/app/lib/interfaces/index.ts @@ -0,0 +1 @@ +export * from "./routes.interfaces"; diff --git a/client/src/app/lib/interfaces/routes.interfaces.ts b/client/src/app/lib/interfaces/routes.interfaces.ts new file mode 100644 index 000000000..63655211e --- /dev/null +++ b/client/src/app/lib/interfaces/routes.interfaces.ts @@ -0,0 +1,15 @@ +export interface IBaseNavigationRoute { + label: string; + disabled?: boolean; +} + +export interface IRoute extends IBaseNavigationRoute { + url: string; + isExternal?: boolean; +} + +export interface IDropdownRoute extends IBaseNavigationRoute { + routes: IRoute[]; +} + +export type NavigationRoute = IRoute | IDropdownRoute;