Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 8 additions & 27 deletions app/components/ClusterStatusButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,14 @@

import { useCluster, useClusterModal } from '@providers/cluster';
import { Cluster, ClusterStatus } from '@utils/cluster';
import React from 'react';
import React, { useCallback } from 'react';
import { AlertCircle, CheckCircle } from 'react-feather';

export function ClusterStatusBanner() {
const [, setShow] = useClusterModal();

return (
<div className="container d-md-none my-4">
<div onClick={() => setShow(true)}>
<Button />
</div>
</div>
);
}

export function ClusterStatusButton() {
export const ClusterStatusButton = () => {
const { status, cluster, name, customUrl } = useCluster();
const [, setShow] = useClusterModal();

return (
<div onClick={() => setShow(true)}>
<Button />
</div>
);
}

function Button() {
const { status, cluster, name, customUrl } = useCluster();
const onClickHandler = useCallback(() => setShow(true), [setShow]);
const statusName = cluster !== Cluster.Custom ? `${name}` : `${customUrl}`;

const btnClasses = (variant: string) => {
Expand All @@ -40,26 +21,26 @@ function Button() {
switch (status) {
case ClusterStatus.Connected:
return (
<span className={btnClasses('primary')}>
<span className={btnClasses('primary')} onClick={onClickHandler}>
<CheckCircle className="fe me-2" size={15} />
{statusName}
</span>
);

case ClusterStatus.Connecting:
return (
<span className={btnClasses('warning')}>
<span className={btnClasses('warning')} onClick={onClickHandler}>
<span className={spinnerClasses} role="status" aria-hidden="true"></span>
{statusName}
</span>
);

case ClusterStatus.Failure:
return (
<span className={btnClasses('danger')}>
<span className={btnClasses('danger')} onClick={onClickHandler}>
<AlertCircle className="me-2" size={15} />
{statusName}
</span>
);
}
}
};
28 changes: 18 additions & 10 deletions app/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
'use client';

import Logo from '@img/logos-solana/dark-explorer-logo.svg';
import { useDisclosure } from '@mantine/hooks';
import { useClusterPath } from '@utils/url';
import Image from 'next/image';
import Link from 'next/link';
import { useSelectedLayoutSegment, useSelectedLayoutSegments } from 'next/navigation';
import React from 'react';
import React, { ReactNode } from 'react';

import { ClusterStatusButton } from './ClusterStatusButton';

export function Navbar() {
// TODO: use `collapsing` to animate collapsible navbar
const [collapse, setCollapse] = React.useState(false);
export interface INavbarProps {
children?: ReactNode;
}

export function Navbar({ children }: INavbarProps) {
const [navOpened, navHandlers] = useDisclosure(false);
const homePath = useClusterPath({ pathname: '/' });
const supplyPath = useClusterPath({ pathname: '/supply' });
const inspectorPath = useClusterPath({ pathname: '/tx/inspector' });
const selectedLayoutSegment = useSelectedLayoutSegment();
const selectedLayoutSegments = useSelectedLayoutSegments();
return (
<nav className="navbar navbar-expand-md navbar-light">
<div className="container">
<nav className="navbar navbar-expand-lg navbar-light">
<div className="container px-4">
<Link href={homePath}>
<Image alt="Solana Explorer" height={22} src={Logo} width={250} />
<Image alt="Solana Explorer" height={22} src={Logo} width={214} />
</Link>

<button className="navbar-toggler" type="button" onClick={() => setCollapse(value => !value)}>
<button className="navbar-toggler" type="button" onClick={navHandlers.toggle}>
<span className="navbar-toggler-icon"></span>
</button>

<div className={`collapse navbar-collapse ms-auto me-4 ${collapse ? 'show' : ''}`}>
<div className="navbar-children d-flex align-items-center flex-grow-1 w-100 h-100 d-none d-lg-block">
{children}
</div>

<div className={`collapse navbar-collapse ms-auto me-4 ${navOpened ? 'show' : ''} flex-shrink-0`}>
<ul className="navbar-nav me-auto">
<li className="nav-item">
<Link
Expand Down Expand Up @@ -61,7 +69,7 @@ export function Navbar() {
</ul>
</div>

<div className="d-none d-md-block">
<div className="d-none d-lg-block flex-shrink-0">
<ClusterStatusButton />
</div>
</div>
Expand Down
146 changes: 97 additions & 49 deletions app/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,51 @@
'use client';

import { useHotkeys } from '@mantine/hooks';
import { useCluster } from '@providers/cluster';
import { VersionedMessage } from '@solana/web3.js';
import { Cluster } from '@utils/cluster';
import bs58 from 'bs58';
import { useRouter, useSearchParams } from 'next/navigation';
import React, { useId } from 'react';
import { Search } from 'react-feather';
import { ActionMeta, InputActionMeta, ValueType } from 'react-select';
import React, { LegacyRef, MouseEvent, MouseEventHandler, useCallback, useId, useMemo, useRef } from 'react';
import { Search, X } from 'react-feather';
import { ActionMeta, components, ControlProps, GroupBase, InputActionMeta } from 'react-select';
import AsyncSelect from 'react-select/async';
import Select from 'react-select/dist/declarations/src/Select';

import { FetchedDomainInfo } from '../api/domain-info/[domain]/route';
import { LOADER_IDS, LoaderName, PROGRAM_INFO_BY_ID, SPECIAL_IDS, SYSVAR_IDS } from '../utils/programs';
import { searchTokens } from '../utils/token-search';
import { MIN_MESSAGE_LENGTH } from './inspector/RawInputCard';

interface SearchOption {
label: string;
value: string[];
pathname: string;
}
interface SearchOptions {
label: string;
options: {
label: string;
value: string[];
pathname: string;
}[];
options: SearchOption[];
}

const hasDomainSyntax = (value: string) => {
return value.length > 3 && value.split('.').length === 2;
};

const RESET_VALUE = '' as any;

export function SearchBar() {
const [search, setSearch] = React.useState('');
const selectRef = React.useRef<AsyncSelect<any> | null>(null);
const router = useRouter();
const { cluster, clusterInfo } = useCluster();
const searchParams = useSearchParams();
const onChange = ({ pathname }: ValueType<any, false>, meta: ActionMeta<any>) => {
const selectRef = useRef<Select<any, false, GroupBase<SearchOption>>>();

const onChange = (option: SearchOption, meta: ActionMeta<any>) => {
if (option === null || typeof option?.pathname !== 'string') {
setSearch('');
return;
}
const { pathname } = option;
if (meta.action === 'select-option') {
// Always use the pathname directly if it contains query params
if (pathname.includes('?')) {
Expand All @@ -48,11 +59,11 @@ export function SearchBar() {
}
};

const onInputChange = (value: string, { action }: InputActionMeta) => {
const onInputChange = useCallback((value: string, { action }: InputActionMeta) => {
if (action === 'input-change') {
setSearch(value);
}
};
}, []);

async function performSearch(search: string): Promise<SearchOptions[]> {
const localOptions = buildOptions(search, cluster, clusterInfo?.epochInfo.epoch);
Expand All @@ -69,41 +80,72 @@ export function SearchBar() {
return [...localOptions, ...tokenOptionsAppendable, ...domainOptions];
}

const resetValue = '' as any;
// Substitute control component to insert custom clear button (the built in clear button only works with selected option, which is not the case)
const Control = useMemo(
() =>
function ControlSubstitute({ children, ...props }: ControlProps<SearchOption, false>) {
const onClearHandler = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setSearch('');
selectRef.current?.clearValue();
selectRef.current?.blur();
}, []);
const hasValue = Boolean(selectRef.current?.inputRef?.value);

return (
<components.Control {...props}>
<Search className="me-3" size={15} />
{children}
{hasValue ? <ClearIndicator onClick={onClearHandler} /> : <KeyIndicator />}
</components.Control>
);
},
[]
);

const onHotKeyPressHandler = useCallback(() => {
selectRef.current?.focus();
}, []);

// Focus search on hotkey press
useHotkeys(
[
['/', onHotKeyPressHandler],
['mod+k', onHotKeyPressHandler],
],
['INPUT', 'TEXTAREA']
);

const noOptionsMessageHandler = useCallback(() => 'No Results', []);
const loadingMessageHandler = useCallback(() => 'loading...', []);
const id = useId();

return (
<div className="container my-4">
<div className="row align-items-center">
<div className="col">
<AsyncSelect
cacheOptions
defaultOptions
loadOptions={performSearch}
autoFocus
inputId={useId()}
ref={selectRef}
noOptionsMessage={() => 'No Results'}
loadingMessage={() => 'loading...'}
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
value={resetValue}
inputValue={search}
blurInputOnSelect
onMenuClose={() => selectRef.current?.blur()}
onChange={onChange}
styles={{
input: style => ({ ...style, width: '100%' }),
/* work around for https://github.com/JedWatson/react-select/issues/3857 */
placeholder: style => ({ ...style, pointerEvents: 'none' }),
}}
onInputChange={onInputChange}
components={{ DropdownIndicator }}
classNamePrefix="search-bar"
/* workaround for https://github.com/JedWatson/react-select/issues/5714 */
onFocus={() => {
selectRef.current?.handleInputChange(search, { action: 'set-value' });
}}
/>
</div>
</div>
<div className="w-100">
<AsyncSelect
cacheOptions
defaultOptions
loadOptions={performSearch}
autoFocus
ref={selectRef as LegacyRef<Select<any, false, GroupBase<SearchOption>>>}
inputId={id}
noOptionsMessage={noOptionsMessageHandler}
loadingMessage={loadingMessageHandler}
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
value={RESET_VALUE}
inputValue={search}
blurInputOnSelect
onChange={onChange}
styles={{
input: style => ({ ...style, width: '100%' }),
/* work around for https://github.com/JedWatson/react-select/issues/3857 */
placeholder: style => ({ ...style, pointerEvents: 'none' }),
}}
onInputChange={onInputChange}
components={{ Control, DropdownIndicator: undefined, IndicatorSeparator: undefined }}
classNamePrefix="search-bar"
/>
</div>
);
}
Expand Down Expand Up @@ -395,10 +437,16 @@ function isValidBase64(str: string): boolean {
}
}

function DropdownIndicator() {
function KeyIndicator() {
return <div className="key-indicator">/</div>;
}

function ClearIndicator({ onClick }: { onClick: MouseEventHandler<HTMLDivElement> }) {
return (
<div className="search-indicator">
<Search className="me-2" size={15} />
<div className="clear-indicator" onClick={onClick}>
<X size={16} />
</div>
);
}

export default SearchBar;
Loading