Compare commits

...

22 Commits

Author SHA1 Message Date
Yana 7eddb12cbe wip 2024-12-06 15:22:52 +07:00
Yana e3fdc9aa12 wip 2024-12-05 21:21:59 +07:00
Yana 4e7ece13c5 wip 2024-12-05 17:39:20 +07:00
Yana 38f2a052d2 Add styling to account stats card 2024-12-05 16:26:30 +07:00
Yana 565e9c5a40 Add account stats card, mobile view 2024-12-04 22:16:53 +07:00
Yana b6465836d8 Add account stats card, desktop view 2024-12-04 19:56:08 +07:00
Yana 40916f77da wip 2024-12-03 16:11:38 +07:00
Yana 492fe16e55 wip 2024-12-02 22:18:53 +07:00
Yana cf3baf9398 Add chat to the card 2024-12-01 21:05:24 +07:00
Yana c3f462c34d add styles 2024-11-29 21:45:36 +07:00
Yana b95a026ddd add ratings 2024-11-29 21:17:24 +07:00
Yana 48a38b6bf3 Add qr code 2024-11-29 20:39:24 +07:00
Yana 57e6fa29db Add copy to clipboard 2024-11-29 20:06:09 +07:00
Yana 1856ac95c6 Add profile image and country 2024-11-29 19:12:13 +07:00
Yana bc6d4562d0 WIP 2024-11-28 22:19:31 +07:00
Yana 4887cbbd48 WIP 2024-11-27 22:34:49 +07:00
Yana 3c44ae89da WIP 2024-11-27 15:41:20 +07:00
Yana a9c9381cb6 WIP 2024-11-26 21:44:37 +07:00
Yana fbd8cc5b4d Add ExplorerCard 2024-11-24 21:25:23 +07:00
Yana 399e4b1abd WIP Explorer Card 2024-11-20 20:01:36 +07:00
Yana fd62ee8204 Add remark42 to Mixnode page 2024-11-04 20:25:41 +07:00
Yana 147ec12a28 WIP on yana/remark42 2024-10-31 17:46:04 +02:00
49 changed files with 6595 additions and 4262 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+50
View File
@@ -0,0 +1,50 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
Starting Docker:
```bash
docker-compose pull
docker-compose up -d
```
Stopping Docker:
```bash
docker-compose down
```
First, run the development server:
```bash
npm run dev -- -p 8080
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:8080/](http://localhost:8080/) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -0,0 +1,43 @@
version: "2"
services:
remark:
# remove the next line in case you want to use this Docker Compose file separately
# as otherwise it would complain for absence of Dockerfile
build: .
image: umputun/remark42:latest
container_name: "remark42"
hostname: "remark42"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
# uncomment to expose directly (no proxy)
ports:
- "8081:8080"
- "443:8443"
environment:
- REMARK_URL=http://localhost:8081
- SECRET=secret-key
- AUTH_ANON=true
- SITE=remark42
# - DEBUG=true
# - AUTH_GOOGLE_CID
# - AUTH_GOOGLE_CSEC
# - AUTH_GITHUB_CID
# - AUTH_GITHUB_CSEC
# - AUTH_FACEBOOK_CID
# - AUTH_FACEBOOK_CSEC
# - AUTH_DISQUS_CID
# - AUTH_DISQUS_CSEC
# Enable it only for the initial comment import or for manual backups.
# Do not leave the server running with the ADMIN_PASSWD set if you don't have an intention
# to keep creating backups manually!
- ADMIN_PASSWD=password
volumes:
- ./var:/srv/var
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+24
View File
@@ -0,0 +1,24 @@
{
"name": "remark42-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "19.0.0-rc-02c0e824-20241028",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"next": "15.0.2"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.2"
}
}
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
@@ -0,0 +1,42 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}
@@ -0,0 +1,168 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: none;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}
@@ -0,0 +1,57 @@
import Head from "next/head";
import Script from "next/script";
import { Box } from "@mui/material";
export default function Home() {
return (
<div>
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Box
display={"flex"}
flexDirection={"column"}
maxWidth={"60%"}
margin={"50px auto"}
>
<div>NymNode X</div>
<div>Info about NymNode X</div>
<div id="remark42"></div>
{/* Configuration Script */}
<Script
id="remark-config"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
var remark_config = {
host: 'http://localhost:8081', // Updated to match the REMARK_URL
site_id: 'remark42',
components: ['embed', 'last-comments'],
max_shown_comments: 100,
theme: 'light',
page_title: 'My custom title for a page',
locale: 'en',
show_email_subscription: false,
simple_view: true,
no_footer: true
};
`,
}}
/>
{/* Initialization Script */}
<Script
id="remark-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);
`,
}}
/>
</Box>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.
Binary file not shown.
+190
View File
@@ -0,0 +1,190 @@
import type { NextApiRequest, NextApiResponse } from "next";
import {
EXPLORER_API,
COSMOS_API,
VALIDATOR_API_EPOCH,
VALIDATOR_API_SUPPLY,
HARBOURMASTER_API_SUMMARY,
HARBOURMASTER_API_MIXNODES_STATS,
HARBOURMASTER_API_BASE,
CURRENT_EPOCH,
CURRENT_EPOCH_REWARDS,
CIRCULATING_NYM_SUPPLY,
} from "../urls";
export interface ExplorerData {
circulatingNymSupplyData: any;
nymNodesData: any;
packetsAndStakingData: any;
currentEpochData: any;
currentEpochRewardsData: any;
}
export interface ExplorerCache {
data?: ExplorerData;
lastUpdated?: Date;
}
declare global {
// Extend the global object with our custom property
var explorerCache: ExplorerCache | undefined;
}
const CACHE_TIME_SECONDS = 60 * 5; // 5 minutes
const getExplorerData = async () => {
// FETCH NYMNODES
const fetchNymNodes = await fetch(HARBOURMASTER_API_SUMMARY, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CURRENT EPOCH
const fetchCurrentEpoch = await fetch(CURRENT_EPOCH, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CURRENT EPOCH REWARDS
const fetchCurrentEpochRewards = await fetch(CURRENT_EPOCH_REWARDS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH CIRCULATING NYM SUPPLY
const fetchCirculatingNymSupply = await fetch(CIRCULATING_NYM_SUPPLY, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
// FETCH PACKETS AND STAKING
const fetchPacketsAndStaking = await fetch(HARBOURMASTER_API_MIXNODES_STATS, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const [
circulatingNymSupplyRes,
nymNodesRes,
packetsAndStakingRes,
currentEpochRes,
currentEpochRewardsRes,
] = await Promise.all([
fetchCirculatingNymSupply,
fetchNymNodes,
fetchPacketsAndStaking,
fetchCurrentEpoch,
fetchCurrentEpochRewards,
]);
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
] = await Promise.all([
circulatingNymSupplyRes.json(),
nymNodesRes.json(),
packetsAndStakingRes.json(),
currentEpochRes.json(),
currentEpochRewardsRes.json(),
]);
return [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
];
};
export async function ensureCacheExists() {
// makes sure the cache exists in global memory
let doUpdate = false;
const now = new Date();
if (!global.explorerCache) {
global.explorerCache = {};
doUpdate = true;
}
if (
global.explorerCache.lastUpdated &&
now.getDate() - global.explorerCache.lastUpdated.getDate() >
CACHE_TIME_SECONDS
) {
doUpdate = true;
}
// if the cache has expired or never existed, get it from API's
if (doUpdate) {
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
] = await getExplorerData();
packetsAndStakingData.pop();
global.explorerCache.data = {
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
currentEpochData,
currentEpochRewardsData,
};
global.explorerCache.lastUpdated = now;
}
}
export async function getCacheExplorerData() {
await ensureCacheExists();
if (!global.explorerCache?.data) {
return null;
}
return global.explorerCache.data || null;
}
/**
* This is a custom API route that returns metadata from Strapi about images: height, width, strapi download url.
*
* The response from Strapi is cached in memory for CACHE_TIME_SECONDS.
*/
// export default async function handler(
// req: NextApiRequest,
// res: NextApiResponse
// ) {
// // return cached data
// const data = await getCacheExplorerData();
// if (data) {
// res.status(200).json(data);
// res.end();
// }
// // catch-all
// res.status(404).end();
// }
+18
View File
@@ -0,0 +1,18 @@
export const HARBOURMASTER_API_SUMMARY =
"https://harbourmaster.nymtech.net/v2/summary";
export const EXPLORER_API = "https://explorer.nymtech.net/api/v1/countries";
export const VALIDATOR_API_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
export const COSMOS_API =
"https://api.nymtech.net/cosmos/bank/v1beta1/balances/n1a53udazy8ayufvy0s434pfwjcedzqv34yg485t";
export const VALIDATOR_API_EPOCH =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const HARBOURMASTER_API_MIXNODES_STATS =
"https://harbourmaster.nymtech.net/v2/mixnodes/stats";
export const HARBOURMASTER_API_BASE = "https://harbourmaster.nymtech.net";
export const CURRENT_EPOCH =
" https://validator.nymtech.net/api/v1/epoch/current";
export const CURRENT_EPOCH_REWARDS =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const CIRCULATING_NYM_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
@@ -0,0 +1,307 @@
import * as React from "react";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Card, CardContent } from "@mui/material";
import { ExplorerStaticProgressBar } from "./ExplorerStaticProgressBar";
import { MultiSegmentProgressBar } from "./ExplorerMultiSegmentProgressBar";
import useMediaQuery from "@mui/material/useMediaQuery";
import CircleIcon from "@mui/icons-material/Circle";
export interface IAccontStatsRowProps {
type: string;
allocation: number;
amount: number;
value: number;
history?: { type: string; amount: number }[];
isLastRow?: boolean;
progressBarColor?: string;
}
const progressBarColours = [
"#BEF885",
"#7FB0FF",
"#00D17D",
"#004650",
"#FEECB3",
];
const TABLET_WIDTH = "(min-width:700px)";
const Row = (props: IAccontStatsRowProps) => {
const tablet = useMediaQuery(TABLET_WIDTH);
const {
type,
allocation,
amount,
value,
history,
isLastRow,
progressBarColor,
} = props;
const [open, setOpen] = React.useState(false);
return (
<React.Fragment>
{/* Main Row */}
{tablet ? (
<TableRow>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "25%",
}}
>
<Typography>{type}</Typography>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "25%",
}}
>
<Box>
<Typography>{allocation}%</Typography>
<ExplorerStaticProgressBar
value={allocation}
color={progressBarColor || "green"}
/>
</Box>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "20%",
}}
>
{amount} NYM
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "20%",
}}
>
$ {value}
</TableCell>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "10%",
}}
>
{history && (
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
) : (
// MOBILE VIEW
<TableRow>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "45%",
}}
>
<Box display={"flex"} gap={1} alignItems={"center"}>
<CircleIcon sx={{ color: progressBarColor }} fontSize="small" />
{type}
</Box>
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "45%",
}}
>
<Typography>{amount} NYM</Typography>
<Typography>$ {value}</Typography>
</TableCell>
<TableCell
sx={{
borderBottom: isLastRow
? "none"
: "1px solid rgba(224, 224, 224, 1)",
width: "10%",
}}
>
{history && (
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
)}
</TableCell>
</TableRow>
)}
{/* History Rows */}
{history &&
open &&
history.map((historyRow, i) => (
<TableRow key={i}>
<TableCell
sx={{
display: "flex",
alignItems: "center",
pl: 5,
borderBottom: "none", // Explicitly remove border
}}
>
<span style={{ marginRight: 8 }}></span>
{historyRow.type}
</TableCell>
<TableCell
align="right"
sx={{
borderBottom: "none", // Explicitly remove border
}}
>
{historyRow.amount}
</TableCell>
<TableCell
sx={{
borderBottom: "none", // Explicitly remove border
}}
>
{/* Any additional content */}
</TableCell>
</TableRow>
))}
</React.Fragment>
);
};
export interface IAccountStatsCardProps {
rows: Array<IAccontStatsRowProps>;
overTitle?: string;
priceTitle?: number;
}
export const AccountStatsCard = (props: IAccountStatsCardProps) => {
const { rows, overTitle, priceTitle } = props;
const tablet = useMediaQuery(TABLET_WIDTH);
const progressBarPercentages = () => {
return rows.map((row, i) => row.allocation);
};
const getProgressValues = () => {
const percentages = progressBarPercentages();
const result: Array<{ percentage: number; color: string }> = [];
percentages.map((value, i) => {
result.push({
percentage: value,
color: progressBarColours[i],
});
});
return result;
};
const progressValues = getProgressValues();
return (
<Card sx={{ height: "100%", borderRadius: "unset" }}>
<CardContent>
{overTitle && (
<Typography fontSize={14} mb={3} textTransform={"uppercase"}>
{overTitle}
</Typography>
)}
{priceTitle && (
<Typography fontSize={24} mb={3}>
${priceTitle}
</Typography>
)}
{!tablet && <MultiSegmentProgressBar values={progressValues} />}
<TableContainer>
<Table aria-label="collapsible table" sx={{ marginBottom: 3 }}>
<TableHead>
{tablet ? (
<TableRow>
<TableCell>
<Typography textTransform={"uppercase"}>Type</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>
Allocation
</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>Amount</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>Value</Typography>
</TableCell>
<TableCell></TableCell>
</TableRow>
) : (
<TableRow>
<TableCell>
<Typography textTransform={"uppercase"}>Type</Typography>
</TableCell>
<TableCell align="right">
<Typography textTransform={"uppercase"}>
Amount / Value
</Typography>
</TableCell>
<TableCell></TableCell>
</TableRow>
)}
</TableHead>
<TableBody>
{rows.map((row, i) => (
<Row
key={i}
{...row}
isLastRow={i === rows.length - 1}
progressBarColor={progressBarColours[i]}
/>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
@@ -0,0 +1,391 @@
import { Card, CardContent, Typography, Box, Button } from "@mui/material";
import React, { FC, ReactElement, ReactEventHandler, useEffect } from "react";
import { ExplorerLineChart, IExplorerLineChartData } from "./ExplorerLineChart";
import {
ExplorerDynamicProgressBar,
IExplorerDynamicProgressBarProps,
} from "./ExplorerDynamicProgressBar";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import { NymTokenSVG } from "../icons/NymTokenSVG";
import { CopyToClipboard } from "@nymproject/react/clipboard/CopyToClipboard";
import Image from "next/image";
import profileImagePlaceholder from "../../public/profileImagePlaceholder.png";
import Flag from "react-world-flags";
import { QRCodeCanvas } from "qrcode.react";
import StarIcon from "@mui/icons-material/Star";
import Script from "next/script";
import { useMainContext } from "../context/main";
declare global {
interface Window {
remark_config: {
host: string;
site_id: string;
components: string[];
max_shown_comments: number;
theme: string;
locale: string;
show_email_subscription: boolean;
simple_view: boolean;
no_footer: boolean;
};
REMARK42: {
createInstance: (config: typeof window.remark_config) => void;
changeTheme: (theme: "light" | "dark") => void;
};
}
}
interface ICardUpDownPriceLineProps {
percentage: number;
numberWentUp: boolean;
}
const CardUpDownPriceLine = (
props: ICardUpDownPriceLineProps
): ReactElement => {
const { percentage, numberWentUp } = props;
return (
<Box mb={3} display={"flex"}>
{numberWentUp ? (
<ArrowUpwardIcon sx={{ color: "#00CA33" }} fontSize="small" />
) : (
<ArrowDownwardIcon sx={{ color: "#DF1400" }} fontSize="small" />
)}
<Typography sx={{ color: numberWentUp ? "#00CA33" : "#DF1400" }}>
{percentage}% (24H)
</Typography>
</Box>
);
};
interface ICardTitlePriceProps {
price: number;
upDownLine: ICardUpDownPriceLineProps;
}
const CardTitlePrice = (props: ICardTitlePriceProps): React.ReactNode => {
const { price, upDownLine } = props;
return (
<Box display={"flex"} flexDirection={"column"} alignItems={"flex-end"}>
<Box display={"flex"} justifyContent={"space-between"} width={"100%"}>
<Box display={"flex"} gap={1}>
<NymTokenSVG />
<Typography>NYM</Typography>
</Box>
<Typography>${price}</Typography>
</Box>
<CardUpDownPriceLine {...upDownLine} />
</Box>
);
};
export interface ICardDataRowsProps {
rows: Array<{ key: string; value: string }>;
}
export const CardDataRows = (props: ICardDataRowsProps): React.ReactNode => {
const { rows } = props;
return (
<Box mb={3}>
{rows.map((row, i) => {
return (
<Box
key={i}
paddingTop={2}
paddingBottom={2}
display={"flex"}
justifyContent={"space-between"}
borderBottom={i === 0 ? "1px solid #C3D7D7" : "none"}
>
<Typography>{row.key}</Typography>
<Typography>{row.value}</Typography>
</Box>
);
})}
</Box>
);
};
interface ICardProileImage {
url?: string;
}
const CardProfileImage = (props: ICardProileImage) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"} mb={3}>
{url ? (
<Image src={url} alt="linkedIn" width={80} height={80} />
) : (
<Image
src={profileImagePlaceholder}
alt="linkedIn"
width={80}
height={80}
/>
)}
</Box>
);
};
interface ICardProfileCountry {
countryCode: string;
countryName: string;
}
const CardProfileCountry = (props: ICardProfileCountry) => {
const { countryCode, countryName } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"} gap={2} mb={3}>
<Flag code={countryCode} width="20" />
<Typography textTransform={"uppercase"}>{countryName}</Typography>
</Box>
);
};
interface ICardCopyAddressProps {
title: string;
address: string;
}
const CardCopyAddress = (props: ICardCopyAddressProps) => {
const { title, address } = props;
return (
<Box
paddingTop={2}
paddingBottom={2}
display={"flex"}
flexDirection={"column"}
gap={2}
borderBottom={"1px solid #C3D7D7"}
>
<Typography textTransform={"uppercase"}>{title}</Typography>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography>{address}</Typography>
<CopyToClipboard
sx={{ mr: 0.5, color: "grey.400" }}
smallIcons
value={address}
tooltip={`Copy identity key ${address} to clipboard`}
/>
</Box>
</Box>
);
};
interface ICardQRCodeProps {
url: string;
}
const CardQRCode = (props: ICardQRCodeProps) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"}>
<Box
padding={2}
border={"1px solid #C3D7D7"}
mb={3}
display={"block"}
width={"unset"}
>
<QRCodeCanvas value={url} />
</Box>
</Box>
);
};
interface ICardRatingsProps {
ratings: Array<{ title: string; numberOfStars: number }>;
}
const CardRatings = (props: ICardRatingsProps) => {
const { ratings } = props;
return (
<Box mb={3}>
{ratings.map((rating, i) => {
const Stars = () => {
const stars = [];
for (let i = 0; i < rating.numberOfStars; i++) {
stars.push(<StarIcon sx={{ color: "#14E76F" }} fontSize="small" />);
}
return stars;
};
const RatingTitle = () => {
if (rating.numberOfStars === 1) {
return <Typography>Bad</Typography>;
} else if (rating.numberOfStars === 2) {
return <Typography>Bad</Typography>;
} else if (rating.numberOfStars === 3) {
return <Typography>ok</Typography>;
} else if (rating.numberOfStars === 4) {
return <Typography>Good</Typography>;
} else {
return <Typography>Excellent</Typography>;
}
};
return (
<Box
key={i}
paddingTop={2}
paddingBottom={2}
display={"flex"}
justifyContent={"space-between"}
borderBottom={i < ratings.length - 1 ? "1px solid #C3D7D7" : "none"}
>
<Typography>{rating.title}</Typography>
<Box display={"flex"} gap={1} alignItems={"center"}>
<Stars />
<RatingTitle />
</Box>
</Box>
);
})}
</Box>
);
};
const CardChat = () => {
const { mode } = useMainContext();
useEffect(() => {
if (typeof window !== "undefined") {
// Set Remark42 configuration on the window object
window.remark_config = {
host: "https://remark.blockfend.com",
site_id: "nym-explorer-test",
components: ["embed", "last-comments"],
max_shown_comments: 100,
theme: mode === "light" ? "light" : "dark",
locale: "en",
show_email_subscription: false,
simple_view: true,
no_footer: true,
};
// Dynamically load the Remark42 script if it doesn't exist
if (!document.getElementById("remark42-script")) {
const script = document.createElement("script");
script.src = `${window.remark_config.host}/web/embed.js`;
script.async = true;
script.defer = true;
script.id = "remark42-script";
document.body.appendChild(script);
} else if (window.REMARK42) {
// Re-initialize if the script is already loaded
window.REMARK42.createInstance(window.remark_config);
}
}
}, []);
// React to mode changes and update Remark42 theme
useEffect(() => {
if (window.REMARK42 && window.REMARK42.changeTheme) {
window.REMARK42.changeTheme(mode === "dark" ? "dark" : "light");
}
}, [mode]);
return (
<Box>
<div id="remark42" className="remark"></div>
<Script
id="remark-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
if (window.REMARK42) {
window.REMARK42.createInstance(window.remark_config);
}
`,
}}
/>
</Box>
);
};
export type ContentCardProps = {
overTitle?: string;
profileImage?: ICardProileImage;
title?: string | number;
profileCountry?: ICardProfileCountry;
upDownLine?: ICardUpDownPriceLineProps;
titlePrice?: ICardTitlePriceProps;
dataRows?: ICardDataRowsProps;
graph?: { data: Array<IExplorerLineChartData>; color: string; label: string };
progressBar?: IExplorerDynamicProgressBarProps;
paragraph?: string;
onClick?: ReactEventHandler;
nymAddress?: ICardCopyAddressProps;
identityKey?: ICardCopyAddressProps;
qrCode?: ICardQRCodeProps;
ratings?: ICardRatingsProps;
chat?: boolean;
button?: {
onClick: () => void;
label: string;
};
};
export const ExplorerCard: FC<ContentCardProps> = ({
title,
titlePrice,
overTitle,
upDownLine,
dataRows,
graph,
progressBar,
paragraph,
onClick,
profileImage,
profileCountry,
nymAddress,
identityKey,
qrCode,
ratings,
chat,
button,
}) => (
<Card onClick={onClick} sx={{ height: "100%", borderRadius: "unset" }}>
<CardContent>
{overTitle && (
<Typography fontSize={14} mb={3} textTransform={"uppercase"}>
{overTitle}
</Typography>
)}
{profileImage && <CardProfileImage {...profileImage} />}
{title && (
<Typography fontSize={24} mb={3}>
{title}
</Typography>
)}
{profileCountry && <CardProfileCountry {...profileCountry} />}
{upDownLine && <CardUpDownPriceLine {...upDownLine} />}
{titlePrice && <CardTitlePrice {...titlePrice} />}
{qrCode && <CardQRCode {...qrCode} />}
{nymAddress && <CardCopyAddress {...nymAddress} />}
{identityKey && <CardCopyAddress {...identityKey} />}
{dataRows && <CardDataRows {...dataRows} />}
{ratings && <CardRatings {...ratings} />}
{graph && (
<Box mb={3}>
<ExplorerLineChart
data={graph.data}
color={graph.color}
label={graph.label}
/>
</Box>
)}
{progressBar && (
<Box mb={3}>
<ExplorerDynamicProgressBar {...progressBar} />
</Box>
)}
{paragraph && <Typography>{paragraph}</Typography>}
{chat && <CardChat />}
{button && (
<Button onClick={button.onClick} variant="contained">
{button.label}
</Button>
)}
</CardContent>
</Card>
);
@@ -0,0 +1,101 @@
import * as React from "react";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import { Typography } from "@mui/material";
export interface IExplorerDynamicProgressBarProps {
title?: string;
start: string; // Start timestamp as ISO 8601 string
showEpoch: boolean;
}
export const ExplorerDynamicProgressBar = (
props: IExplorerDynamicProgressBarProps
) => {
const { start, showEpoch, title } = props;
const [progress, setProgress] = React.useState(0);
React.useEffect(() => {
// Parse the start timestamp
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000; // Add 1 hour to the start time
// Validate start timestamp
if (isNaN(startTime)) {
console.error("Invalid start timestamp:", { start });
return;
}
// Function to calculate progress
const calculateProgress = () => {
const currentTime = Date.now();
if (currentTime < startTime) {
return 0;
}
if (currentTime >= endTime) {
return 100;
}
const elapsed = currentTime - startTime;
const total = endTime - startTime;
return (elapsed / total) * 100;
};
// Set initial progress and start timer
setProgress(calculateProgress());
const timer = setInterval(() => {
setProgress(calculateProgress());
}, 60000); // Update every minute (60000 milliseconds)
// Cleanup on unmount
return () => {
clearInterval(timer);
};
}, [start]);
// Helper function to format date
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); // Months are 0-based
const year = date.getFullYear();
return `${hours}:${minutes}, ${day}/${month}/${year}`;
};
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000;
return (
<Box sx={{ width: "100%" }}>
{title && (
<Typography mb={2} textTransform={"uppercase"}>
{title}
</Typography>
)}
<LinearProgress
variant="determinate"
value={progress}
sx={{
backgroundColor: "#CAD6D7",
"& .MuiLinearProgress-bar": {
backgroundColor: "#14E76F",
},
}}
/>
{showEpoch && (
<Box mt={2}>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography textTransform={"uppercase"}>START:</Typography>
<Typography> {startTime ? formatDate(startTime) : ""}</Typography>
</Box>
<Box display={"flex"} justifyContent={"space-between"}>
<Typography textTransform={"uppercase"}>END:</Typography>
<Typography> {endTime ? formatDate(endTime) : ""}</Typography>
</Box>
</Box>
)}
</Box>
);
};
@@ -0,0 +1,153 @@
import { Box, useMediaQuery, useTheme } from "@mui/material";
import dynamic from "next/dynamic";
import Loading from "./Loading";
import { useEffect, useState } from "react";
const LineChart = dynamic(
() => import("@nivo/line").then((m) => m.ResponsiveLine),
{
loading: () => <Loading />,
ssr: false,
}
);
export interface IExplorerLineChartData {
date_utc: string;
numericData?: number;
// purpleLineNumericData?: number;
}
interface IAxes {
x: Date;
y: number;
}
interface ILineAxes {
id: string;
data: Array<IAxes>;
}
export const ExplorerLineChart = ({
data,
color,
label,
}: {
data: Array<IExplorerLineChartData>;
color: string;
label: string;
}) => {
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("lg"));
const [chartData, setChartData] = useState<Array<ILineAxes>>();
useEffect(() => {
const resultData = transformData(data);
if (resultData.length > 0) {
setChartData(resultData);
}
}, [data]);
const transformData = (data: Array<IExplorerLineChartData>) => {
const lineData: ILineAxes = {
id: label,
data: [],
};
// const purpleLineData: ILineAxes = {
// id: "Numeric Data 2",
// data: [],
// };
data.map((item: any) => {
const axesGreenLineData: IAxes = {
x: new Date(item.date_utc),
y: item.numericData,
};
lineData.data.push(axesGreenLineData);
// const axesPurpleLineData: IAxes = {
// x: new Date(item.date_utc),
// y: item.purpleLineNumericData,
// };
// purpleLineData.data.push(axesPurpleLineData);
});
return [{ ...lineData }];
};
const yformat = (num: number | string | Date) => {
if (typeof num === "number") {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(1).replace(/\.0$/, "") + "B";
}
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K";
}
return num;
} else {
throw new Error("Unexpected value");
}
};
return (
<Box width={"100%"} height={isDesktop ? 200 : 150}>
{chartData && (
<LineChart
curve="monotoneX"
colors={[color]}
data={chartData}
animate
enableSlices="x"
margin={{
bottom: 24,
left: 36,
right: 16,
top: 20,
}}
theme={{
grid: { line: { strokeWidth: 0 } },
tooltip: { container: { color: "black" } },
axis: {
domain: {
line: { stroke: "#C3D7D7", strokeWidth: 1, strokeOpacity: 1 },
},
ticks: {
text: {
fill: "#818386",
},
},
legend: {
text: {
fill: "#818386",
},
},
},
}}
xScale={{
type: "time",
format: "%Y-%m-%d",
}}
yScale={{ min: 1, type: "linear" }}
xFormat="time:%Y-%m-%d"
axisLeft={{
legendOffset: 12,
tickSize: 3,
format: yformat,
tickValues: 5,
}}
axisBottom={{
format: "%b %d",
legendOffset: -12,
tickValues:
chartData[0].data.length > 7 ? "every 4 days" : "every day",
}}
/>
)}
</Box>
);
};
@@ -0,0 +1,36 @@
import React from "react";
import { Box } from "@mui/material";
export interface MultiSegmentProgressBarProps {
values: { percentage: number; color: string }[]; // Array of percentage and color pairs
height?: number; // Optional height, default is 8
borderRadius?: number; // Optional border radius, default is 4
backgroundColor?: string; // Optional background color for the bar, default is light gray
}
export const MultiSegmentProgressBar: React.FC<
MultiSegmentProgressBarProps
> = ({ values, height = 8, borderRadius = 4, backgroundColor = "#CAD6D7" }) => {
return (
<Box
sx={{
display: "flex",
width: "100%",
height,
borderRadius,
overflow: "hidden",
backgroundColor,
}}
>
{values.map((value, index) => (
<Box
key={index}
sx={{
width: `${value.percentage}%`,
backgroundColor: value.color,
}}
/>
))}
</Box>
);
};
@@ -0,0 +1,31 @@
import * as React from "react";
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
export interface IExplorerStaticProgressBarProps {
color: string;
value: number;
}
export const ExplorerStaticProgressBar = (
props: IExplorerStaticProgressBarProps
) => {
const { color, value } = props;
return (
<Box>
<LinearProgress
variant="determinate"
value={value}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "#CAD6D7",
"& .MuiLinearProgress-bar": {
backgroundColor: color,
},
}}
/>
</Box>
);
};
@@ -0,0 +1,65 @@
import React, { useState } from "react";
import { Box, Button } from "@mui/material";
interface TwoSidedSwitchProps {
leftLabel: string; // Label for the left side
rightLabel: string; // Label for the right side
onSwitch?: (side: "left" | "right") => void; // Callback when switched
}
const TwoSidedSwitch: React.FC<TwoSidedSwitchProps> = ({
leftLabel,
rightLabel,
onSwitch,
}) => {
const [selectedSide, setSelectedSide] = useState<"left" | "right">("left");
const handleSwitch = (side: "left" | "right") => {
setSelectedSide(side);
if (onSwitch) onSwitch(side);
};
return (
<Box
sx={{
display: "flex",
borderRadius: "8px",
overflow: "hidden",
width: "200px",
height: "40px",
border: "2px solid black",
}}
>
<Button
onClick={() => handleSwitch("left")}
sx={{
flex: 1,
backgroundColor: selectedSide === "left" ? "black" : "white",
color: selectedSide === "left" ? "white" : "black",
borderRadius: 0,
"&:hover": {
backgroundColor: selectedSide === "left" ? "black" : "lightgray",
},
}}
>
{leftLabel}
</Button>
<Button
onClick={() => handleSwitch("right")}
sx={{
flex: 1,
backgroundColor: selectedSide === "right" ? "black" : "white",
color: selectedSide === "right" ? "white" : "black",
borderRadius: 0,
"&:hover": {
backgroundColor: selectedSide === "right" ? "black" : "lightgray",
},
}}
>
{rightLabel}
</Button>
</Box>
);
};
export default TwoSidedSwitch;
@@ -0,0 +1,19 @@
import * as React from "react";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
export default function Loading() {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
marginY: 10,
width: 1,
}}
>
<CircularProgress />
</Box>
);
}
@@ -0,0 +1,40 @@
import { Card, CardContent, Typography, Box, Button } from "@mui/material";
import { ICardDataRowsProps } from "./ExplorerCard";
export interface IStakingCardProps {
title?: string;
titleCenter?: string;
paragraphCenter?: string;
closeButton?: {
onClick: () => void;
};
addressInput?: boolean;
amountInput?: boolean;
dataRows?: ICardDataRowsProps;
blockExplorerLink?: string;
backButton?: {
onClick: () => void;
};
nextButton?: {
onClick: () => void;
};
}
export const StakingCard = (props: IStakingCardProps) => {
const {
title,
titleCenter,
closeButton,
paragraphCenter,
addressInput,
amountInput,
dataRows,
blockExplorerLink,
backButton,
nextButton,
} = props;
return (
<Card sx={{ height: "100%", borderRadius: "unset" }}>
<CardContent></CardContent>
</Card>
);
};
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
import { useTheme } from "@mui/material/styles";
export const NymTokenSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.text.primary;
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_7134_15888)">
<path
d="M17.07 3.43C13.17 -0.480002 6.83 -0.480002 2.93 3.43C-0.980002 7.34 -0.980002 13.67 2.93 17.57C6.84 21.48 13.17 21.48 17.07 17.57C20.98 13.67 20.98 7.33 17.07 3.43ZM16.21 16.71C12.78 20.14 7.21 20.14 3.78 16.71C0.349997 13.28 0.349997 7.71 3.78 4.28C7.21 0.849997 12.78 0.849997 16.21 4.28C19.65 7.72 19.65 13.28 16.21 16.71Z"
fill={color}
/>
<path
d="M15.4 16.33V4.66999C14.89 4.18999 14.32 3.76999 13.71 3.43999V14.59L6.35001 3.39999C5.71001 3.73999 5.12001 4.15999 4.60001 4.65999V16.33C5.11001 16.81 5.68001 17.23 6.29001 17.56V6.40999L13.65 17.6C14.29 17.26 14.88 16.83 15.4 16.33Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_7134_15888">
<rect
width="20"
height="20"
fill={color}
transform="translate(0 0.5)"
/>
</clipPath>
</defs>
</svg>
);
};
+7 -7
View File
@@ -1,15 +1,15 @@
import type { Metadata } from 'next'
import '@interchain-ui/react/styles'
import { App } from './App'
import type { Metadata } from "next";
import "@interchain-ui/react/styles";
import { App } from "./App";
export const metadata: Metadata = {
title: 'Nym Network Explorer',
}
title: "Nym Network Explorer",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
children: React.ReactNode;
}>) {
return (
<html lang="en">
@@ -17,5 +17,5 @@ export default function RootLayout({
<App>{children}</App>
</body>
</html>
)
);
}
@@ -1,72 +1,72 @@
'use client'
"use client";
import * as React from 'react'
import { Alert, AlertTitle, Box, CircularProgress, Grid } from '@mui/material'
import { useParams } from 'next/navigation'
import { GatewayBond } from '@/app/typeDefs/explorer-api'
import { ColumnsType, DetailTable } from '@/app/components/DetailTable'
import * as React from "react";
import { Alert, AlertTitle, Box, CircularProgress, Grid } from "@mui/material";
import { useParams } from "next/navigation";
import { GatewayBond } from "@/app/typeDefs/explorer-api";
import { ColumnsType, DetailTable } from "@/app/components/DetailTable";
import {
gatewayEnrichedToGridRow,
GatewayEnrichedRowType,
} from '@/app/components/Gateways/Gateways'
import { ComponentError } from '@/app/components/ComponentError'
import { ContentCard } from '@/app/components/ContentCard'
import { TwoColSmallTable } from '@/app/components/TwoColSmallTable'
import { UptimeChart } from '@/app/components/UptimeChart'
} from "@/app/components/Gateways/Gateways";
import { ComponentError } from "@/app/components/ComponentError";
import { ContentCard } from "@/app/components/ContentCard";
import { TwoColSmallTable } from "@/app/components/TwoColSmallTable";
import { UptimeChart } from "@/app/components/UptimeChart";
import {
GatewayContextProvider,
useGatewayContext,
} from '@/app/context/gateway'
import { useMainContext } from '@/app/context/main'
import { Title } from '@/app/components/Title'
} from "@/app/context/gateway";
import { useMainContext } from "@/app/context/main";
import { Title } from "@/app/components/Title";
const columns: ColumnsType[] = [
{
field: 'identity_key',
title: 'Identity Key',
headerAlign: 'left',
field: "identity_key",
title: "Identity Key",
headerAlign: "left",
width: 230,
},
{
field: 'bond',
title: 'Bond',
headerAlign: 'left',
field: "bond",
title: "Bond",
headerAlign: "left",
},
{
field: 'node_performance',
title: 'Routing Score',
headerAlign: 'left',
field: "node_performance",
title: "Routing Score",
headerAlign: "left",
tooltipInfo:
"Gateway's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test",
},
{
field: 'avgUptime',
title: 'Avg. Score',
headerAlign: 'left',
field: "avgUptime",
title: "Avg. Score",
headerAlign: "left",
tooltipInfo: "Gateway's average routing score in the last 24 hours",
},
{
field: 'host',
title: 'IP',
headerAlign: 'left',
field: "host",
title: "IP",
headerAlign: "left",
width: 99,
},
{
field: 'location',
title: 'Location',
headerAlign: 'left',
field: "location",
title: "Location",
headerAlign: "left",
},
{
field: 'owner',
title: 'Owner',
headerAlign: 'left',
field: "owner",
title: "Owner",
headerAlign: "left",
},
{
field: 'version',
title: 'Version',
headerAlign: 'left',
field: "version",
title: "Version",
headerAlign: "left",
},
]
];
/**
* Shows gateway details
@@ -74,26 +74,26 @@ const columns: ColumnsType[] = [
const PageGatewayDetailsWithState = ({
selectedGateway,
}: {
selectedGateway: GatewayBond | undefined
selectedGateway: GatewayBond | undefined;
}) => {
const [enrichGateway, setEnrichGateway] =
React.useState<GatewayEnrichedRowType>()
const [status, setStatus] = React.useState<number[] | undefined>()
const { uptimeReport, uptimeStory } = useGatewayContext()
React.useState<GatewayEnrichedRowType>();
const [status, setStatus] = React.useState<number[] | undefined>();
const { uptimeReport, uptimeStory } = useGatewayContext();
React.useEffect(() => {
if (uptimeReport?.data && selectedGateway) {
setEnrichGateway(
gatewayEnrichedToGridRow(selectedGateway, uptimeReport.data)
)
);
}
}, [uptimeReport, selectedGateway])
}, [uptimeReport, selectedGateway]);
React.useEffect(() => {
if (enrichGateway) {
setStatus([enrichGateway.mixPort, enrichGateway.clientsPort])
setStatus([enrichGateway.mixPort, enrichGateway.clientsPort]);
}
}, [enrichGateway])
}, [enrichGateway]);
return (
<Box component="main">
@@ -115,7 +115,7 @@ const PageGatewayDetailsWithState = ({
<ContentCard title="Gateway Status">
<TwoColSmallTable
loading={false}
keys={['Mix port', 'Client WS API Port']}
keys={["Mix port", "Client WS API Port"]}
values={status.map((each) => each)}
icons={status.map((elem) => !!elem)}
/>
@@ -137,39 +137,40 @@ const PageGatewayDetailsWithState = ({
</ContentCard>
)}
</Grid>
<Grid item xs={12} md={8}></Grid>
</Grid>
</Box>
)
}
);
};
/**
* Guard component to handle loadingW and not found states
*/
const PageGatewayDetailGuard = () => {
const [selectedGateway, setSelectedGateway] = React.useState<GatewayBond>()
const { gateways } = useMainContext()
const { id } = useParams()
const [selectedGateway, setSelectedGateway] = React.useState<GatewayBond>();
const { gateways } = useMainContext();
const { id } = useParams();
React.useEffect(() => {
if (gateways?.data) {
setSelectedGateway(
gateways.data.find((g) => g.gateway.identity_key === id)
)
);
}
}, [gateways, id])
}, [gateways, id]);
if (gateways?.isLoading) {
return <CircularProgress />
return <CircularProgress />;
}
if (gateways?.error) {
// eslint-disable-next-line no-console
console.error(gateways?.error)
console.error(gateways?.error);
return (
<Alert severity="error">
Oh no! Could not load mixnode <code>{id || ''}</code>
Oh no! Could not load mixnode <code>{id || ""}</code>
</Alert>
)
);
}
// loaded, but not found
@@ -177,31 +178,31 @@ const PageGatewayDetailGuard = () => {
return (
<Alert severity="warning">
<AlertTitle>Gateway not found</AlertTitle>
Sorry, we could not find a mixnode with id <code>{id || ''}</code>
Sorry, we could not find a mixnode with id <code>{id || ""}</code>
</Alert>
)
);
}
return <PageGatewayDetailsWithState selectedGateway={selectedGateway} />
}
return <PageGatewayDetailsWithState selectedGateway={selectedGateway} />;
};
/**
* Wrapper component that adds the mixnode content based on the `id` in the address URL
*/
const PageGatewayDetail = () => {
const { id } = useParams()
const { id } = useParams();
if (!id || typeof id !== 'string') {
if (!id || typeof id !== "string") {
return (
<Alert severity="error">Oh no! No mixnode identity key specified</Alert>
)
);
}
return (
<GatewayContextProvider gatewayIdentityKey={id}>
<PageGatewayDetailGuard />
</GatewayContextProvider>
)
}
);
};
export default PageGatewayDetail
export default PageGatewayDetail;
@@ -1,6 +1,6 @@
'use client'
"use client";
import * as React from 'react'
import * as React from "react";
import {
Alert,
AlertTitle,
@@ -8,75 +8,82 @@ import {
CircularProgress,
Grid,
Typography,
} from '@mui/material'
import { ColumnsType, DetailTable } from '@/app/components/DetailTable'
import { BondBreakdownTable } from '@/app/components/MixNodes/BondBreakdown'
} from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { ColumnsType, DetailTable } from "@/app/components/DetailTable";
import { BondBreakdownTable } from "@/app/components/MixNodes/BondBreakdown";
import {
DelegatorsInfoTable,
EconomicsInfoColumns,
EconomicsInfoRows,
} from '@/app/components/MixNodes/Economics'
import { ComponentError } from '@/app/components/ComponentError'
import { ContentCard } from '@/app/components/ContentCard'
import { TwoColSmallTable } from '@/app/components/TwoColSmallTable'
import { UptimeChart } from '@/app/components/UptimeChart'
import { WorldMap } from '@/app/components/WorldMap'
import { MixNodeDetailSection } from '@/app/components/MixNodes/DetailSection'
} from "@/app/components/MixNodes/Economics";
import { ComponentError } from "@/app/components/ComponentError";
import { ContentCard } from "@/app/components/ContentCard";
import { TwoColSmallTable } from "@/app/components/TwoColSmallTable";
import { UptimeChart } from "@/app/components/UptimeChart";
import { WorldMap } from "@/app/components/WorldMap";
import { MixNodeDetailSection } from "@/app/components/MixNodes/DetailSection";
import {
MixnodeContextProvider,
useMixnodeContext,
} from '@/app/context/mixnode'
import { Title } from '@/app/components/Title'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { useParams } from 'next/navigation'
} from "@/app/context/mixnode";
import { Title } from "@/app/components/Title";
import { useIsMobile } from "@/app/hooks/useIsMobile";
import { useParams } from "next/navigation";
import Script from "next/script";
import Head from "next/head";
import { useEffect } from "react";
import { useMainContext } from "@/app/context/main";
import { ExplorerCard } from "@/app/components/ExplorerCard";
const columns: ColumnsType[] = [
{
field: 'owner',
title: 'Owner',
width: '15%',
field: "owner",
title: "Owner",
width: "15%",
},
{
field: 'identity_key',
title: 'Identity Key',
width: '15%',
field: "identity_key",
title: "Identity Key",
width: "15%",
},
{
field: 'bond',
title: 'Stake',
width: '12.5%',
field: "bond",
title: "Stake",
width: "12.5%",
},
{
field: 'stake_saturation',
title: 'Stake Saturation',
width: '12.5%',
field: "stake_saturation",
title: "Stake Saturation",
width: "12.5%",
tooltipInfo:
'Level of stake saturation for this node. Nodes receive more rewards the higher their saturation level, up to 100%. Beyond 100% no additional rewards are granted. The current stake saturation level is 940k NYMs, computed as S/K where S is target amount of tokens staked in the network and K is the number of nodes in the reward set.',
"Level of stake saturation for this node. Nodes receive more rewards the higher their saturation level, up to 100%. Beyond 100% no additional rewards are granted. The current stake saturation level is 940k NYMs, computed as S/K where S is target amount of tokens staked in the network and K is the number of nodes in the reward set.",
},
{
field: 'self_percentage',
width: '10%',
title: 'Bond %',
field: "self_percentage",
width: "10%",
title: "Bond %",
tooltipInfo:
"Percentage of the operator's bond to the total stake on the node",
},
{
field: 'host',
width: '10%',
title: 'Host',
field: "host",
width: "10%",
title: "Host",
},
{
field: 'location',
title: 'Location',
field: "location",
title: "Location",
},
{
field: 'layer',
title: 'Layer',
field: "layer",
title: "Layer",
},
]
];
/**
* Shows mix node details
@@ -90,10 +97,53 @@ const PageMixnodeDetailWithState = () => {
status,
uptimeStory,
uniqDelegations,
} = useMixnodeContext()
const isMobile = useIsMobile()
} = useMixnodeContext();
const isMobile = useIsMobile();
const { mode } = useMainContext();
// useEffect(() => {
// if (typeof window !== "undefined") {
// // Set Remark42 configuration on the window object
// window.remark_config = {
// host: "http://localhost:8081",
// site_id: "remark42",
// components: ["embed", "last-comments"],
// max_shown_comments: 100,
// theme: mode === "light" ? "light" : "dark",
// page_title: "My custom title for a page",
// locale: "en",
// show_email_subscription: false,
// simple_view: true,
// no_footer: true,
// };
// // Dynamically load the Remark42 script if it doesn't exist
// if (!document.getElementById("remark42-script")) {
// const script = document.createElement("script");
// script.src = `${window.remark_config.host}/web/embed.js`;
// script.async = true;
// script.defer = true;
// script.id = "remark42-script";
// document.body.appendChild(script);
// } else if (window.REMARK42) {
// // Re-initialize if the script is already loaded
// window.REMARK42.createInstance(window.remark_config);
// }
// }
// }, []);
// // React to mode changes and update Remark42 theme
// useEffect(() => {
// if (window.REMARK42 && window.REMARK42.changeTheme) {
// window.REMARK42.changeTheme(mode === "dark" ? "dark" : "light");
// }
// }, [mode]);
return (
<Box component="main">
<Head>
<title>Mixnode Detail</title>
</Head>
<Title text="Mixnode Detail" />
<Grid container spacing={2} mt={1} mb={6}>
<Grid item xs={12}>
@@ -105,9 +155,9 @@ const PageMixnodeDetailWithState = () => {
)}
{mixNodeRow?.blacklisted && (
<Typography
textAlign={isMobile ? 'left' : 'right'}
textAlign={isMobile ? "left" : "right"}
fontSize="smaller"
sx={{ color: 'error.main' }}
sx={{ color: "error.main" }}
>
This node is having a poor performance
</Typography>
@@ -153,7 +203,7 @@ const PageMixnodeDetailWithState = () => {
loading={stats.isLoading}
error={stats?.error?.message}
title="Since startup"
keys={['Received', 'Sent', 'Explicitly dropped']}
keys={["Received", "Sent", "Explicitly dropped"]}
values={[
stats?.data?.packets_received_since_startup || 0,
stats?.data?.packets_sent_since_startup || 0,
@@ -164,7 +214,7 @@ const PageMixnodeDetailWithState = () => {
loading={stats.isLoading}
error={stats?.error?.message}
title="Since last update"
keys={['Received', 'Sent', 'Explicitly dropped']}
keys={["Received", "Sent", "Explicitly dropped"]}
values={[
stats?.data?.packets_received_since_last_update || 0,
stats?.data?.packets_sent_since_last_update || 0,
@@ -204,7 +254,7 @@ const PageMixnodeDetailWithState = () => {
<TwoColSmallTable
loading={status.isLoading}
error={status?.error?.message}
keys={['Mix port', 'Verloc port', 'HTTP port']}
keys={["Mix port", "Verloc port", "HTTP port"]}
values={[1789, 1790, 8000].map((each) => each)}
icons={
(status?.data?.ports && Object.values(status.data.ports)) || [
@@ -237,29 +287,32 @@ const PageMixnodeDetailWithState = () => {
)}
</Grid>
</Grid>
<Grid item xs={12} md={4}>
<ExplorerCard chat={true} overTitle="Test" />
</Grid>
</Box>
)
}
);
};
/**
* Guard component to handle loading and not found states
*/
const PageMixnodeDetailGuard = () => {
const { mixNode } = useMixnodeContext()
const { id } = useParams()
const { mixNode } = useMixnodeContext();
const { id } = useParams();
if (mixNode?.isLoading) {
return <CircularProgress />
return <CircularProgress />;
}
if (mixNode?.error) {
// eslint-disable-next-line no-console
console.error(mixNode?.error)
console.error(mixNode?.error);
return (
<Alert severity="error">
Oh no! Could not load mixnode <code>{id || ''}</code>
Oh no! Could not load mixnode <code>{id || ""}</code>
</Alert>
)
);
}
// loaded, but not found
@@ -267,31 +320,31 @@ const PageMixnodeDetailGuard = () => {
return (
<Alert severity="warning">
<AlertTitle>Mixnode not found</AlertTitle>
Sorry, we could not find a mixnode with id <code>{id || ''}</code>
Sorry, we could not find a mixnode with id <code>{id || ""}</code>
</Alert>
)
);
}
return <PageMixnodeDetailWithState />
}
return <PageMixnodeDetailWithState />;
};
/**
* Wrapper component that adds the mixnode content based on the `id` in the address URL
*/
const PageMixnodeDetail = () => {
const { id } = useParams()
const { id } = useParams();
if (!id || typeof id !== 'string') {
if (!id || typeof id !== "string") {
return (
<Alert severity="error">Oh no! No mixnode identity key specified</Alert>
)
);
}
return (
<MixnodeContextProvider mixId={id}>
<PageMixnodeDetailGuard />
</MixnodeContextProvider>
)
}
);
};
export default PageMixnodeDetail
export default PageMixnodeDetail;
+313 -33
View File
@@ -1,26 +1,288 @@
'use client'
"use client";
import React, { useEffect } from 'react'
import { Box, Grid, Link, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { PeopleAlt } from '@mui/icons-material'
import { Title } from '@/app/components/Title'
import { StatsCard } from '@/app/components/StatsCard'
import { MixnodesSVG } from '@/app/icons/MixnodesSVG'
import { Icons } from '@/app/components/Icons'
import { GatewaysSVG } from '@/app/icons/GatewaysSVG'
import { ValidatorsSVG } from '@/app/icons/ValidatorsSVG'
import { ContentCard } from '@/app/components/ContentCard'
import { WorldMap } from '@/app/components/WorldMap'
import { BIG_DIPPER } from '@/app/api/constants'
import { formatNumber } from '@/app/utils'
import { useMainContext } from './context/main'
import { useRouter } from 'next/navigation'
import React, { useEffect, useState } from "react";
import { Box, Grid, Link, Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { PeopleAlt } from "@mui/icons-material";
import { Title } from "@/app/components/Title";
import { StatsCard } from "@/app/components/StatsCard";
import { MixnodesSVG } from "@/app/icons/MixnodesSVG";
import { Icons } from "@/app/components/Icons";
import { GatewaysSVG } from "@/app/icons/GatewaysSVG";
import { ValidatorsSVG } from "@/app/icons/ValidatorsSVG";
import { ContentCard } from "@/app/components/ContentCard";
import { WorldMap } from "@/app/components/WorldMap";
import { BIG_DIPPER } from "@/app/api/constants";
import { formatNumber } from "@/app/utils";
import { useMainContext } from "./context/main";
import { useRouter } from "next/navigation";
import { ContentCardProps, ExplorerCard } from "./components/ExplorerCard";
import { ExplorerData, getCacheExplorerData } from "./api/explorer";
import { IExplorerLineChartData } from "./components/ExplorerLineChart";
import {
AccountStatsCard,
IAccountStatsCardProps,
} from "./components/AccountStatsCard";
import TwoSidedSwitch from "./components/ExplorerSwitchButton";
const PageOverview = () => {
const theme = useTheme()
const router = useRouter()
const explorerCard: ContentCardProps = {
overTitle: "SINGLE",
profileImage: {},
title: "SINGLE",
profileCountry: {
countryCode: "NO",
countryName: "Norway",
},
upDownLine: {
percentage: 10,
numberWentUp: true,
},
titlePrice: {
price: 1.15,
upDownLine: {
percentage: 10,
numberWentUp: true,
},
},
dataRows: {
rows: [
{ key: "Market cap", value: "$ 1000000" },
{ key: "24H VOL", value: "$ 1000000" },
],
},
graph: {
data: [
{
date_utc: "2024-11-20",
numericData: 10,
},
{
date_utc: "2024-11-21",
numericData: 12,
},
{
date_utc: "2024-11-22",
numericData: 9,
},
{
date_utc: "2024-11-23",
numericData: 11,
},
],
color: "#00CA33",
label: "Label",
},
nymAddress: {
address: "n1w7tfthyfkhh3au3mqpy294p4dk65dzal2h04su",
title: "Nym address",
},
identityKey: {
address: "n1w7tfthyfkhh3au3mqpy294p4dk65dzal2h04su",
title: "Nym address",
},
qrCode: {
url: "https://nymtech.net",
},
ratings: {
ratings: [
{ title: "Rating", numberOfStars: 4 },
{ title: "Rating", numberOfStars: 2 },
{ title: "Rating", numberOfStars: 3 },
],
},
chat: true,
paragraph: "Additional line",
button: {
onClick: () => {},
label: "Label",
},
};
export const DATA_REVALIDATE = 60;
export default function PageOverview() {
const [explorerData, setExplorerData] = useState<ExplorerData | null>(null);
const [noiseLineGraphData, setNoiseLineGraphData] = useState<{
color: string;
label: string;
data: IExplorerLineChartData[];
}>();
const [stakeLineGraphData, setStakeLineGraphData] = useState<{
color: string;
label: string;
data: IExplorerLineChartData[];
}>();
useEffect(() => {
async function fetchData() {
const data = await getCacheExplorerData();
setExplorerData(data);
}
fetchData();
}, []);
const theme = useTheme();
const router = useRouter();
// CURRENT EPOCH
const currentEpochStart =
explorerData?.currentEpochData.current_epoch_start || "";
const progressBar = {
title: "Current NGM epoch",
start: currentEpochStart || "",
showEpoch: true,
};
const formatBigNum = (num: number) => {
if (typeof num === "number") {
if (num >= 1000000000) {
return (num / 1000000000).toFixed(1).replace(/\.0$/, "") + "B";
}
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M";
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K";
}
return num;
}
};
// NOISE
const noiseLast24H =
explorerData?.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 1
].total_packets_sent +
explorerData?.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 1
].total_packets_received;
const noisePrevious24H =
explorerData?.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 2
].total_packets_sent +
explorerData?.packetsAndStakingData[
explorerData.packetsAndStakingData.length - 2
].total_packets_received;
const calculatePercentageChange = (last24H: number, previous24H: number) => {
if (previous24H === 0) {
throw new Error(
"Cannot calculate percentage change when yesterday's value is zero."
);
}
const change = ((last24H - previous24H) / previous24H) * 100;
return parseFloat(change.toFixed(2));
};
const percentage = calculatePercentageChange(noiseLast24H, noisePrevious24H);
const getPacketsData = () => {
const data: Array<IExplorerLineChartData> = [];
explorerData?.packetsAndStakingData.map((item: any) => {
data.push({
date_utc: item.date_utc,
numericData: item.total_packets_sent + item.total_packets_received,
});
});
return data;
};
useEffect(() => {
const noiseLineGraphData = {
color: "#8482FD",
label: "Total packets sent and received",
data: getPacketsData(),
};
setNoiseLineGraphData(noiseLineGraphData);
}, [explorerData]);
const noiseCard = {
overTitle: "Noise generated last 24h",
title: formatBigNum(noiseLast24H) || "",
upDownLine: {
percentage: Math.abs(percentage) || 0,
numberWentUp: percentage > 0,
},
graph: noiseLineGraphData,
};
// STAKE
const currentStake =
Number(explorerData?.currentEpochRewardsData.interval.staking_supply) /
1000000 || 0;
const getStakeData = () => {
const data: Array<IExplorerLineChartData> = [];
explorerData?.packetsAndStakingData.map((item: any) => {
data.push({
date_utc: item.date_utc,
numericData: item.total_stake / 1000000,
});
});
return data;
};
useEffect(() => {
const stakeLineGraphData = {
color: "#00CA33",
label: "Total stake delegated in NYM",
data: getStakeData(),
};
setStakeLineGraphData(stakeLineGraphData);
}, [explorerData]);
const stakeCard = {
overTitle: "Current network stake",
title: currentStake + " NYM" || "",
graph: stakeLineGraphData,
};
const accountStatsCard: IAccountStatsCardProps = {
overTitle: "Total value",
priceTitle: 1990.0174,
rows: [
{ type: "Spendable", allocation: 15.53, amount: 12800, value: 1200 },
{
type: "Delegated",
allocation: 15.53,
amount: 12800,
value: 1200,
history: [
{ type: "Liquid", amount: 6900 },
{ type: "Locked", amount: 6900 },
],
},
{
type: "Claimable",
allocation: 15.53,
amount: 12800,
value: 1200,
history: [
{ type: "Unlocked", amount: 6900 },
{ type: "Staking rewards", amount: 6900 },
{ type: "Operator comission", amount: 6900 },
],
},
{
type: "Self bonded",
allocation: 15.53,
amount: 12800,
value: 1200,
},
{
type: "Locked",
allocation: 15.53,
amount: 12800,
value: 1200,
},
],
};
const {
summaryOverview,
@@ -29,9 +291,14 @@ const PageOverview = () => {
block,
countryData,
serviceProviders,
} = useMainContext()
} = useMainContext();
return (
<Box component="main" sx={{ flexGrow: 1 }}>
<TwoSidedSwitch
leftLabel="Account"
rightLabel="Mixnode"
onSwitch={() => {}}
/>
<Grid>
<Grid item paddingBottom={3}>
<Title text="Overview" />
@@ -40,19 +307,34 @@ const PageOverview = () => {
<Grid container spacing={3}>
{summaryOverview && (
<>
<Grid item xs={12}>
<AccountStatsCard {...accountStatsCard} />
</Grid>
<Grid item xs={12} md={4}>
<ExplorerCard {...explorerCard} />
</Grid>
<Grid item xs={12} md={4}>
<ExplorerCard progressBar={progressBar} />
</Grid>
<Grid item xs={12} md={4}>
<ExplorerCard {...noiseCard} />
</Grid>
<Grid item xs={12} md={4}>
<ExplorerCard {...stakeCard} />
</Grid>
<Grid item xs={12} md={4}>
<StatsCard
onClick={() => router.push('/network-components/mixnodes')}
onClick={() => router.push("/network-components/mixnodes")}
title="Mixnodes"
icon={<MixnodesSVG />}
count={summaryOverview.data?.mixnodes.count || ''}
count={summaryOverview.data?.mixnodes.count || ""}
errorMsg={summaryOverview?.error}
/>
</Grid>
<Grid item xs={12} md={4}>
<StatsCard
onClick={() =>
router.push('/network-components/mixnodes?status=active')
router.push("/network-components/mixnodes?status=active")
}
title="Active nodes"
icon={<Icons.Mixnodes.Status.Active />}
@@ -66,7 +348,7 @@ const PageOverview = () => {
<Grid item xs={12} md={4}>
<StatsCard
onClick={() =>
router.push('/network-components/mixnodes?status=standby')
router.push("/network-components/mixnodes?status=standby")
}
title="Standby nodes"
color={
@@ -82,9 +364,9 @@ const PageOverview = () => {
{gateways && (
<Grid item xs={12} md={4}>
<StatsCard
onClick={() => router.push('/network-components/gateways')}
onClick={() => router.push("/network-components/gateways")}
title="Gateways"
count={gateways?.data?.length || ''}
count={gateways?.data?.length || ""}
errorMsg={gateways?.error}
icon={<GatewaysSVG />}
/>
@@ -94,7 +376,7 @@ const PageOverview = () => {
<Grid item xs={12} md={4}>
<StatsCard
onClick={() =>
router.push('/network-components/service-providers')
router.push("/network-components/service-providers")
}
title="Service providers"
icon={<PeopleAlt />}
@@ -108,7 +390,7 @@ const PageOverview = () => {
<StatsCard
onClick={() => window.open(`${BIG_DIPPER}/validators`)}
title="Validators"
count={validators?.data?.count || ''}
count={validators?.data?.count || ""}
errorMsg={validators?.error}
icon={<ValidatorsSVG />}
/>
@@ -150,7 +432,5 @@ const PageOverview = () => {
</Grid>
</Grid>
</Box>
)
);
}
export default PageOverview
+1
View File
@@ -0,0 +1 @@
declare module "react-world-flags";
+46
View File
@@ -0,0 +1,46 @@
version: "2"
services:
remark:
# remove the next line in case you want to use this Docker Compose file separately
# as otherwise it would complain for absence of Dockerfile
build: .
image: umputun/remark42:latest
container_name: "explorer_remark42"
hostname: "remark42"
restart: always
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
# uncomment to expose directly (no proxy)
ports:
- "8081:8080"
- "443:8443"
environment:
# - REMARK_URL=http://localhost:8081
- REMARK_URL=https://remark.blockfend.com
- SECRET=secret-key
- AUTH_ANON=true
# - SITE=remark42
- SITE=nym-explorer-test
# - DEBUG=true
# - AUTH_GOOGLE_CID
# - AUTH_GOOGLE_CSEC
# - AUTH_GITHUB_CID
# - AUTH_GITHUB_CSEC
# - AUTH_FACEBOOK_CID
# - AUTH_FACEBOOK_CSEC
# - AUTH_DISQUS_CID
# - AUTH_DISQUS_CSEC
# Enable it only for the initial comment import or for manual backups.
# Do not leave the server running with the ADMIN_PASSWD set if you don't have an intention
# to keep creating backups manually!
- ADMIN_PASSWD=password
volumes:
- ./var:/srv/var
+7 -4
View File
@@ -9,15 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"@nymproject/react": "^1.0.0",
"@mui/x-data-grid": "7.1.1",
"@mui/x-date-pickers": "7.1.1",
"@nivo/line": "^0.88.0",
"@nymproject/nym-validator-client": "0.18.0",
"@nymproject/react": "^1.0.0",
"material-react-table": "^2.12.1",
"next": "14.1.4",
"react": "^18",
"react-dom": "^18",
"react-error-boundary": "^4.0.13",
"material-react-table": "^2.12.1",
"@mui/x-date-pickers": "7.1.1",
"@mui/x-data-grid": "7.1.1"
"react-world-flags": "^1.6.0",
"qrcode.react": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
+2 -1
View File
@@ -28,7 +28,8 @@
"../assets/*"
]
},
"moduleResolution": "node"
"moduleResolution": "node",
"target": "ES2017"
},
"include": [
"next-env.d.ts",
Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.
Binary file not shown.
+4144 -4083
View File
File diff suppressed because it is too large Load Diff