diff --git a/.github/workflows/frontend-test.yml b/.github/workflows/frontend-test.yml
index 53454a797..1d575665c 100644
--- a/.github/workflows/frontend-test.yml
+++ b/.github/workflows/frontend-test.yml
@@ -34,5 +34,5 @@ jobs:
yarn lint --max-warnings=0
yarn prettier --check "src/**/*.(tsx|ts)"
- - name: Pretend we have chains.json and run tests
- run: cp public/test-file.json src/chains.json && yarn test
+ - name: Pretend we have data.json and run tests
+ run: cp public/test-file.json public/data.json && yarn test
diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml
deleted file mode 100644
index 80e8a4868..000000000
--- a/.github/workflows/update.yml
+++ /dev/null
@@ -1,116 +0,0 @@
-name: Check updates
-
-on:
- workflow_dispatch:
- schedule:
- - cron: '0 * * * *'
-
-env:
- BRANCH_PREFIX: sign-me
- NOTIFY_MATRIX: false
-
-jobs:
- update:
- runs-on: ubuntu-latest
- steps:
- - name: 🛎 Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: 🔧 Install rust dependencies
- uses: ./.github/workflows/rust-install
-
- - name: Try to checkout exising PR branch
- id: checkout-pr
- run: |
- SIGN_ME_BRANCH=$(git branch -r --list "origin/$BRANCH_PREFIX-*" --sort=-refname | head -n 1)
- if [ -z "$SIGN_ME_BRANCH" ]
- then
- switched="false"
- else
- git checkout --track $SIGN_ME_BRANCH
- switched="true"
- fi
- echo "switched=$switched" >> $GITHUB_OUTPUT
-
- - name: âš™ Build metadata-cli
- run: cargo build --release
-
- - name: âš™ Update QRs from RPC nodes
- run: cargo run --release -- update --source node
-
- - name: âš™ Update QRs from GitHub releases
- run: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} cargo run --release -- update --source github
-
- - name: 📌 Commit changes if PR exists
- if: ${{ steps.checkout-pr.outputs.switched == 'true' }}
- uses: ./.github/workflows/commit-changes
- with:
- message: 'metadata update'
-
- - name: New PR branch
- if: ${{ steps.checkout-pr.outputs.switched == 'false' }}
- id: new-branch
- run: |
- NAME="$BRANCH_PREFIX-$(date '+%Y-%m-%d')"
- echo "name=$NAME" >> $GITHUB_OUTPUT
-
- - name: Create Pull Request if not exist
- if: ${{ steps.checkout-pr.outputs.switched == 'false' }}
- id: cpr
- uses: peter-evans/create-pull-request@v4
- with:
- commit-message: add unsigned QR codes
- branch: ${{ steps.new-branch.outputs.name }}
- delete-branch: true
- base: master
- title: '[Automated] Sign new metadata QRs'
- body: |
- Checkout this branch locally and run:
- - [ ] `make signer` to sign files
- - [ ] `make collector` to gather information about current chain versions
- - [ ] `make cleaner` to remove obsolete QRs
- draft: true
-
- - name: Notify Matrix channel
- uses: s3krit/matrix-message-action@v0.0.3
- if: ${{ env.NOTIFY_MATRIX == 'true' && steps.cpr.outputs.pull-request-operation == 'created' }}
- with:
- room_id: ${{ secrets.MATRIX_ROOM_ID }}
- access_token: ${{ secrets.MATRIX_ACCESS_TOKEN }}
- server: ${{ secrets.MATRIX_SERVER }}
- message: "# New metadata is available! 📑
-[GitHub PR#${{ steps.cpr.outputs.pull-request-number }}](${{ steps.cpr.outputs.pull-request-url }})"
-
- check-deployment:
- runs-on: ubuntu-latest
- steps:
- - name: 🛎 Checkout
- uses: actions/checkout@v4
-
- - name: 🔧 Install rust dependencies
- uses: ./.github/workflows/rust-install
-
- - name: âš™ Check existing deployment
- id: check-deployment
- run: |
- cargo run --release -- check-deployment
- exit_code=$?
- if [ $exit_code -eq 12 ]
- then
- echo "redeploy=true" >> $GITHUB_OUTPUT
- exit 0
- fi
- echo "redeploy=false" >> $GITHUB_OUTPUT
- exit $exit_code
- shell: bash {0}
-
- - name: âš™ Run collector
- if: ${{ steps.check-deployment.outputs.redeploy == 'true' }}
- run: make collector
-
- - if: ${{ steps.check-deployment.outputs.redeploy == 'true' }}
- uses: ./.github/workflows/deploy
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index d289dd4f5..c1473b41c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,7 +26,7 @@ yarn-error.log*
.idea
# Auto-generated data file for frontend
-src/chains.json
+public/data.json
# Rust build
target
diff --git a/config.toml b/config.toml
index d96bf606d..d5973c44b 100644
--- a/config.toml
+++ b/config.toml
@@ -1,4 +1,4 @@
-data_file = "src/chains.json"
+data_file = "public/data.json"
public_dir = "public"
qr_dir = "public/qr"
@@ -14,4 +14,4 @@ color = "#fcc367"
[[chains]]
name = "altair"
rpc_endpoint = "wss://fullnode.altair.centrifuge.io"
-color = "#ffb700"
+color = "#ffb700"
\ No newline at end of file
diff --git a/package.json b/package.json
index bb63e7f21..1298507c2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "metadata-portal",
- "version": "0.2.0",
- "homepage": "https://centrifuge.github.io/metadata-portal",
+ "version": "0.1.0",
+ "homepage": "https://centrifuge.github.io/metadata-portal/",
"private": true,
"dependencies": {
"@headlessui/react": "^1.7.10",
diff --git a/public/CNAME b/public/CNAME
index 38593238d..b65eb395e 100644
--- a/public/CNAME
+++ b/public/CNAME
@@ -1 +1 @@
-centrifuge.github.io/metadata-portal
\ No newline at end of file
+centrifuge.github.io/metadata-portal/
\ No newline at end of file
diff --git a/public/portals.json b/public/portals.json
new file mode 100644
index 000000000..31a739de3
--- /dev/null
+++ b/public/portals.json
@@ -0,0 +1,14 @@
+{
+ "parity": {
+ "name": "Parity",
+ "url": "https://metadata.parity.io/"
+ },
+ "k-factory": {
+ "name": "Centrifuge",
+ "url": "https://centrifuge.github.io/metadata-portal/"
+ },
+ "novasama": {
+ "name": "Novasama",
+ "url": "https://metadata.novasama.io/"
+ }
+}
diff --git a/src/assets/icons/vault.svg b/src/assets/icons/vault.svg
new file mode 100644
index 000000000..0f14eab7d
--- /dev/null
+++ b/src/assets/icons/vault.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/About.tsx b/src/components/About.tsx
new file mode 100644
index 000000000..0c7b16b57
--- /dev/null
+++ b/src/components/About.tsx
@@ -0,0 +1,6 @@
+export const About = () => (
+
+ Metadata Portal is a self-hosted web page which shows you the latest
+ metadata for a given network.
+
+);
diff --git a/src/components/AddToSigner.tsx b/src/components/AddToSigner.tsx
deleted file mode 100644
index 07028fd0e..000000000
--- a/src/components/AddToSigner.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Dialog, Transition } from "@headlessui/react";
-import { Fragment, useState } from "react";
-import { QrInfo } from "../scheme";
-
-export default function AddToSigner({ path }: QrInfo) {
- const [isOpen, setIsOpen] = useState(false);
-
- function closeModal() {
- setIsOpen(false);
- }
-
- function openModal() {
- setIsOpen(true);
- }
-
- return (
- <>
-
-
- Add to Signer
-
-
-
-
-
-
-
-
-
-
- {/* This element is to trick the browser into centering the modal contents. */}
-
-
-
-
-
-
- Scan this code with your signer device
-
-
-
-
-
-
-
- Got it, thanks!
-
-
-
-
-
-
-
- >
- );
-}
diff --git a/src/components/App.css b/src/components/App.css
deleted file mode 100644
index 74b5e0534..000000000
--- a/src/components/App.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- height: 40vmin;
- pointer-events: none;
-}
-
-@media (prefers-reduced-motion: no-preference) {
- .App-logo {
- animation: App-logo-spin infinite 20s linear;
- }
-}
-
-.App-header {
- background-color: #282c34;
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
-}
-
-.App-link {
- color: #61dafb;
-}
-
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx
index 13103d27b..0d103c73f 100644
--- a/src/components/App.test.tsx
+++ b/src/components/App.test.tsx
@@ -1,10 +1,15 @@
import { render } from "@testing-library/react";
import App from "./App";
+import { BrowserRouter as Router } from "react-router-dom";
test("renders ok", () => {
- render( );
+ render(
+
+
+
+ );
});
test("data file exists", async () => {
- require("../chains.json");
+ require("../../public/data.json");
});
diff --git a/src/components/App.tsx b/src/components/App.tsx
index e2aa91adc..b61dc9e5e 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1,26 +1,96 @@
-import { Routes, Route, HashRouter } from "react-router-dom";
-import { getChains } from "../scheme";
-import Page from "./Page";
+import { useEffect, useState } from "react";
+import { Chains, Portals } from "../scheme";
+import { About } from "./About";
+import { FAQ } from "./FAQ";
+import { Hr } from "./Hr";
+import { Links } from "./Links";
+import { Network } from "./Network";
+import { NetworkAndPortalSelectMobile } from "./NetworkAndPortalSelectMobile";
+import { NetworkSelect } from "./NetworkSelect";
+import { PortalSelect } from "./PortalSelect";
export default function App() {
- const allChains = getChains();
- const chainsName = Object.keys(allChains);
- const routes = chainsName.map((name) => (
- }
- />
- ));
+ const [chains, setChains] = useState({} as Chains);
+ const [portals, setPortals] = useState({} as Portals);
+ const [currentChain, setCurrentChain] = useState("");
+ const spec = chains[currentChain];
+
+ useEffect(() => {
+ fetch("data.json")
+ .then((res) => res.json())
+ .catch(() => {
+ console.error(
+ "Unable to fetch data file. Run `make collector` to generate it"
+ );
+ })
+ .then(setChains);
+ }, []);
+
+ useEffect(() => {
+ fetch("portals.json")
+ .then((res) => res.json())
+ .catch(() => {
+ console.error("Unable to fetch portals file");
+ })
+ .then(setPortals);
+ }, []);
+
+ useEffect(() => {
+ if (Object.keys(chains).length === 0 || currentChain) return;
+
+ const locationChain = location.hash.replace("#/", "");
+ const network =
+ (Object.keys(chains).includes(locationChain) && locationChain) ||
+ Object.keys(chains)[0];
+ setCurrentChain(network);
+ }, [chains]);
+
+ useEffect(() => {
+ if (currentChain) location.assign("#/" + currentChain);
+ }, [currentChain]);
+
+ if (!spec) return null;
+
return (
-
-
- }
- />
- {routes}
-
-
+
);
}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 000000000..5cb753030
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,26 @@
+interface Props {
+ onClick: () => void;
+ label: string | JSX.Element;
+ className?: string;
+ backgroundColor?: string;
+}
+
+export default function Button({
+ onClick,
+ label,
+ className,
+ backgroundColor,
+}: Props): JSX.Element {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/ChevronIcon.tsx b/src/components/ChevronIcon.tsx
new file mode 100644
index 000000000..df9ce6f7a
--- /dev/null
+++ b/src/components/ChevronIcon.tsx
@@ -0,0 +1,7 @@
+import { ChevronDownIcon } from "@heroicons/react/24/outline";
+
+export const ChevronIcon = () => (
+
+
+
+);
diff --git a/src/components/Copyable.tsx b/src/components/Copyable.tsx
new file mode 100644
index 000000000..9c6d14332
--- /dev/null
+++ b/src/components/Copyable.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useState } from "react";
+import "./SpecsTab.css";
+import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
+
+interface CopyableProps {
+ value: string;
+ slicer: (v: string) => string;
+}
+
+export const copyToClipboard = (text: string): void => {
+ const dummy = document.createElement("textarea");
+ document.body.appendChild(dummy);
+ dummy.value = text;
+ dummy.select();
+ document.execCommand("copy");
+ document.body.removeChild(dummy);
+};
+
+export const hashSlicer = (v: string) =>
+ v.slice(0, 6) + "..." + v.slice(v.length - 4, v.length);
+export const keepHeadSlicer = (i: number) => (v: string) => {
+ if (v.length <= i) {
+ return v;
+ }
+ return v.slice(0, i) + "...";
+};
+
+export default function Copyable({ value, slicer }: CopyableProps) {
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ setTimeout(() => copied && setCopied(false), 2000);
+ }, [copied]);
+
+ const copyable = (el: string) => {
+ const sliced = slicer(el);
+ const cName = copied ? "fade" : "hidden";
+ return (
+
+
{sliced}
+
{
+ setCopied(true);
+ copyToClipboard(el);
+ }}
+ />
+ Copied
+
+ );
+ };
+
+ return copyable(value);
+}
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
new file mode 100644
index 000000000..6daae681c
--- /dev/null
+++ b/src/components/Dropdown.tsx
@@ -0,0 +1,81 @@
+import { Fragment } from "react";
+import { Listbox, Transition } from "@headlessui/react";
+import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
+
+interface Props {
+ selected: T;
+ all: T[];
+ onChange: (item: T) => void;
+ label: string;
+ formatter: (item: T) => string;
+ actionButton?: JSX.Element;
+}
+
+export default function Dropdown({
+ selected,
+ all,
+ onChange,
+ label,
+ formatter,
+ actionButton,
+}: Props): JSX.Element {
+ return (
+
+
+
{label}
+
+
+
+ {formatter(selected)}
+
+
+
+
+
+ {actionButton}
+
+
+
+
+ {all.map((item, idx) => (
+
+ `cursor-default select-none relative py-2 pl-10 pr-4 ${
+ active ? "text-amber-900 bg-amber-100" : "text-gray-900"
+ }`
+ }
+ value={item}
+ >
+ {({ selected }) => (
+ <>
+
+ {formatter(item)}
+
+ {selected ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/Extension.tsx b/src/components/Extension.tsx
new file mode 100644
index 000000000..1dd3cccc6
--- /dev/null
+++ b/src/components/Extension.tsx
@@ -0,0 +1,101 @@
+import { useEffect, useState } from "react";
+import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
+import { web3Enable } from "@polkadot/extension-dapp";
+import {
+ InjectedExtension,
+ InjectedMetadataKnown,
+ MetadataDef,
+} from "@polkadot/extension-inject/types";
+import { ChainSpec } from "../scheme";
+import Dropdown from "./Dropdown";
+import Button from "./Button";
+import { capitalizeFirstLetter } from "../utils";
+
+export default function Extension(chainSpec: ChainSpec) {
+ const [selected, setSelected] = useState(
+ undefined
+ );
+ const [extensions, setExtensions] = useState([]);
+ useEffect(() => {
+ extensionsToUpdate(chainSpec).then((injected) => {
+ setExtensions(injected);
+ setSelected(injected[0]);
+ });
+ }, [chainSpec]);
+
+ if (!selected) {
+ return null;
+ }
+
+ const meta: MetadataDef = {
+ chain: capitalizeFirstLetter(chainSpec.title),
+ genesisHash: chainSpec.genesisHash,
+ icon: chainSpec.logo,
+ specVersion: chainSpec.liveMetaVersion,
+ ss58Format: chainSpec.base58prefix,
+ tokenDecimals: chainSpec.decimals,
+ tokenSymbol: chainSpec.unit,
+ chainType: "substrate",
+ types: {} as unknown as Record,
+ };
+
+ return (
+
+
+ selected={selected}
+ all={extensions}
+ onChange={setSelected}
+ formatter={(item) => `${item.name} ${item.version}`}
+ label="Upgradable extensions:"
+ actionButton={
+ }
+ onClick={() => {
+ selected?.metadata?.provide(meta).then((ok) => {
+ if (ok) {
+ extensionsToUpdate(chainSpec).then((injected) => {
+ setExtensions(injected);
+ setSelected(injected[0]);
+ });
+ }
+ });
+ }}
+ />
+ }
+ />
+
+ );
+}
+
+interface ExtensionWithMeta {
+ injectedExtension: InjectedExtension;
+ metadata: InjectedMetadataKnown[];
+}
+
+async function extensionsToUpdate(
+ chainSpec: ChainSpec
+): Promise {
+ const allInjected = await web3Enable("Metadata Portal");
+
+ const extensions = await Promise.all(
+ allInjected.map(async (injected) => {
+ const metas = await injected.metadata?.get();
+ return {
+ injectedExtension: injected,
+ metadata: metas,
+ } as ExtensionWithMeta;
+ })
+ );
+
+ return extensions
+ .filter((extension) => {
+ const current = extension.metadata.find(
+ ({ genesisHash }) => genesisHash === chainSpec.genesisHash
+ );
+ if (!current) {
+ return true;
+ }
+ return current.specVersion < chainSpec.liveMetaVersion;
+ })
+ .map((extension) => extension.injectedExtension);
+}
diff --git a/src/components/FAQ.tsx b/src/components/FAQ.tsx
new file mode 100644
index 000000000..bb17a20c0
--- /dev/null
+++ b/src/components/FAQ.tsx
@@ -0,0 +1,182 @@
+import { Disclosure } from "@headlessui/react";
+import { ReactNode } from "react";
+import { ChevronIcon } from "./ChevronIcon";
+
+const OrdinalNumber = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+export const FAQ = () => (
+
+
FAQ
+
+
+
+ How Can I Use The Portal?
+
+
+
+
+ The Portal is primarily used to add and update networks for{" "}
+
+ Polkadot Vault cold storage wallet
+
+ . Since Polkadot Vault is always offline and air-gapped, you need to
+ follow a unique process to
+ update the chain metadata so that your transactions are valid.
+
+
+
+
+
+ What Is Polkadot Vault?
+
+
+
+
+ Polkadot Vault is a cold storage solution that turns your iOS or
+ Android device into a dedicated hardware wallet for Polkadot,
+ Kusama, and other Substrate-based chains. Your keys are kept secure
+ (i.e. offline) at all times, and transactions are signed in an
+ air-gapped way via QR-codes.
+
+
+
+
+
+
+ How To Add And Update Networks In Polkadot Vault
+
+
+
+
+
+
+ 1
+
+ The first step is to locate the network that needs metadata
+ updating in Parity or{" "}
+ Novasama . Note: To
+ navigate between the two portals, use the Metadata Portal
+ dropdown at the top of the page.
+
+
+
+ 2
+
+ Then select the{" "}
+ {'"Chain Spec"'} tab
+
+
+
+ 3
+
+ Open the scanner tab from your Polkadot Vault device and scan
+ the network’s{" "}
+ {'"Chain Spec" QR code'}
+
+
+
+ 4
+
+ Review the verifier certificate, and{" "}
+ {'Select "Approve"'}
+
+
+
+ 5
+
+ Select the{" "}
+ {'"Metadata" tab'} at the
+ top of the metadata portal screen
+
+
+
+ 6
+
+ Open the scanner tab in your Polkadot Vault device again and{" "}
+
+ {'scan the network’s "Chain Spec" QR code'}
+
+
+
+
+ 7
+
+ Scan the Metadata QR code
+ . Note: this can take a few minutes to complete.
+
+
+
+ 8
+
+ Finally, review the verifier certificate and{" "}
+ {'Select "Approve"'}
+
+
+
+
+
+
+
+
+ {"I Haven't Found The Network I Need"}
+
+
+
+
+
+ Metadata about networks chain specs is stored in two places:
+
+
+
+ 1
+
+
+ Parity
+ {" "}
+ for Polkadot, Kusama, and Westend
+
+
+
+ 2
+
+
+
+ 2
+
+
+ Novasama
+ {" "}
+ for other Parachains and Solochains
+
+
+
+
+ If you cannot find the network you are looking for, contact the
+ network’s developers directly
+
+
+
+
+
+);
diff --git a/src/components/Hr.tsx b/src/components/Hr.tsx
new file mode 100644
index 000000000..4e29df49b
--- /dev/null
+++ b/src/components/Hr.tsx
@@ -0,0 +1 @@
+export const Hr = () => ;
diff --git a/src/components/Links.tsx b/src/components/Links.tsx
new file mode 100644
index 000000000..1d080bb00
--- /dev/null
+++ b/src/components/Links.tsx
@@ -0,0 +1,22 @@
+const LINKS = [
+ ["GitHub", "https://github.com/paritytech/metadata-portal"],
+ ["Terms of Service", "https://www.parity.io/terms/"],
+];
+
+export const Links = () => {
+ return (
+
+ {LINKS.map(([label, href], i) => (
+
+ {label}
+
+ ))}
+
+ );
+};
diff --git a/src/components/Network.tsx b/src/components/Network.tsx
new file mode 100644
index 000000000..0272a407d
--- /dev/null
+++ b/src/components/Network.tsx
@@ -0,0 +1,243 @@
+import { Tab } from "@headlessui/react";
+import { Fragment, useState } from "react";
+import { ChainSpec, RpcSource, WasmSource } from "../scheme";
+import { cn, formatTitle } from "../utils";
+import Copyable, { hashSlicer, keepHeadSlicer } from "./Copyable";
+import { Hr } from "./Hr";
+import { Links } from "./Links";
+import { Row } from "./Row";
+import {
+ CheckBadgeIcon,
+ ExclamationCircleIcon,
+} from "@heroicons/react/24/solid";
+
+function tabFromSearch() {
+ const params = new URLSearchParams(location.search);
+ const tab = parseInt(params.get("tab") || "0", 10);
+
+ return tab === 0 || tab === 1 ? tab : 0;
+}
+
+function setTabToSearch(v: number) {
+ const url = new URL(location.href);
+ url.searchParams.set("tab", v.toString());
+ window.history.replaceState(null, "", url);
+}
+
+export const Network = ({ spec }: { spec: ChainSpec }) => {
+ const [selectedTab, setSelectedTab] = useState(tabFromSearch());
+ const metadataQr = spec.metadataQr;
+
+ function updateTab(v: number) {
+ setTabToSearch(v);
+ setSelectedTab(v);
+ }
+ const svgClass = "inline mr-2 h-7";
+ const vaultLink = (
+
+ Polkadot Vault
+
+ );
+
+ const createGithubIssueLink = (
+
+ Create a Github issue
+
+ );
+
+ return (
+
+
+
+ {formatTitle(spec.title)}
+
+
+
+
+
+
+ {selectedTab === 0 && (
+
+
+
+ {spec.specsQr.signedBy ? (
+
+
+ Signed by {spec.specsQr.signedBy}
+
+ ) : (
+
+
+ Unsigned
+
+ )}
+
+
+ {"Scan this code to add chain specs to the "}
+ {vaultLink}
+
+
+ )}
+ {selectedTab === 1 && (
+
+ {!metadataQr && (
+
+
+ The metadata for {spec.title} Network is out of date.
+ Request the new metadata version by creating a Github
+ issue.
+ {createGithubIssueLink}
+
+
+ )}
+ {metadataQr && (
+
+
+
+ {spec.metadataQr?.file.signedBy ? (
+
+
+ Signed by {spec.metadataQr?.file.signedBy}
+
+ ) : (
+
+
+ Unsigned
+
+ )}
+
+
+ {"Scan this code to update "}
+ {vaultLink}
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {["Chain Specs", "Metadata"].map((title) => (
+
+ {({ selected }) => (
+
+ {title}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {spec.base58prefix}
+ {spec.unit}
+ {spec.liveMetaVersion}
+
+
+
+ {metadataQr && (
+
+ {metadataQr?.file.source?.type === "Wasm" && (
+
+ )}
+ {metadataQr?.file.source?.type === "Rpc" && (
+
+ )}
+
#{metadataQr?.version}
+
{metadataQr?.file.signedBy}
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/NetworkAndPortalSelectMobile.tsx b/src/components/NetworkAndPortalSelectMobile.tsx
new file mode 100644
index 000000000..0625353ce
--- /dev/null
+++ b/src/components/NetworkAndPortalSelectMobile.tsx
@@ -0,0 +1,116 @@
+import { Listbox } from "@headlessui/react";
+import { XMarkIcon } from "@heroicons/react/24/solid";
+import { Chains, Portals } from "../scheme";
+import { cn, currentPortalKey, formatTitle } from "../utils";
+import { ChevronIcon } from "./ChevronIcon";
+import { Hr } from "./Hr";
+
+export const NetworkAndPortalSelectMobile = ({
+ chains,
+ portals,
+ currentChain,
+ onSelect,
+}: {
+ chains: Chains;
+ portals: Portals;
+ currentChain: string;
+ onSelect: (v: string) => void;
+}) => {
+ const current = currentPortalKey(portals);
+ const hasManyPortals = Boolean(current) && Object.keys(portals).length > 1;
+
+ return (
+
+ {hasManyPortals && (
+ <>
+
+
+
+
+ Metadata Portal
+
+
+ {portals[current].name}
+
+
+
+
+
+
Metadata Portal
+
+
+
+
+ {Object.keys(portals).map((portal) => (
+
+ {({ selected }) =>
+ selected ? (
+
+
{portals[portal].name}
+
+ ) : (
+
+ {portals[portal].name}
+
+ )
+ }
+
+ ))}
+
+
+
+
+ >
+ )}
+
+
+
+
+ Selected Network
+
+
+ {formatTitle(chains[currentChain]?.title)}
+
+
+
+
+
+
Selected Network
+
+
+
+
+ {Object.keys(chains).map((chain) => (
+
+ {({ selected }) => (
+
+
+ {formatTitle(chains[chain].title)}
+
+
+ )}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/NetworkSelect.tsx b/src/components/NetworkSelect.tsx
new file mode 100644
index 000000000..44ab46780
--- /dev/null
+++ b/src/components/NetworkSelect.tsx
@@ -0,0 +1,60 @@
+import { Listbox } from "@headlessui/react";
+import { Chains } from "../scheme";
+import { cn, formatTitle } from "../utils";
+import { useState } from "react";
+import { SearchBar } from "./SearchBar";
+
+export const NetworkSelect = ({
+ chains,
+ currentChain,
+ onSelect,
+}: {
+ chains: Chains;
+ currentChain: string;
+ onSelect: (v: string) => void;
+}) => {
+ const chainList = Object.keys(chains);
+ const [searchString, setSearchString] = useState("");
+
+ const handleSearch = (event: React.ChangeEvent) => {
+ setSearchString(event.target.value);
+ };
+
+ const filteredItems = chainList.filter((item) =>
+ item.toLowerCase().includes(searchString.toLowerCase())
+ );
+ return (
+
+
Networks
+ {chainList.length > 10 && (
+
+ )}
+
+
+ {filteredItems.map((chain) => (
+
+ {({ selected }) => (
+
+
+ {formatTitle(chains[chain].title)}
+
+
+ )}
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Page.tsx b/src/components/Page.tsx
deleted file mode 100644
index 0c731dff4..000000000
--- a/src/components/Page.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Chains } from "../scheme";
-import Selector from "./Selector";
-import QrCode from "./QrCode";
-import Specs from "./Specs";
-import AddToSigner from "./AddToSigner";
-
-interface Props {
- allChains: Chains;
- currentName: string;
-}
-
-export default function Page({ allChains, currentName }: Props) {
- const chain = allChains[currentName];
- const metadataQr = chain.metadataQr;
- const specsQr = chain.specsQr;
- document.body.style.backgroundColor = chain.color;
- return (
-
-
-
-
-
- {metadataQr && (
- <>
-
-
-
- Metadata #{metadataQr.version}
-
-
- {specsQr &&
}
-
- >
- )}
-
-
- );
-}
diff --git a/src/components/PortalSelect.tsx b/src/components/PortalSelect.tsx
new file mode 100644
index 000000000..835b37a24
--- /dev/null
+++ b/src/components/PortalSelect.tsx
@@ -0,0 +1,74 @@
+import { Listbox, Transition } from "@headlessui/react";
+import { Fragment } from "react";
+import { Portals } from "../scheme";
+import { cn, currentPortalKey } from "../utils";
+import { ChevronIcon } from "./ChevronIcon";
+
+export const PortalSelect = ({ portals }: { portals: Portals }) => {
+ const current = currentPortalKey(portals);
+ const hasManyPortals = Boolean(current) && Object.keys(portals).length > 1;
+
+ if (!hasManyPortals) return null;
+
+ return (
+
+
+
+ cn(
+ "w-full bordered-action py-3 hover:bg-neutral-100 transition-colors",
+ open && "bg-neutral-100"
+ )
+ }
+ >
+
+
+
+ Metadata Portal
+
+ {portals[current].name}
+
+
+
+
+
+
+ {Object.keys(portals).map((portal) => (
+
+ {({ selected }) =>
+ selected ? (
+
+ {portals[portal].name}
+
+ ) : (
+
+ {portals[portal].name}
+
+ )
+ }
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/QrCode.tsx b/src/components/QrCode.tsx
deleted file mode 100644
index 16a19fcc6..000000000
--- a/src/components/QrCode.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { QrInfo } from "../scheme";
-import {
- CheckBadgeIcon,
- ExclamationCircleIcon,
-} from "@heroicons/react/24/solid";
-
-export default function QrCode({ path, signedBy }: QrInfo) {
- const svgClass = "inline mr-2 h-7";
- return (
-
-
-
- {signedBy ? (
-
-
- Signed by {signedBy}
-
- ) : (
-
-
- Unsigned
-
- )}
-
-
- );
-}
diff --git a/src/components/Row.tsx b/src/components/Row.tsx
new file mode 100644
index 000000000..e897f8c47
--- /dev/null
+++ b/src/components/Row.tsx
@@ -0,0 +1,16 @@
+import { ReactNode } from "react";
+
+export function Row({
+ title,
+ children,
+}: {
+ title: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx
new file mode 100644
index 000000000..50707244e
--- /dev/null
+++ b/src/components/SearchBar.tsx
@@ -0,0 +1,62 @@
+import { cn } from "../utils";
+import { ChangeEventHandler } from "react";
+
+export const SearchBar = ({
+ searchString,
+ setSearchString,
+ onChange,
+}: {
+ searchString: string;
+ setSearchString: (v: string) => void;
+ onChange: ChangeEventHandler;
+}) => {
+ return (
+
+
+
+
+
setSearchString("")}
+ >
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Selector.tsx b/src/components/Selector.tsx
deleted file mode 100644
index 166ce0223..000000000
--- a/src/components/Selector.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { Menu, Transition } from "@headlessui/react";
-import { Fragment } from "react";
-import { ChevronDownIcon } from "@heroicons/react/24/solid";
-import { Link } from "react-router-dom";
-import { Chains, ChainSpec } from "../scheme";
-
-interface Props {
- allChains: Chains;
- selectedName: string;
-}
-
-function capitalizeFirstLetter(string: string) {
- return string.charAt(0).toUpperCase() + string.slice(1);
-}
-
-function ChainLogo(props: { chain: ChainSpec; className?: string }) {
- return (
-
- {props.chain.title}
-
- );
-}
-
-export default function Selector({ selectedName, allChains }: Props) {
- const selected = allChains[selectedName];
- const dropdownItems = Object.keys(allChains).map((name) => {
- return (
-
- {({ active }) => (
-
-
-
- {capitalizeFirstLetter(name)}
-
-
- )}
-
- );
- });
- return (
-
-
-
-
-
- {capitalizeFirstLetter(selected.title)}
-
-
-
-
-
- {dropdownItems}
-
-
-
-
- );
-}
diff --git a/src/components/Specs.tsx b/src/components/Specs.tsx
deleted file mode 100644
index a594d89eb..000000000
--- a/src/components/Specs.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { ChainSpec } from "../scheme";
-
-export default function Specs(chainSpec: ChainSpec) {
- return (
-
- {row("RPC endpoint", chainSpec.rpcEndpoint)}
- {row("Genesis hash", chainSpec.genesisHash)}
- {row("Color", chainSpec.color)}
- {row("Unit", chainSpec.unit)}
- {row("Address prefix", chainSpec.base58prefix)}
-
- );
-}
-
-function row(title: string, value: string | number) {
- return (
-
- {title}
- {value}
-
- );
-}
diff --git a/src/components/SpecsTab.css b/src/components/SpecsTab.css
new file mode 100644
index 000000000..afb50e88d
--- /dev/null
+++ b/src/components/SpecsTab.css
@@ -0,0 +1,11 @@
+.fade {
+ animation: fade-in-keyframes 0.3s alternate;
+}
+@keyframes fade-in-keyframes {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
diff --git a/src/fonts/Web3-Regular.woff2 b/src/fonts/Web3-Regular.woff2
deleted file mode 100644
index adf372c01..000000000
Binary files a/src/fonts/Web3-Regular.woff2 and /dev/null differ
diff --git a/src/index.css b/src/index.css
index d7fd1bd2b..dc52bcac0 100644
--- a/src/index.css
+++ b/src/index.css
@@ -2,25 +2,28 @@
@tailwind components;
@tailwind utilities;
+@import url("https://fonts.googleapis.com/css2?family=Inter&family=Unbounded&display=swap");
+/*
@font-face {
font-family: "Web3-Regular";
- src: url("./fonts/Web3-Regular.woff2") format("woff2");
+ src: url("./assets/fonts/Web3-Regular.woff") format("woff"),
+ url("./assets/fonts/Web3-Regular.woff2") format("woff2");
}
+.web3-regular {
+ font-family: "Web3-Regular";
+} */
+
body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
- "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ font-family: "Inter";
}
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
- monospace;
+.unbounded {
+ font-family: "Unbounded";
}
-.web3-icon {
- font-family: "Web3-Regular";
+@layer components {
+ .bordered-action {
+ @apply px-4 py-2 border border-neutral-200 rounded-4xl;
+ }
}
diff --git a/src/index.tsx b/src/index.tsx
index e11120a0f..1f7c1e6b9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,15 +1,13 @@
import React from "react";
import { createRoot } from "react-dom/client";
-import "./index.css";
+import { BrowserRouter as Router } from "react-router-dom";
import App from "./components/App";
+import "./index.css";
-// Assert that the element is non-null with `!`
-const container = document.getElementById("root")!;
-// Now TypeScript knows `container` is not null
-const root = createRoot(container);
-
-root.render(
-
-
-
+createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+
+
+
);
diff --git a/src/scheme.ts b/src/scheme.ts
index 3f8f05f35..6ef38dc72 100644
--- a/src/scheme.ts
+++ b/src/scheme.ts
@@ -1,5 +1,3 @@
-import jsonData from "./chains.json"; // Dynamically generated datafile. Run `make collector` to create
-
export interface ChainSpec {
title: string;
color: string;
@@ -43,19 +41,20 @@ export interface RpcSource extends SourceBase {
block: string;
}
-export interface Chains {
- [title: string]: ChainSpec;
+export interface AddToSignerInterface {
+ path: string;
+ color: string;
+ name: string;
}
-export function getChains(): Chains {
- const chainList = Object.values(jsonData).map((chain: object) =>
- Object.assign({} as ChainSpec, chain)
- );
+export interface Chains {
+ [name: string]: ChainSpec;
+}
- return chainList.reduce((obj: Chains, chain: ChainSpec) => {
- return {
- ...obj,
- [chain.title]: chain,
- };
- }, {});
+export type Portal = {
+ name: string;
+ url: string;
+};
+export interface Portals {
+ [name: string]: Portal;
}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 000000000..452a5725a
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,25 @@
+import { Portals } from "./scheme";
+
+export function capitalizeFirstLetter(string: string) {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+}
+
+export function formatTitle(title: string) {
+ return title
+ .split(" ")
+ .map((v) => capitalizeFirstLetter(v))
+ .join(" ");
+}
+
+export function cn(...classes: (string | boolean | undefined)[]) {
+ return classes.filter(Boolean).join(" ");
+}
+
+export function currentPortalKey(portals: Portals) {
+ const keys = Object.keys(portals);
+
+ return (
+ keys.find((key) => new URL(portals[key].url).host === location.host) ||
+ keys[0]
+ );
+}