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 ( - <> -
- -
- - - -
- - - - - {/* This element is to trick the browser into centering the modal contents. */} - - -
- - Scan this code with your signer device - -
- Qr code -
- -
- -
-
-
-
-
-
- - ); -} 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 ( + + ); +} 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={ +
+ ); +} + +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. + 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. +
  3. + 2 +
    + Then select the{" "} + {'"Chain Spec"'} tab +
    +
  4. +
  5. + 3 +
    + Open the scanner tab from your Polkadot Vault device and scan + the network’s{" "} + {'"Chain Spec" QR code'} +
    +
  6. +
  7. + 4 +
    + Review the verifier certificate, and{" "} + {'Select "Approve"'} +
    +
  8. +
  9. + 5 +
    + Select the{" "} + {'"Metadata" tab'} at the + top of the metadata portal screen +
    +
  10. +
  11. + 6 +
    + Open the scanner tab in your Polkadot Vault device again and{" "} + + {'scan the network’s "Chain Spec" QR code'} + +
    +
  12. +
  13. + 7 +
    + Scan the Metadata QR code + . Note: this can take a few minutes to complete. +
    +
  14. +
  15. + 8 +
    + Finally, review the verifier certificate and{" "} + {'Select "Approve"'} +
    +
  16. +
+
+
+ + +

+ {"I Haven't Found The Network I Need"} +

+ +
+ +

+ Metadata about networks chain specs is stored in two places: +

+
    +
  1. + 1 +
    + + Parity + {" "} + for Polkadot, Kusama, and Westend +
    +
  2. +
  3. + 2 +
    + + Centrifuge + {" "} + for Centrifuge and Altair parachains +
    +
  4. +
  5. + 2 +
    + + Novasama + {" "} + for other Parachains and Solochains +
    +
  6. +
+

+ 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 && ( +
+ Qr code +
+ {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 && ( +
+ metadata qr code +
+ {spec.metadataQr?.file.signedBy ? ( +
+ + Signed by {spec.metadataQr?.file.signedBy} +
+ ) : ( +
+ + Unsigned +
+ )} +
+
+ {"Scan this code to update "} + {vaultLink} +
+
+ )} +
+ )} +
+
+
+ + + {["Chain Specs", "Metadata"].map((title) => ( + + {({ selected }) => ( + + )} + + ))} + +
+
+
+ + +
    + + + + + + + +
    +
    {spec.color}
    +
    +
    + + {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 ( -
- metadata qr code -
- {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 ( +
    +
    +
    + +
    + + +
    +
    + ); +}; 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 }) => ( - - - - )} - - ); - }); - 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] + ); +}