Add navbar and remove tabbed page layout

This commit is contained in:
Justin Starry 2020-08-01 23:32:25 +08:00 committed by Justin Starry
parent eab48c99d7
commit d4eb49d252
13 changed files with 116 additions and 601 deletions

View File

@ -1,59 +1,33 @@
import React from "react";
import { Link, Switch, Route, Redirect } from "react-router-dom";
import { Switch, Route, Redirect } from "react-router-dom";
import AccountsCard from "./components/AccountsCard";
import AccountDetails from "./components/AccountDetails";
import TransactionsCard from "./components/TransactionsCard";
import TransactionDetails from "./components/TransactionDetails";
import ClusterModal from "./components/ClusterModal";
import Logo from "./img/logos-solana/light-explorer-logo.svg";
import { TX_ALIASES } from "./providers/transactions";
import { ACCOUNT_ALIASES, ACCOUNT_ALIASES_PLURAL } from "./providers/accounts";
import TabbedPage from "components/TabbedPage";
import TopAccountsCard from "components/TopAccountsCard";
import SupplyCard from "components/SupplyCard";
import StatsCard from "components/StatsCard";
import { pickCluster } from "utils/url";
import Banner from "components/Banner";
import MessageBanner from "components/MessageBanner";
import Navbar from "components/Navbar";
import { ClusterStatusBanner } from "components/ClusterStatusButton";
function App() {
return (
<>
<ClusterModal />
<div className="main-content">
<nav className="navbar navbar-expand-xl navbar-light">
<div className="container">
<div className="row align-items-end">
<div className="col">
<Link
to={(location) => ({
...pickCluster(location),
pathname: "/",
})}
>
<img src={Logo} width="250" alt="Solana Explorer" />
</Link>
</div>
</div>
</div>
</nav>
<Banner />
<Navbar />
<MessageBanner />
<ClusterStatusBanner />
<Switch>
<Route exact path="/supply">
<TabbedPage tab="Supply">
<Route exact path={["/supply", "/accounts", "accounts/top"]}>
<div className="container mt-4">
<SupplyCard />
<TopAccountsCard />
</TabbedPage>
</div>
</Route>
<Route
exact
path="/accounts/top"
render={({ location }) => (
<Redirect to={{ ...location, pathname: "/supply" }} />
)}
></Route>
<Route
exact
path={TX_ALIASES.flatMap((tx) => [tx, tx + "s"]).map(
@ -63,11 +37,6 @@ function App() {
<TransactionDetails signature={match.params.signature} />
)}
/>
<Route exact path={TX_ALIASES.map((tx) => `/${tx}s`)}>
<TabbedPage tab="Transactions">
<TransactionsCard />
</TabbedPage>
</Route>
<Route
exact
path={ACCOUNT_ALIASES.concat(ACCOUNT_ALIASES_PLURAL).map(
@ -77,19 +46,16 @@ function App() {
<AccountDetails address={match.params.address} />
)}
/>
<Route
exact
path={ACCOUNT_ALIASES_PLURAL.map((alias) => "/" + alias)}
>
<TabbedPage tab="Accounts">
<AccountsCard />
</TabbedPage>
</Route>
<Route>
<TabbedPage tab="Stats">
<Route exact path="/">
<div className="container mt-4">
<StatsCard />
</TabbedPage>
</div>
</Route>
<Route
render={({ location }) => (
<Redirect to={{ ...location, pathname: "/" }} />
)}
/>
</Switch>
</div>
</>

View File

@ -1,8 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { useClusterModal } from "providers/cluster";
import { PublicKey, StakeProgram } from "@solana/web3.js";
import ClusterStatusButton from "components/ClusterStatusButton";
import { useHistory, useLocation } from "react-router-dom";
import {
FetchStatus,
@ -23,7 +21,6 @@ import { useFetchAccountHistory } from "providers/accounts/history";
type Props = { address: string };
export default function AccountDetails({ address }: Props) {
const fetchAccount = useFetchAccountInfo();
const [, setShow] = useClusterModal();
const [search, setSearch] = React.useState(address);
const history = useHistory();
const location = useLocation();
@ -61,34 +58,10 @@ export default function AccountDetails({ address }: Props) {
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center">
<div className="col">
<h6 className="header-pretitle">Details</h6>
<h3 className="header-title">Account</h3>
</div>
<div className="col-auto">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
<div className="row mb-4 mt-n2 align-items-center">
<div className="col d-none d-md-block">
<div className="input-group input-group-merge">
{searchInput}
<div className="input-group-prepend">
<div className="input-group-text">
<span className="fe fe-search"></span>
</div>
</div>
</div>
</div>
<div className="col d-block d-md-none">{searchInput}</div>
<div className="col-auto ml-n3 d-block d-md-none">
<button className="btn btn-white" onClick={updateAddress}>
<span className="fe fe-search"></span>
</button>
<h6 className="header-pretitle">Address</h6>
<h4 className="header-title text-monospace text-truncate font-weight-bold">
{address}
</h4>
</div>
</div>
{pubkey && <AccountCards pubkey={pubkey} />}

View File

@ -1,188 +0,0 @@
import React, { ReactNode } from "react";
import { Link } from "react-router-dom";
import {
useAccounts,
Account,
FetchStatus,
useFetchAccountInfo,
} from "../providers/accounts";
import { assertUnreachable } from "../utils";
import { displayAddress } from "../utils/tx";
import { PublicKey } from "@solana/web3.js";
import Copyable from "./Copyable";
import { lamportsToSolString } from "utils";
function AccountsCard() {
const { accounts, idCounter } = useAccounts();
const fetchAccountInfo = useFetchAccountInfo();
const addressInput = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState("");
const onNew = (address: string) => {
if (address.length === 0) return;
let pubkey;
try {
pubkey = new PublicKey(address);
} catch (err) {
setError(`${err}`);
return;
}
fetchAccountInfo(pubkey);
const inputEl = addressInput.current;
if (inputEl) {
inputEl.value = "";
}
};
return (
<div className="card">
{renderHeader()}
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">
<span className="fe fe-hash"></span>
</th>
<th className="text-muted">Status</th>
<th className="text-muted">Address</th>
<th className="text-muted">Balance (SOL)</th>
<th className="text-muted">Data (bytes)</th>
<th className="text-muted">Owner</th>
<th className="text-muted">Details</th>
</tr>
</thead>
<tbody className="list">
<tr>
<td>
<span className="badge badge-soft-dark badge-pill">
{idCounter + 1}
</span>
</td>
<td>
<span className={`badge badge-soft-dark`}>New</span>
</td>
<td>
<input
type="text"
onInput={() => setError("")}
onKeyDown={(e) =>
e.keyCode === 13 && onNew(e.currentTarget.value)
}
onSubmit={(e) => onNew(e.currentTarget.value)}
ref={addressInput}
className={`form-control text-address text-monospace ${
error ? "is-invalid" : ""
}`}
placeholder="input account address"
/>
{error ? <div className="invalid-feedback">{error}</div> : null}
</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td></td>
</tr>
{accounts.map((account) => renderAccountRow(account))}
</tbody>
</table>
</div>
</div>
);
}
const renderHeader = () => {
return (
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h4 className="card-header-title">Look Up Account(s)</h4>
</div>
</div>
</div>
);
};
const renderAccountRow = (account: Account) => {
let statusText;
let statusClass;
switch (account.status) {
case FetchStatus.FetchFailed:
statusClass = "dark";
statusText = "Cluster Error";
break;
case FetchStatus.Fetching:
statusClass = "info";
statusText = "Fetching";
break;
case FetchStatus.Fetched:
if (account.details?.executable) {
statusClass = "dark";
statusText = "Executable";
} else if (account.lamports) {
statusClass = "success";
statusText = "Found";
} else {
statusClass = "danger";
statusText = "Not Found";
}
break;
default:
return assertUnreachable(account.status);
}
let data = "-";
let owner = "-";
if (account.details) {
data = `${account.details.space}`;
owner = displayAddress(account.details.owner.toBase58());
}
let balance: ReactNode = "-";
if (account.lamports !== undefined) {
balance = lamportsToSolString(account.lamports);
}
const base58AccountPubkey = account.pubkey.toBase58();
return (
<tr key={account.id}>
<td>
<span className="badge badge-soft-dark badge-pill">{account.id}</span>
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>{statusText}</span>
</td>
<td>
<Copyable text={base58AccountPubkey}>
<code>{base58AccountPubkey}</code>
</Copyable>
</td>
<td>{balance}</td>
<td>{data}</td>
<td>
{owner === "-" ? (
owner
) : (
<Copyable text={owner}>
<code>{owner}</code>
</Copyable>
)}
</td>
<td>
<Link
to={(location) => ({
...location,
pathname: "/account/" + base58AccountPubkey,
})}
className="btn btn-rounded-circle btn-white btn-sm"
>
<span className="fe fe-arrow-right"></span>
</Link>
</td>
</tr>
);
};
export default AccountsCard;

View File

@ -1,30 +1,39 @@
import React from "react";
import { useCluster, ClusterStatus, Cluster } from "../providers/cluster";
import {
useCluster,
ClusterStatus,
Cluster,
useClusterModal,
} from "../providers/cluster";
export function ClusterStatusBanner() {
const [, setShow] = useClusterModal();
function ClusterStatusButton({
onClick,
expand,
}: {
onClick: () => void;
expand?: boolean;
}) {
return (
<div onClick={onClick}>
<Button expand={expand} />
<div className="container d-md-none my-4">
<div onClick={() => setShow(true)}>
<Button />
</div>
</div>
);
}
function Button({ expand }: { expand?: boolean }) {
export function ClusterStatusButton() {
const [, setShow] = useClusterModal();
return (
<div onClick={() => setShow(true)}>
<Button />
</div>
);
}
function Button() {
const { status, cluster, name, customUrl } = useCluster();
const statusName = cluster !== Cluster.Custom ? `${name}` : `${customUrl}`;
const btnClasses = (variant: string) => {
if (expand) {
return `btn lift d-block btn-${variant}`;
} else {
return `btn b-white lift btn-outline-${variant}`;
}
return `btn d-block btn-${variant}`;
};
const spinnerClasses = "spinner-grow spinner-grow-sm mr-2";
@ -59,5 +68,3 @@ function Button({ expand }: { expand?: boolean }) {
);
}
}
export default ClusterStatusButton;

View File

@ -0,0 +1,55 @@
import React from "react";
import Logo from "img/logos-solana/light-explorer-logo.svg";
import { Location } from "history";
import { pickCluster } from "utils/url";
import { Link, NavLink } from "react-router-dom";
import { ClusterStatusButton } from "components/ClusterStatusButton";
const clusterPath = (pathname: string) => {
return (location: Location) => ({
...pickCluster(location),
pathname,
});
};
export default function Navbar() {
// TODO: use `collapsing` to animate collapsible navbar
const [collapse, setCollapse] = React.useState(false);
return (
<nav className="navbar navbar-expand-md navbar-light">
<div className="container">
<Link to={clusterPath("/")}>
<img src={Logo} width="250" alt="Solana Explorer" />
</Link>
<button
className="navbar-toggler"
type="button"
onClick={() => setCollapse((value) => !value)}
>
<span className="navbar-toggler-icon"></span>
</button>
<div className={`collapse navbar-collapse ml-auto mr-4 ${collapse ? "show" : ""}`}>
<ul className="navbar-nav mr-auto">
<li className="nav-item">
<NavLink className="nav-link" to={clusterPath("/")} exact>
Cluster Status
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to={clusterPath("/supply")}>
Supply
</NavLink>
</li>
</ul>
</div>
<div className="d-none d-md-block">
<ClusterStatusButton />
</div>
</div>
</nav>
);
}

View File

@ -19,7 +19,7 @@ export default function StatsCard() {
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h4 className="card-header-title">Live Cluster Info</h4>
<h4 className="card-header-title">Live Cluster Status</h4>
</div>
</div>
</div>

View File

@ -32,9 +32,7 @@ export default function SupplyCard() {
<TableCardBody>
<tr>
<td className="w-100">Total Supply (SOL)</td>
<td className="text-right">
{lamportsToSolString(supply.total, 0)}
</td>
<td className="text-right">{lamportsToSolString(supply.total, 0)}</td>
</tr>
<tr>
@ -60,7 +58,7 @@ const renderHeader = () => {
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h4 className="card-header-title">Overview</h4>
<h4 className="card-header-title">Supply Overview</h4>
</div>
</div>
</div>

View File

@ -1,77 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
import { useClusterModal } from "providers/cluster";
import ClusterStatusButton from "components/ClusterStatusButton";
import { pickCluster } from "utils/url";
export type Tab = "Transactions" | "Accounts" | "Supply" | "Stats";
type Props = { children: React.ReactNode; tab: Tab };
export default function TabbedPage({ children, tab }: Props) {
const [, setShow] = useClusterModal();
return (
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center d-md-none">
<div className="col-12">
<ClusterStatusButton expand onClick={() => setShow(true)} />
</div>
</div>
<div className="row align-items-center">
<div className="col">
<ul className="nav nav-tabs nav-overflow header-tabs">
<li className="nav-item">
<NavLink href="/" tab="Stats" current={tab} />
</li>
<li className="nav-item">
<NavLink
href="/transactions"
tab="Transactions"
current={tab}
/>
</li>
<li className="nav-item">
<NavLink href="/accounts" tab="Accounts" current={tab} />
</li>
<li className="nav-item">
<NavLink href="/supply" tab="Supply" current={tab} />
</li>
</ul>
</div>
<div className="col-auto d-none d-md-block">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
{children}
</div>
);
}
function NavLink({
href,
tab,
current,
}: {
href: string;
tab: Tab;
current: Tab;
}) {
let classes = "nav-link";
if (tab === current) {
classes += " active";
}
return (
<Link
to={(location) => ({ ...pickCluster(location), pathname: href })}
className={classes}
>
{tab}
</Link>
);
}

View File

@ -124,7 +124,10 @@ const renderAccountRow = (
</Copyable>
</td>
<td className="text-right">{lamportsToSolString(account.lamports, 0)}</td>
<td className="text-center">{`${((100 * account.lamports) / supply).toFixed(3)}%`}</td>
<td className="text-center">{`${(
(100 * account.lamports) /
supply
).toFixed(3)}%`}</td>
<td>
<Link
to={(location) => ({

View File

@ -6,18 +6,16 @@ import {
FetchStatus,
} from "../providers/transactions";
import { useFetchTransactionDetails } from "providers/transactions/details";
import { useCluster, useClusterModal } from "providers/cluster";
import { useCluster } from "providers/cluster";
import {
TransactionSignature,
SystemProgram,
StakeProgram,
SystemInstruction,
} from "@solana/web3.js";
import ClusterStatusButton from "components/ClusterStatusButton";
import { lamportsToSolString } from "utils";
import { displayAddress } from "utils/tx";
import Copyable from "./Copyable";
import { useHistory, useLocation } from "react-router-dom";
import { UnknownDetailsCard } from "./instruction/UnknownDetailsCard";
import { SystemDetailsCard } from "./instruction/system/SystemDetailsCard";
import { StakeDetailsCard } from "./instruction/stake/StakeDetailsCard";
@ -31,63 +29,20 @@ import { isCached } from "providers/transactions/cached";
type Props = { signature: TransactionSignature };
export default function TransactionDetails({ signature }: Props) {
const fetchTransaction = useFetchTransactionStatus();
const [, setShow] = useClusterModal();
const [search, setSearch] = React.useState(signature);
const history = useHistory();
const location = useLocation();
const updateSignature = () => {
history.push({ ...location, pathname: "/tx/" + search });
};
// Fetch transaction on load
React.useEffect(() => {
fetchTransaction(signature);
}, [signature]); // eslint-disable-line react-hooks/exhaustive-deps
const searchInput = (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyUp={(e) => e.key === "Enter" && updateSignature()}
className="form-control form-control-prepended search text-monospace"
placeholder="Search for signature"
/>
);
return (
<div className="container">
<div className="header">
<div className="header-body">
<div className="row align-items-center">
<div className="col">
<h6 className="header-pretitle">Details</h6>
<h3 className="header-title">Transaction</h3>
</div>
<div className="col-auto">
<ClusterStatusButton onClick={() => setShow(true)} />
</div>
</div>
</div>
</div>
<div className="row mb-4 mt-n2 align-items-center">
<div className="col d-none d-md-block">
<div className="input-group input-group-merge">
{searchInput}
<div className="input-group-prepend">
<div className="input-group-text">
<span className="fe fe-search"></span>
</div>
</div>
</div>
</div>
<div className="col d-block d-md-none">{searchInput}</div>
<div className="col-auto ml-n3 d-block d-md-none">
<button className="btn btn-white" onClick={updateSignature}>
<span className="fe fe-search"></span>
</button>
<h6 className="header-pretitle">Transaction</h6>
<h4 className="header-title text-monospace text-truncate font-weight-bold">
{signature}
</h4>
</div>
</div>

View File

@ -1,177 +0,0 @@
import React from "react";
import { Link } from "react-router-dom";
import {
useTransactions,
TransactionStatus,
FetchStatus,
useFetchTransactionStatus,
} from "../providers/transactions";
import bs58 from "bs58";
import { assertUnreachable } from "../utils";
import Copyable from "./Copyable";
function TransactionsCard() {
const { transactions, idCounter } = useTransactions();
const fetchTransaction = useFetchTransactionStatus();
const signatureInput = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState("");
const onNew = (signature: string) => {
if (signature.length === 0) return;
try {
const length = bs58.decode(signature).length;
if (length > 64) {
setError("Signature is too long");
return;
} else if (length < 64) {
setError("Signature is too short");
return;
}
} catch (err) {
setError(`${err}`);
return;
}
fetchTransaction(signature);
const inputEl = signatureInput.current;
if (inputEl) {
inputEl.value = "";
}
};
return (
<div className="card">
{renderHeader()}
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">
<span className="fe fe-hash"></span>
</th>
<th className="text-muted">Status</th>
<th className="text-muted">Signature</th>
<th className="text-muted">Confirmations</th>
<th className="text-muted">Slot Number</th>
<th className="text-muted">Details</th>
</tr>
</thead>
<tbody className="list">
<tr>
<td>
<span className="badge badge-soft-dark badge-pill">
{idCounter + 1}
</span>
</td>
<td>
<span className={`badge badge-soft-dark`}>New</span>
</td>
<td>
<input
type="text"
onInput={() => setError("")}
onKeyDown={(e) =>
e.keyCode === 13 && onNew(e.currentTarget.value)
}
onSubmit={(e) => onNew(e.currentTarget.value)}
ref={signatureInput}
className={`form-control text-signature text-monospace ${
error ? "is-invalid" : ""
}`}
placeholder="input transaction signature"
/>
{error ? <div className="invalid-feedback">{error}</div> : null}
</td>
<td>-</td>
<td>-</td>
<td></td>
</tr>
{transactions.map((transaction) =>
renderTransactionRow(transaction)
)}
</tbody>
</table>
</div>
</div>
);
}
const renderHeader = () => {
return (
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h4 className="card-header-title">Look Up Transaction(s)</h4>
</div>
</div>
</div>
);
};
const renderTransactionRow = (transactionStatus: TransactionStatus) => {
const { fetchStatus, info, signature, id } = transactionStatus;
let statusText;
let statusClass;
switch (fetchStatus) {
case FetchStatus.FetchFailed:
statusClass = "dark";
statusText = "Cluster Error";
break;
case FetchStatus.Fetching:
statusClass = "info";
statusText = "Fetching";
break;
case FetchStatus.Fetched: {
if (!info) {
statusClass = "warning";
statusText = "Not Found";
} else if (info.result.err) {
statusClass = "danger";
statusText = "Failed";
} else {
statusClass = "success";
statusText = "Success";
}
break;
}
default:
return assertUnreachable(fetchStatus);
}
let slotText = "-";
let confirmationsText = "-";
if (info) {
slotText = `${info.slot}`;
confirmationsText = `${info.confirmations}`;
}
return (
<tr key={signature}>
<td>
<span className="badge badge-soft-dark badge-pill">{id}</span>
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>{statusText}</span>
</td>
<td>
<Copyable text={signature}>
<code>{signature}</code>
</Copyable>
</td>
<td className="text-uppercase">{confirmationsText}</td>
<td>{slotText}</td>
<td>
<Link
to={(location) => ({ ...location, pathname: "/tx/" + signature })}
className="btn btn-rounded-circle btn-white btn-sm"
>
<span className="fe fe-arrow-right"></span>
</Link>
</td>
</tr>
);
};
export default TransactionsCard;

View File

@ -31,10 +31,10 @@ $rainbow-3: #79abd2;
$rainbow-4: #38d0bd;
$rainbow-5: #1dd79b;
$primary: #65D39F;
$success: #42ba96;
$primary: $success;
$primary-desat: #42ba96;
$secondary: $gray-700;
$success: #42ba96;
$info: #b45be1;
$info-muted: #9272a3;
$warning: #d83aeb;