Pārlūkot izejas kodu

feat(insights): initial app shell & index pages

Connor Prussin 1 gadu atpakaļ
vecāks
revīzija
4a605875c3
89 mainītis faili ar 3451 papildinājumiem un 253 dzēšanām
  1. 1 1
      .nvmrc
  2. 2 2
      .tool-versions
  3. 2 2
      Dockerfile.node
  4. 15 1
      apps/insights/package.json
  5. 1 0
      apps/insights/src/app/loading.tsx
  6. 1 1
      apps/insights/src/app/page.ts
  7. 1 0
      apps/insights/src/app/price-feeds/layout.ts
  8. 1 0
      apps/insights/src/app/price-feeds/loading.ts
  9. 1 0
      apps/insights/src/app/price-feeds/page.ts
  10. 1 0
      apps/insights/src/app/publishers/layout.ts
  11. 1 0
      apps/insights/src/app/publishers/loading.ts
  12. 1 0
      apps/insights/src/app/publishers/page.ts
  13. 5 0
      apps/insights/src/clickhouse.ts
  14. 78 0
      apps/insights/src/components/CopyButton/index.tsx
  15. 4 2
      apps/insights/src/components/Error/index.tsx
  16. 8 0
      apps/insights/src/components/H1/index.tsx
  17. 0 5
      apps/insights/src/components/Home/index.tsx
  18. 12 0
      apps/insights/src/components/Loading/index.tsx
  19. 9 0
      apps/insights/src/components/MaxWidth/index.tsx
  20. 4 2
      apps/insights/src/components/NotFound/index.tsx
  21. 8 0
      apps/insights/src/components/Overview/index.tsx
  22. 186 0
      apps/insights/src/components/Paginator/index.tsx
  23. 18 0
      apps/insights/src/components/PriceFeeds/columns.ts
  24. 38 0
      apps/insights/src/components/PriceFeeds/epoch-select.tsx
  25. 81 0
      apps/insights/src/components/PriceFeeds/index.tsx
  26. 19 0
      apps/insights/src/components/PriceFeeds/layout.tsx
  27. 40 0
      apps/insights/src/components/PriceFeeds/loading.tsx
  28. 143 0
      apps/insights/src/components/PriceFeeds/prices.tsx
  29. 197 0
      apps/insights/src/components/PriceFeeds/results.tsx
  30. 21 0
      apps/insights/src/components/Publishers/columns.ts
  31. 38 0
      apps/insights/src/components/Publishers/epoch-select.tsx
  32. 93 0
      apps/insights/src/components/Publishers/index.tsx
  33. 43 0
      apps/insights/src/components/Publishers/layout.tsx
  34. 23 0
      apps/insights/src/components/Publishers/loading.tsx
  35. 102 0
      apps/insights/src/components/Publishers/results.tsx
  36. 87 0
      apps/insights/src/components/Root/footer.tsx
  37. 51 0
      apps/insights/src/components/Root/header.tsx
  38. 18 8
      apps/insights/src/components/Root/index.tsx
  39. 4 0
      apps/insights/src/components/Root/logo.svg
  40. 48 0
      apps/insights/src/components/Root/mobile-menu.tsx
  41. 30 0
      apps/insights/src/components/Root/nav-link.tsx
  42. 15 0
      apps/insights/src/components/Root/orb.svg
  43. 92 0
      apps/insights/src/components/Root/tabs.tsx
  44. 40 0
      apps/insights/src/components/Root/theme-switch.tsx
  45. 3 0
      apps/insights/src/components/Root/wordmark.svg
  46. 27 20
      apps/insights/src/config/server.ts
  47. 972 0
      apps/insights/src/icons.tsx
  48. 24 0
      apps/insights/src/pyth.ts
  49. 18 0
      apps/insights/src/zod-utils.ts
  50. 1 0
      apps/insights/tailwind.config.ts
  51. 14 2
      apps/insights/turbo.json
  52. 3 3
      flake.lock
  53. 2 2
      governance/xc_admin/packages/xc_admin_frontend/package.json
  54. 3 3
      package.json
  55. 4 0
      packages/component-library/package.json
  56. 23 21
      packages/component-library/src/Button/index.tsx
  57. 37 0
      packages/component-library/src/Card/index.tsx
  58. 4 2
      packages/component-library/src/Link/index.tsx
  59. 103 0
      packages/component-library/src/Select/index.tsx
  60. 12 0
      packages/component-library/src/Skeleton/index.tsx
  61. 134 0
      packages/component-library/src/Table/index.tsx
  62. 9 0
      packages/component-library/src/UnstyledButton/index.tsx
  63. 1 1
      packages/component-library/src/UnstyledLink/index.tsx
  64. 16 0
      packages/component-library/src/UnstyledTable/index.tsx
  65. 9 0
      packages/component-library/src/UnstyledToolbar/index.tsx
  66. 15 1
      packages/component-library/tailwind.config.ts
  67. 2 0
      packages/known-publishers/.prettierignore
  68. 1 0
      packages/known-publishers/README.md
  69. 1 0
      packages/known-publishers/eslint.config.js
  70. 1 0
      packages/known-publishers/jest.config.js
  71. 32 0
      packages/known-publishers/package.json
  72. 1 0
      packages/known-publishers/prettier.config.js
  73. 6 0
      packages/known-publishers/src/icons/blocksize.svg
  74. 27 0
      packages/known-publishers/src/icons/color/finazon.svg
  75. 12 0
      packages/known-publishers/src/icons/color/sentio.svg
  76. 1 0
      packages/known-publishers/src/icons/elfomo.svg
  77. 5 0
      packages/known-publishers/src/icons/finazon.svg
  78. 6 0
      packages/known-publishers/src/icons/monochrome/blocksize.svg
  79. 1 0
      packages/known-publishers/src/icons/monochrome/elfomo.svg
  80. 5 0
      packages/known-publishers/src/icons/monochrome/finazon.svg
  81. 4 0
      packages/known-publishers/src/icons/monochrome/sentio.svg
  82. 4 0
      packages/known-publishers/src/icons/sentio.svg
  83. 42 0
      packages/known-publishers/src/index.tsx
  84. 6 0
      packages/known-publishers/svg.d.ts
  85. 3 0
      packages/known-publishers/tsconfig.json
  86. 2 1
      packages/next-root/package.json
  87. 11 2
      packages/next-root/src/index.tsx
  88. 252 170
      pnpm-lock.yaml
  89. 9 1
      pnpm-workspace.yaml

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-v20.17.0
+v20.18.0

+ 2 - 2
.tool-versions

@@ -1,4 +1,4 @@
-nodejs 20.17.0
-pnpm 9.12.1
+nodejs 20.18.0
+pnpm 9.12.3
 rust 1.78.0
 python 3.12.4

+ 2 - 2
Dockerfile.node

@@ -1,4 +1,4 @@
-FROM node:20.17.0-slim@sha256:2394e403d45a644e41ac2a15b6f843a7d4a99ad24be48c27982c5fdc61a1ef17 as builder-base
+FROM node:20.18.0-slim@sha256:ec35a66c9a0a275b027debde05247c081f8b2f0c43d7399d3a6ad5660cee2f6a as builder-base
 WORKDIR /usr/src/pyth
 ENV PNPM_HOME="/pnpm"
 ENV PATH="$PNPM_HOME:$PATH"
@@ -7,7 +7,7 @@ COPY ./ .
 RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
 
 
-FROM node:20.17.0-alpine@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 as runner-base
+FROM node:20.18.0-alpine3.20@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 as runner-base
 WORKDIR /srv
 ENV NODE_ENV production
 RUN addgroup --system --gid 1001 pyth && adduser --system --uid 1001 pyth -g pyth && chown pyth:pyth .

+ 15 - 1
apps/insights/package.json

@@ -10,6 +10,7 @@
     "build": "next build",
     "fix:format": "prettier --write .",
     "fix:lint": "eslint --fix .",
+    "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_TBkf9EyQjQF37gs4Vk0sQKJj97kE vercel env pull",
     "start:dev": "next dev --port 3003",
     "start:prod": "next start --port 3003",
     "test:format": "prettier --check .",
@@ -17,14 +18,27 @@
     "test:types": "tsc"
   },
   "dependencies": {
+    "@clickhouse/client": "catalog:",
+    "@phosphor-icons/react": "catalog:",
     "@pythnetwork/app-logger": "workspace:*",
+    "@pythnetwork/client": "catalog:",
     "@pythnetwork/component-library": "workspace:*",
     "@pythnetwork/fonts": "workspace:*",
+    "@pythnetwork/known-publishers": "workspace:*",
     "@pythnetwork/next-root": "workspace:*",
+    "@react-hookz/web": "catalog:",
+    "@solana/web3.js": "catalog:",
     "clsx": "catalog:",
+    "cryptocurrency-icons": "catalog:",
+    "framer-motion": "catalog:",
     "next": "catalog:",
+    "next-themes": "catalog:",
+    "nuqs": "catalog:",
     "react": "catalog:",
-    "react-dom": "catalog:"
+    "react-aria": "catalog:",
+    "react-aria-components": "catalog:",
+    "react-dom": "catalog:",
+    "zod": "catalog:"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 1 - 0
apps/insights/src/app/loading.tsx

@@ -0,0 +1 @@
+export { Loading as default } from "../components/Loading";

+ 1 - 1
apps/insights/src/app/page.ts

@@ -1 +1 @@
-export { Home as default } from "../components/Home";
+export { Overview as default } from "../components/Overview";

+ 1 - 0
apps/insights/src/app/price-feeds/layout.ts

@@ -0,0 +1 @@
+export { PriceFeedsLayout as default } from "../../components/PriceFeeds/layout";

+ 1 - 0
apps/insights/src/app/price-feeds/loading.ts

@@ -0,0 +1 @@
+export { PriceFeedsLoading as default } from "../../components/PriceFeeds/loading";

+ 1 - 0
apps/insights/src/app/price-feeds/page.ts

@@ -0,0 +1 @@
+export { PriceFeeds as default } from "../../components/PriceFeeds";

+ 1 - 0
apps/insights/src/app/publishers/layout.ts

@@ -0,0 +1 @@
+export { PublishersLayout as default } from "../../components/Publishers/layout";

+ 1 - 0
apps/insights/src/app/publishers/loading.ts

@@ -0,0 +1 @@
+export { PublishersLoading as default } from "../../components/Publishers/loading";

+ 1 - 0
apps/insights/src/app/publishers/page.ts

@@ -0,0 +1 @@
+export { Publishers as default } from "../../components/Publishers";

+ 5 - 0
apps/insights/src/clickhouse.ts

@@ -0,0 +1,5 @@
+import { createClient } from "@clickhouse/client";
+
+import { CLICKHOUSE } from "./config/server";
+
+export const client = createClient(CLICKHOUSE);

+ 78 - 0
apps/insights/src/components/CopyButton/index.tsx

@@ -0,0 +1,78 @@
+"use client";
+
+import { Copy, Check } from "@phosphor-icons/react/dist/ssr";
+import { useLogger } from "@pythnetwork/app-logger";
+import { UnstyledButton } from "@pythnetwork/component-library/UnstyledButton";
+import clsx from "clsx";
+import { type ComponentProps, useCallback, useEffect, useState } from "react";
+
+type CopyButtonProps = ComponentProps<typeof UnstyledButton> & {
+  text: string;
+};
+
+export const CopyButton = ({
+  text,
+  children,
+  className,
+  ...props
+}: CopyButtonProps) => {
+  const [isCopied, setIsCopied] = useState(false);
+  const logger = useLogger();
+  const copy = useCallback(() => {
+    // eslint-disable-next-line n/no-unsupported-features/node-builtins
+    navigator.clipboard
+      .writeText(text)
+      .then(() => {
+        setIsCopied(true);
+      })
+      .catch((error: unknown) => {
+        /* TODO do something here? */
+        logger.error(error);
+      });
+  }, [text, logger]);
+
+  useEffect(() => {
+    setIsCopied(false);
+  }, [text]);
+
+  useEffect(() => {
+    if (isCopied) {
+      const timeout = setTimeout(() => {
+        setIsCopied(false);
+      }, 2000);
+      return () => {
+        clearTimeout(timeout);
+      };
+    } else {
+      return;
+    }
+  }, [isCopied]);
+
+  return (
+    <UnstyledButton
+      onPress={copy}
+      isDisabled={isCopied}
+      className={clsx(
+        "group/copy-button mx-[-0.5em] -mt-0.5 inline-block whitespace-nowrap rounded-md px-[0.5em] py-0.5 outline-none outline-0 outline-steel-600 transition data-[hovered]:bg-black/5 data-[focus-visible]:outline-2 dark:outline-steel-300 dark:data-[hovered]:bg-white/10",
+        className,
+      )}
+      {...(isCopied && { "data-is-copied": true })}
+      {...props}
+    >
+      {(...args) => (
+        <>
+          <span>
+            {typeof children === "function" ? children(...args) : children}
+          </span>
+          <span className="relative top-[0.125em] ml-1 inline-block">
+            <span className="opacity-50 transition-opacity duration-100 group-data-[is-copied]/copy-button:opacity-0">
+              <Copy className="size-[1em]" />
+              <div className="sr-only">Copy to clipboard</div>
+            </span>
+            <Check className="absolute inset-0 text-green-600 opacity-0 transition-opacity duration-100 group-data-[is-copied]/copy-button:opacity-100" />
+          </span>
+        </>
+      )}
+    </UnstyledButton>
+  );
+};

+ 4 - 2
apps/insights/src/components/Error/index.tsx

@@ -2,6 +2,8 @@ import { useLogger } from "@pythnetwork/app-logger";
 import { Button } from "@pythnetwork/component-library/Button";
 import { useEffect } from "react";
 
+import { MaxWidth } from "../MaxWidth";
+
 type Props = {
   error: Error & { digest?: string };
   reset?: () => void;
@@ -15,13 +17,13 @@ export const Error = ({ error, reset }: Props) => {
   }, [error, logger]);
 
   return (
-    <main>
+    <MaxWidth>
       <h1>Uh oh!</h1>
       <h2>Something went wrong</h2>
       <p>
         Error Details: <strong>{error.digest ?? error.message}</strong>
       </p>
       {reset && <Button onPress={reset}>Reset</Button>}
-    </main>
+    </MaxWidth>
   );
 };

+ 8 - 0
apps/insights/src/components/H1/index.tsx

@@ -0,0 +1,8 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+export const H1 = ({ className, children, ...props }: ComponentProps<"h1">) => (
+  <h1 className={clsx(className, "text-2xl font-medium")} {...props}>
+    {children}
+  </h1>
+);

+ 0 - 5
apps/insights/src/components/Home/index.tsx

@@ -1,5 +0,0 @@
-export const Home = () => (
-  <main>
-    <h1>Hello world!</h1>
-  </main>
-);

+ 12 - 0
apps/insights/src/components/Loading/index.tsx

@@ -0,0 +1,12 @@
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+
+import { H1 } from "../H1";
+import { MaxWidth } from "../MaxWidth";
+
+export const Loading = () => (
+  <MaxWidth>
+    <H1>
+      <Skeleton className="w-60" />
+    </H1>
+  </MaxWidth>
+);

+ 9 - 0
apps/insights/src/components/MaxWidth/index.tsx

@@ -0,0 +1,9 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+export const MaxWidth = ({ className, ...props }: ComponentProps<"div">) => (
+  <div
+    className={clsx("mx-auto box-content max-w-screen-2xl px-6", className)}
+    {...props}
+  />
+);

+ 4 - 2
apps/insights/src/components/NotFound/index.tsx

@@ -1,9 +1,11 @@
 import { ButtonLink } from "@pythnetwork/component-library/Button";
 
+import { MaxWidth } from "../MaxWidth";
+
 export const NotFound = () => (
-  <main>
+  <MaxWidth>
     <h1>Not Found</h1>
     <p>{"The page you're looking for isn't here"}</p>
     <ButtonLink href="/">Go Home</ButtonLink>
-  </main>
+  </MaxWidth>
 );

+ 8 - 0
apps/insights/src/components/Overview/index.tsx

@@ -0,0 +1,8 @@
+import { H1 } from "../H1";
+import { MaxWidth } from "../MaxWidth";
+
+export const Overview = () => (
+  <MaxWidth>
+    <H1>Overview</H1>
+  </MaxWidth>
+);

+ 186 - 0
apps/insights/src/components/Paginator/index.tsx

@@ -0,0 +1,186 @@
+import {
+  CaretLeft,
+  CaretRight,
+  CircleNotch,
+} from "@phosphor-icons/react/dist/ssr";
+import { ButtonLink } from "@pythnetwork/component-library/Button";
+import { Select } from "@pythnetwork/component-library/Select";
+import { UnstyledToolbar } from "@pythnetwork/component-library/UnstyledToolbar";
+import {
+  type ComponentProps,
+  useTransition,
+  useMemo,
+  useCallback,
+} from "react";
+
+type Props = {
+  numPages: number;
+  currentPage: number;
+  setCurrentPage: (newPage: number) => void;
+  pageSize: number;
+  setPageSize: (newPageSize: number) => void;
+  mkPageLink: (page: number) => string;
+};
+
+export const Paginator = ({
+  numPages,
+  currentPage,
+  pageSize,
+  setCurrentPage,
+  setPageSize,
+  mkPageLink,
+}: Props) => (
+  <div className="flex flex-row justify-between p-4">
+    <PageSizeSelect pageSize={pageSize} setPageSize={setPageSize} />
+    {numPages > 1 && (
+      <PaginatorToolbar
+        currentPage={currentPage}
+        numPages={numPages}
+        setCurrentPage={setCurrentPage}
+        mkPageLink={mkPageLink}
+      />
+    )}
+  </div>
+);
+
+type PageSizeSelectProps = {
+  pageSize: number;
+  setPageSize: (newPageSize: number) => void;
+};
+
+const PageSizeSelect = ({ pageSize, setPageSize }: PageSizeSelectProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+
+  const onChange = useCallback(
+    (newPageSize: number) => {
+      startTransition(() => {
+        setPageSize(newPageSize);
+      });
+    },
+    [startTransition, setPageSize],
+  );
+
+  return (
+    <div className="flex flex-row items-center gap-1">
+      <Select
+        label="Page size"
+        hideLabel
+        options={[10, 20, 50, 100] as const}
+        selectedKey={pageSize}
+        onSelectionChange={onChange}
+        show={(value) => `${value.toString()} per page`}
+        variant="ghost"
+        size="xs"
+      />
+      {isTransitioning && <CircleNotch className="size-4 animate-spin" />}
+    </div>
+  );
+};
+
+type PaginatorProps = {
+  numPages: number;
+  currentPage: number;
+  setCurrentPage: (newPage: number) => void;
+  mkPageLink: (page: number) => string;
+};
+
+const PaginatorToolbar = ({
+  numPages,
+  currentPage,
+  setCurrentPage,
+  mkPageLink,
+}: PaginatorProps) => {
+  const first =
+    currentPage <= 3 || numPages <= 5
+      ? 1
+      : currentPage - 2 - Math.max(2 - (numPages - currentPage), 0);
+  const pages = Array.from({ length: Math.min(numPages - first + 1, 5) })
+    .fill(undefined)
+    .map((_, i) => i + first);
+
+  return (
+    <UnstyledToolbar aria-label="Page" className="flex flex-row gap-1">
+      <PageLink
+        hideText
+        beforeIcon={CaretLeft}
+        isDisabled={currentPage === 1}
+        page={1}
+        setCurrentPage={setCurrentPage}
+        mkPageLink={mkPageLink}
+      >
+        First Page
+      </PageLink>
+      {pages.map((page) => {
+        return page === currentPage ? (
+          <SelectedPage key={page}>{page.toString()}</SelectedPage>
+        ) : (
+          <PageLink
+            key={page}
+            page={page}
+            aria-label={`Page ${page.toString()}`}
+            setCurrentPage={setCurrentPage}
+            mkPageLink={mkPageLink}
+          >
+            {page.toString()}
+          </PageLink>
+        );
+      })}
+      <PageLink
+        hideText
+        beforeIcon={CaretRight}
+        isDisabled={currentPage === numPages}
+        page={numPages}
+        setCurrentPage={setCurrentPage}
+        mkPageLink={mkPageLink}
+      >
+        Last Page
+      </PageLink>
+    </UnstyledToolbar>
+  );
+};
+
+type PageLinkProps = Omit<
+  ComponentProps<typeof ButtonLink>,
+  "variant" | "size" | "href" | "onPress"
+> & {
+  page: number;
+  setCurrentPage: (newPage: number) => void;
+  mkPageLink: (page: number) => string;
+};
+
+const PageLink = ({
+  page,
+  isDisabled,
+  setCurrentPage,
+  mkPageLink,
+  ...props
+}: PageLinkProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+
+  const url = useMemo(() => mkPageLink(page), [page, mkPageLink]);
+  const onPress = useCallback(() => {
+    startTransition(() => {
+      setCurrentPage(page);
+    });
+  }, [setCurrentPage, page]);
+
+  return (
+    <ButtonLink
+      variant="ghost"
+      size="xs"
+      onPress={onPress}
+      href={url}
+      isDisabled={isDisabled === true || isTransitioning}
+      {...props}
+    />
+  );
+};
+
+const SelectedPage = ({ children }: { children: string }) => (
+  <div
+    className="inline-block h-6 rounded-md bg-black/10 px-button-padding-xs text-[0.6875rem] font-medium leading-6 text-stone-900 dark:bg-white/10 dark:text-steel-50"
+    key={children}
+  >
+    <span className="px-1">{children}</span>
+  </div>
+);

+ 18 - 0
apps/insights/src/components/PriceFeeds/columns.ts

@@ -0,0 +1,18 @@
+export const columns = [
+  {
+    id: "asset" as const,
+    name: "ASSET",
+    isRowHeader: true,
+    alignment: "left" as const,
+  },
+  {
+    id: "assetType" as const,
+    name: "ASSET TYPE",
+    fill: true,
+    alignment: "left" as const,
+  },
+  { id: "price" as const, name: "PRICE", alignment: "right" as const },
+  { id: "uptime" as const, name: "UPTIME", alignment: "center" as const },
+  { id: "deviation" as const, name: "DEVIATION", alignment: "center" as const },
+  { id: "staleness" as const, name: "STALENESS", alignment: "center" as const },
+];

+ 38 - 0
apps/insights/src/components/PriceFeeds/epoch-select.tsx

@@ -0,0 +1,38 @@
+"use client";
+
+import {
+  CaretLeft,
+  CaretRight,
+  CalendarDots,
+} from "@phosphor-icons/react/dist/ssr";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Select } from "@pythnetwork/component-library/Select";
+
+export const EpochSelect = () => (
+  <div className="flex flex-row items-center gap-2">
+    <Button variant="outline" size="sm" beforeIcon={CaretLeft} hideText>
+      Previous Epoch
+    </Button>
+    <Select
+      variant="outline"
+      size="sm"
+      beforeIcon={CalendarDots}
+      options={["27 Oct – 3 Nov"]}
+      selectedKey="27 Oct – 3 Nov"
+      label="Epoch"
+      hideLabel
+      onSelectionChange={() => {
+        /* no-op */
+      }}
+    />
+    <Button
+      variant="outline"
+      size="sm"
+      beforeIcon={CaretRight}
+      hideText
+      isDisabled
+    >
+      Next Epoch
+    </Button>
+  </div>
+);

+ 81 - 0
apps/insights/src/components/PriceFeeds/index.tsx

@@ -0,0 +1,81 @@
+import Generic from "cryptocurrency-icons/svg/color/generic.svg";
+import { Fragment } from "react";
+import { z } from "zod";
+
+import { columns } from "./columns";
+import { Price } from "./prices";
+import { Results } from "./results";
+import { getIcon } from "../../icons";
+import { client } from "../../pyth";
+
+export const PriceFeeds = async () => {
+  const priceFeeds = await getPriceFeeds();
+
+  return (
+    <Results
+      label="Price Feeds"
+      columns={columns}
+      priceFeeds={priceFeeds.map(({ symbol, product }) => ({
+        symbol,
+        key: product.price_account,
+        displaySymbol: product.display_symbol,
+        data: {
+          asset: <AssetName>{product.display_symbol}</AssetName>,
+          assetType: <AssetType>{product.asset_type}</AssetType>,
+          price: <Price account={product.price_account} />,
+          uptime: 43,
+          deviation: 56,
+          staleness: 46,
+        },
+      }))}
+    />
+  );
+};
+
+const AssetName = ({ children }: { children: string }) => {
+  const [firstPart, ...parts] = children.split("/");
+  const Icon = firstPart ? (getIcon(firstPart) ?? Generic) : Generic;
+  return (
+    <div className="flex flex-row gap-3">
+      <Icon className="size-6" width="100%" height="100%" viewBox="0 0 32 32" />
+      <div className="flex flex-row items-center gap-1">
+        <span className="font-medium">{firstPart}</span>
+        {parts.map((part, i) => (
+          <Fragment key={i}>
+            <span className="font-light text-stone-600 dark:text-steel-400">
+              /
+            </span>
+            <span className="opacity-60">{part}</span>
+          </Fragment>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+const AssetType = ({ children }: { children: string }) => (
+  <span className="inline-block rounded-3xl border border-steel-900 px-2 text-[0.625rem] uppercase leading-4 text-steel-900 dark:border-steel-50 dark:text-steel-50">
+    {children}
+  </span>
+);
+
+const getPriceFeeds = async () => {
+  const data = await client.getData();
+  return priceFeedsSchema.parse(
+    data.symbols.map((symbol) => ({
+      symbol,
+      product: data.productFromSymbol.get(symbol),
+    })),
+  );
+};
+
+const priceFeedsSchema = z.array(
+  z.object({
+    symbol: z.string(),
+    product: z.object({
+      display_symbol: z.string(),
+      asset_type: z.string(),
+      price_account: z.string(),
+    }),
+  }),
+);

+ 19 - 0
apps/insights/src/components/PriceFeeds/layout.tsx

@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+import { EpochSelect } from "./epoch-select";
+import { H1 } from "../H1";
+import { MaxWidth } from "../MaxWidth";
+
+type Props = {
+  children: ReactNode | undefined;
+};
+
+export const PriceFeedsLayout = ({ children }: Props) => (
+  <MaxWidth>
+    <div className="mb-12 flex flex-row items-center justify-between">
+      <H1>Price Feeds</H1>
+      <EpochSelect />
+    </div>
+    {children}
+  </MaxWidth>
+);

+ 40 - 0
apps/insights/src/components/PriceFeeds/loading.tsx

@@ -0,0 +1,40 @@
+import { ChartLine } from "@phosphor-icons/react/dist/ssr";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { Table } from "@pythnetwork/component-library/Table";
+
+import { columns } from "./columns";
+
+export const PriceFeedsLoading = () => (
+  <Card
+    header={
+      <div className="flex flex-row items-center gap-3">
+        <ChartLine className="size-6 text-violet-600" />
+        <div>Price Feeds</div>
+      </div>
+    }
+    full
+  >
+    <Table
+      label="Publishers"
+      columns={columns}
+      rows={[
+        {
+          id: 1,
+          data: {
+            asset: (
+              <div className="mr-6">
+                <Skeleton className="w-28" />
+              </div>
+            ),
+            assetType: <Skeleton className="w-20" />,
+            price: <Skeleton className="w-20" />,
+            uptime: <Skeleton className="w-6" />,
+            deviation: <Skeleton className="w-6" />,
+            staleness: <Skeleton className="w-6" />,
+          },
+        },
+      ]}
+    />
+  </Card>
+);

+ 143 - 0
apps/insights/src/components/PriceFeeds/prices.tsx

@@ -0,0 +1,143 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { useMap } from "@react-hookz/web";
+import { PublicKey } from "@solana/web3.js";
+import clsx from "clsx";
+import {
+  type ComponentProps,
+  createContext,
+  useContext,
+  useEffect,
+} from "react";
+import { useNumberFormatter } from "react-aria";
+
+import { client, subscribe } from "../../pyth";
+
+const PriceContext = createContext<
+  Map<string, [number, ChangeDirection]> | undefined
+>(undefined);
+
+enum ChangeDirection {
+  Up,
+  Down,
+  Flat,
+}
+
+type PriceProviderProps = Omit<ComponentProps<typeof PriceContext>, "value"> & {
+  feedKeys: string[];
+};
+
+export const PriceProvider = ({ feedKeys, ...props }: PriceProviderProps) => {
+  const priceData = usePriceData(feedKeys);
+
+  return <PriceContext value={priceData} {...props} />;
+};
+
+export const Price = ({ account }: { account: string }) => {
+  const numberFormatter = useNumberFormatter({ maximumSignificantDigits: 5 });
+  const price = usePrices().get(account);
+
+  return price === undefined ? (
+    <Skeleton className="w-20" />
+  ) : (
+    <span
+      className={clsx("transition-colors duration-100", getColor(price[1]))}
+    >
+      {numberFormatter.format(price[0])}
+    </span>
+  );
+};
+
+const usePriceData = (feedKeys: string[]) => {
+  const priceData = useMap<string, [number, ChangeDirection]>([]);
+  const logger = useLogger();
+
+  useEffect(() => {
+    const initialFeedKeys = feedKeys.filter((key) => !priceData.has(key));
+    if (initialFeedKeys.length > 0) {
+      client
+        .getAssetPricesFromAccounts(
+          initialFeedKeys.map((key) => new PublicKey(key)),
+        )
+        .then((initialPrices) => {
+          for (const [i, price] of initialPrices.entries()) {
+            const key = initialFeedKeys[i];
+            if (key && !priceData.has(key)) {
+              priceData.set(key, [price.aggregate.price, ChangeDirection.Flat]);
+            }
+          }
+        })
+        .catch((error: unknown) => {
+          logger.error("Failed to fetch initial prices", error);
+        });
+    }
+
+    const connection = subscribe(
+      feedKeys.map((key) => new PublicKey(key)),
+      ({ price_account }, { aggregate }) => {
+        if (price_account) {
+          const prevPrice = priceData.get(price_account)?.[0];
+          priceData.set(price_account, [
+            aggregate.price,
+            getChangeDirection(prevPrice, aggregate.price),
+          ]);
+        }
+      },
+    );
+
+    connection.start().catch((error: unknown) => {
+      logger.error("Failed to subscribe to prices", error);
+    });
+    return () => {
+      connection.stop().catch((error: unknown) => {
+        logger.error("Failed to unsubscribe from price updates", error);
+      });
+    };
+  }, [feedKeys, logger, priceData]);
+
+  return new Map(priceData);
+};
+
+const usePrices = () => {
+  const prices = useContext(PriceContext);
+  if (prices === undefined) {
+    throw new NotInitializedError();
+  }
+  return prices;
+};
+
+class NotInitializedError extends Error {
+  constructor() {
+    super("This component must be a child of <PriceProvider>");
+    this.name = "NotInitializedError";
+  }
+}
+
+const getChangeDirection = (
+  prevPrice: number | undefined,
+  price: number,
+): ChangeDirection => {
+  if (prevPrice === undefined || prevPrice === price) {
+    return ChangeDirection.Flat;
+  } else if (prevPrice < price) {
+    return ChangeDirection.Up;
+  } else {
+    return ChangeDirection.Down;
+  }
+};
+
+const getColor = (direction: ChangeDirection) => {
+  switch (direction) {
+    case ChangeDirection.Down: {
+      return "text-red-500";
+    }
+    case ChangeDirection.Up: {
+      return "text-green-500";
+    }
+    case ChangeDirection.Flat: {
+      return;
+    }
+  }
+};

+ 197 - 0
apps/insights/src/components/PriceFeeds/results.tsx

@@ -0,0 +1,197 @@
+"use client";
+
+import {
+  MagnifyingGlass,
+  ChartLine,
+  CircleNotch,
+} from "@phosphor-icons/react/dist/ssr";
+import { useLogger } from "@pythnetwork/app-logger";
+import { Card } from "@pythnetwork/component-library/Card";
+import { Table } from "@pythnetwork/component-library/Table";
+import clsx from "clsx";
+import { usePathname } from "next/navigation";
+import {
+  parseAsString,
+  parseAsInteger,
+  useQueryStates,
+  createSerializer,
+} from "nuqs";
+import {
+  type ComponentProps,
+  useTransition,
+  useMemo,
+  useCallback,
+} from "react";
+import { useFilter, useCollator } from "react-aria";
+import { Input, SearchField } from "react-aria-components";
+
+import { PriceProvider } from "./prices";
+import { Paginator } from "../Paginator";
+
+type Props<T extends string> = Omit<
+  ComponentProps<typeof Table<T>>,
+  "isLoading" | "rows"
+> & {
+  priceFeeds: {
+    symbol: string;
+    key: string;
+    displaySymbol: string;
+    data: ComponentProps<typeof Table<T>>["rows"][number]["data"];
+  }[];
+};
+
+const params = {
+  page: parseAsInteger.withDefault(1),
+  pageSize: parseAsInteger.withDefault(20),
+  search: parseAsString.withDefault(""),
+};
+
+export const Results = <T extends string>({
+  priceFeeds,
+  ...props
+}: Props<T>) => {
+  const [isTransitioning, startTransition] = useTransition();
+  const [{ page, pageSize, search }, setQuery] = useQueryStates(params);
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const collator = useCollator();
+  const filteredFeeds = useMemo(
+    () =>
+      search === ""
+        ? priceFeeds
+        : priceFeeds.filter((feed) => filter.contains(feed.symbol, search)),
+    [search, priceFeeds, filter],
+  );
+  const rows = useMemo(
+    () =>
+      filteredFeeds
+        .sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol))
+        .slice((page - 1) * pageSize, page * pageSize)
+        .map(({ key, data }) => ({ id: key, href: "/", data })),
+    [page, pageSize, filteredFeeds, collator],
+  );
+  const numPages = useMemo(
+    () => Math.ceil(filteredFeeds.length / pageSize),
+    [filteredFeeds, pageSize],
+  );
+
+  const logger = useLogger();
+
+  const updateQuery = useCallback(
+    (...params: Parameters<typeof setQuery>) => {
+      window.scrollTo({ top: 0, behavior: "smooth" });
+      startTransition(() => {
+        setQuery(...params).catch((error: unknown) => {
+          logger.error("Failed to update query", error);
+        });
+      });
+    },
+    [setQuery, startTransition, logger],
+  );
+
+  const updatePage = useCallback(
+    (newPage: number) => {
+      updateQuery({ page: newPage });
+    },
+    [updateQuery],
+  );
+
+  const updatePageSize = useCallback(
+    (newPageSize: number) => {
+      updateQuery({ page: 1, pageSize: newPageSize });
+    },
+    [updateQuery],
+  );
+
+  const updateSearch = useCallback(
+    (newSearch: string) => {
+      updateQuery({ page: 1, search: newSearch });
+    },
+    [updateQuery],
+  );
+
+  const feedKeys = useMemo(() => rows.map((row) => row.id), [rows]);
+
+  const pathname = usePathname();
+
+  const mkPageLink = useCallback(
+    (page: number) => {
+      const serialize = createSerializer(params);
+      return `${pathname}${serialize({ page })}`;
+    },
+    [pathname],
+  );
+
+  return (
+    <Card
+      header={
+        <div className="flex flex-row items-center gap-3">
+          <ChartLine className="size-6 text-violet-600" />
+          <div>Price Feeds</div>
+        </div>
+      }
+      toolbarLabel="Price Feeds"
+      toolbar={<SearchBar search={search} setSearch={updateSearch} />}
+      full
+    >
+      <PriceProvider feedKeys={feedKeys}>
+        <Table
+          isLoading={isTransitioning}
+          rows={rows}
+          renderEmptyState={() => <p>No results!</p>}
+          {...props}
+        />
+      </PriceProvider>
+      <Paginator
+        numPages={numPages}
+        currentPage={page}
+        setCurrentPage={updatePage}
+        pageSize={pageSize}
+        setPageSize={updatePageSize}
+        mkPageLink={mkPageLink}
+      />
+    </Card>
+  );
+};
+
+type SearchBarProps = {
+  search: string;
+  setSearch: (newSearch: string) => void;
+};
+
+const SearchBar = ({ search, setSearch }: SearchBarProps) => {
+  const [isTransitioning, startTransition] = useTransition();
+  const Icon = isTransitioning ? CircleNotch : MagnifyingGlass;
+
+  const doSearch = useCallback(
+    (search: string) => {
+      startTransition(() => {
+        setSearch(search);
+      });
+    },
+    [setSearch, startTransition],
+  );
+
+  return (
+    <div className="space-x-2">
+      <SearchField
+        defaultValue={search}
+        onChange={doSearch}
+        aria-label="Search"
+        className="inline-block"
+      >
+        <span className="relative inline-block h-9 w-48">
+          <Input
+            className="inline-block size-full rounded-lg border border-stone-300 bg-white px-button-padding-sm pl-9 text-sm ring-violet-500 placeholder:text-stone-400 data-[focused]:ring-2 data-[focused]:ring-violet-500 focus:border-stone-300 focus:outline-0 dark:bg-steel-900 dark:placeholder:text-steel-400"
+            placeholder="Search"
+          />
+          <Icon
+            className={clsx(
+              "pointer-events-none absolute inset-y-2 left-button-padding-sm size-5",
+              { "animate-spin": isTransitioning },
+            )}
+          />
+        </span>
+      </SearchField>
+    </div>
+  );
+};

+ 21 - 0
apps/insights/src/components/Publishers/columns.ts

@@ -0,0 +1,21 @@
+export const columns = [
+  { id: "rank" as const, name: "RANKING" },
+  {
+    id: "name" as const,
+    name: "NAME / ID",
+    isRowHeader: true,
+    fill: true,
+    alignment: "left" as const,
+  },
+  {
+    id: "activeFeeds" as const,
+    name: "ACTIVE FEEDS",
+    alignment: "left" as const,
+  },
+  {
+    id: "inactiveFeeds" as const,
+    name: "INACTIVE FEEDS",
+    alignment: "left" as const,
+  },
+  { id: "score" as const, name: "SCORE" },
+];

+ 38 - 0
apps/insights/src/components/Publishers/epoch-select.tsx

@@ -0,0 +1,38 @@
+"use client";
+
+import {
+  CaretLeft,
+  CaretRight,
+  CalendarDots,
+} from "@phosphor-icons/react/dist/ssr";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Select } from "@pythnetwork/component-library/Select";
+
+export const EpochSelect = () => (
+  <div className="flex flex-row items-center gap-2">
+    <Button variant="outline" size="sm" beforeIcon={CaretLeft} hideText>
+      Previous Epoch
+    </Button>
+    <Select
+      variant="outline"
+      size="sm"
+      beforeIcon={CalendarDots}
+      options={["27 Oct – 3 Nov"]}
+      selectedKey="27 Oct – 3 Nov"
+      label="Epoch"
+      hideLabel
+      onSelectionChange={() => {
+        /* no-op */
+      }}
+    />
+    <Button
+      variant="outline"
+      size="sm"
+      beforeIcon={CaretRight}
+      hideText
+      isDisabled
+    >
+      Next Epoch
+    </Button>
+  </div>
+);

+ 93 - 0
apps/insights/src/components/Publishers/index.tsx

@@ -0,0 +1,93 @@
+import { lookup as lookupPublisher } from "@pythnetwork/known-publishers";
+import clsx from "clsx";
+import { type ComponentProps, createElement } from "react";
+import { z } from "zod";
+
+import { columns } from "./columns";
+import { Results } from "./results";
+import { client as clickhouseClient } from "../../clickhouse";
+import { client as pythClient } from "../../pyth";
+import { CopyButton } from "../CopyButton";
+
+export const Publishers = async () => {
+  const [publishers, feedCount] = await Promise.all([
+    getPublishers(),
+    getFeedCount(),
+  ]);
+
+  return (
+    <Results
+      label="Publishers"
+      columns={columns}
+      publishers={publishers.map(({ key, rank, numSymbols }) => ({
+        key,
+        rank,
+        data: {
+          name: <PublisherName>{key}</PublisherName>,
+          rank: <Ranking>{rank}</Ranking>,
+          activeFeeds: <span className="text-sm">{numSymbols}</span>,
+          inactiveFeeds: (
+            <span className="text-sm">{feedCount - numSymbols}</span>
+          ),
+          score: 0,
+        },
+      }))}
+    />
+  );
+};
+
+const PublisherName = ({ children }: { children: string }) => {
+  const knownPublisher = lookupPublisher(children);
+  return knownPublisher ? (
+    <div className="flex flex-row items-center gap-4">
+      {createElement(knownPublisher.icon.color, {
+        className: "flex-none size-9",
+      })}
+      <div className="space-y-1">
+        <div className="text-sm font-medium">{knownPublisher.name}</div>
+        <CopyButton className="text-xs" text={children}>
+          {children}
+        </CopyButton>
+      </div>
+    </div>
+  ) : (
+    <CopyButton className="text-xs" text={children}>
+      {children}
+    </CopyButton>
+  );
+};
+
+const Ranking = ({ className, ...props }: ComponentProps<"span">) => (
+  <span
+    className={clsx(
+      "inline-block h-6 w-full rounded-md bg-steel-200 text-center text-sm font-medium leading-6 text-steel-800 dark:bg-steel-700 dark:text-steel-300",
+      className,
+    )}
+    {...props}
+  />
+);
+
+const getPublishers = async () => {
+  const rows = await clickhouseClient.query({
+    query: "SELECT key, rank, numSymbols FROM insights_publishers",
+  });
+  const result = await rows.json();
+
+  return publishersSchema.parse(result.data);
+};
+
+const getFeedCount = async () => {
+  const pythData = await pythClient.getData();
+  return pythData.symbols.filter(
+    (symbol) =>
+      (pythData.productPrice.get(symbol)?.numComponentPrices ?? 0) > 0,
+  ).length;
+};
+
+const publishersSchema = z.array(
+  z.strictObject({
+    key: z.string(),
+    rank: z.number(),
+    numSymbols: z.number(),
+  }),
+);

+ 43 - 0
apps/insights/src/components/Publishers/layout.tsx

@@ -0,0 +1,43 @@
+import { Info } from "@phosphor-icons/react/dist/ssr";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Card } from "@pythnetwork/component-library/Card";
+import type { ReactNode } from "react";
+
+import { EpochSelect } from "./epoch-select";
+import { H1 } from "../H1";
+import { MaxWidth } from "../MaxWidth";
+
+type Props = {
+  children: ReactNode | undefined;
+};
+
+export const PublishersLayout = ({ children }: Props) => (
+  <MaxWidth>
+    <div className="mb-12 flex flex-row items-center justify-between">
+      <H1>Publishers</H1>
+      <EpochSelect />
+    </div>
+    <Card
+      header="Publishers"
+      toolbarLabel="Publishers"
+      full
+      toolbar={
+        <>
+          <Button size="xs" variant="outline">
+            Show rankings
+          </Button>
+          <Button
+            size="xs"
+            variant="ghost"
+            beforeIcon={(props) => <Info weight="fill" {...props} />}
+            hideText
+          >
+            Help
+          </Button>
+        </>
+      }
+    >
+      {children}
+    </Card>
+  </MaxWidth>
+);

+ 23 - 0
apps/insights/src/components/Publishers/loading.tsx

@@ -0,0 +1,23 @@
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { Table } from "@pythnetwork/component-library/Table";
+
+import { columns } from "./columns";
+
+export const PublishersLoading = () => (
+  <Table
+    label="Publishers"
+    columns={columns}
+    rows={[
+      {
+        id: 1,
+        data: {
+          activeFeeds: <Skeleton className="w-6" />,
+          inactiveFeeds: <Skeleton className="w-6" />,
+          name: <Skeleton className="w-48" />,
+          rank: <Skeleton className="w-10" />,
+          score: <Skeleton className="w-6" />,
+        },
+      },
+    ]}
+  />
+);

+ 102 - 0
apps/insights/src/components/Publishers/results.tsx

@@ -0,0 +1,102 @@
+"use client";
+
+import { useLogger } from "@pythnetwork/app-logger";
+import { Table } from "@pythnetwork/component-library/Table";
+import { usePathname } from "next/navigation";
+import { parseAsInteger, useQueryStates, createSerializer } from "nuqs";
+import {
+  type ComponentProps,
+  useTransition,
+  useMemo,
+  useCallback,
+} from "react";
+
+import { Paginator } from "../Paginator";
+
+type Props<T extends string> = Omit<
+  ComponentProps<typeof Table<T>>,
+  "isLoading" | "rows"
+> & {
+  publishers: {
+    key: string;
+    rank: number;
+    data: ComponentProps<typeof Table<T>>["rows"][number]["data"];
+  }[];
+};
+
+const params = {
+  page: parseAsInteger.withDefault(1),
+  pageSize: parseAsInteger.withDefault(20),
+};
+
+export const Results = <T extends string>({
+  publishers,
+  ...props
+}: Props<T>) => {
+  const [isTransitioning, startTransition] = useTransition();
+  const [{ page, pageSize }, setQuery] = useQueryStates(params);
+  const rows = useMemo(
+    () =>
+      publishers
+        .sort((a, b) => a.rank - b.rank)
+        .slice((page - 1) * pageSize, page * pageSize)
+        .map(({ key, data }) => ({ id: key, href: "/", data })),
+    [page, pageSize, publishers],
+  );
+  const numPages = useMemo(
+    () => Math.ceil(publishers.length / pageSize),
+    [publishers, pageSize],
+  );
+
+  const logger = useLogger();
+
+  const updateQuery = useCallback(
+    (...params: Parameters<typeof setQuery>) => {
+      window.scrollTo({ top: 0, behavior: "smooth" });
+      startTransition(() => {
+        setQuery(...params).catch((error: unknown) => {
+          logger.error("Failed to update query", error);
+        });
+      });
+    },
+    [setQuery, startTransition, logger],
+  );
+
+  const updatePage = useCallback(
+    (newPage: number) => {
+      updateQuery({ page: newPage });
+    },
+    [updateQuery],
+  );
+
+  const updatePageSize = useCallback(
+    (newPageSize: number) => {
+      updateQuery({ page: 1, pageSize: newPageSize });
+    },
+    [updateQuery],
+  );
+
+  const pathname = usePathname();
+
+  const mkPageLink = useCallback(
+    (page: number) => {
+      const serialize = createSerializer(params);
+      return `${pathname}${serialize({ page })}`;
+    },
+    [pathname],
+  );
+
+  return (
+    <>
+      <Table isLoading={isTransitioning} rows={rows} {...props} />
+      <Paginator
+        numPages={numPages}
+        currentPage={page}
+        setCurrentPage={updatePage}
+        pageSize={pageSize}
+        setPageSize={updatePageSize}
+        mkPageLink={mkPageLink}
+      />
+    </>
+  );
+};

+ 87 - 0
apps/insights/src/components/Root/footer.tsx

@@ -0,0 +1,87 @@
+import {
+  TelegramLogo,
+  GithubLogo,
+  XLogo,
+  DiscordLogo,
+  YoutubeLogo,
+} from "@phosphor-icons/react/dist/ssr";
+import { ButtonLink } from "@pythnetwork/component-library/Button";
+import { Link } from "@pythnetwork/component-library/Link";
+import type { ComponentProps } from "react";
+
+import Wordmark from "./wordmark.svg";
+import { MaxWidth } from "../MaxWidth";
+
+export const Footer = () => (
+  <footer className="z-10 space-y-6 bg-beige-100 py-6 sm:border-t sm:border-stone-300 sm:bg-white xl:space-y-12 xl:py-8 dark:bg-steel-900 dark:sm:border-steel-600 sm:dark:bg-steel-950">
+    <MaxWidth className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
+      <div className="flex items-stretch justify-between gap-8 sm:gap-6">
+        <Link href="https://www.pyth.network" className="-m-2 rounded p-2">
+          <Wordmark className="h-5" />
+          <div className="sr-only">Pyth Homepage</div>
+        </Link>
+        <div className="hidden w-px bg-stone-300 sm:block dark:bg-steel-600" />
+        <div className="space-x-6 text-sm">
+          <Link href="/">Help</Link>
+          <Link href="https://docs.pyth.network" target="_blank">
+            Documentation
+          </Link>
+        </div>
+      </div>
+      <div className="-mx-button-padding-sm flex items-center justify-between sm:justify-end sm:gap-2">
+        <SocialLink href="https://t.me/Pyth_Network" icon={TelegramLogo}>
+          Telegram
+        </SocialLink>
+        <SocialLink href="https://github.com/pyth-network" icon={GithubLogo}>
+          Github
+        </SocialLink>
+        <SocialLink href="https://x.com/PythNetwork" icon={XLogo}>
+          X
+        </SocialLink>
+        <SocialLink
+          href="https://discord.gg/invite/PythNetwork"
+          icon={DiscordLogo}
+        >
+          Discord
+        </SocialLink>
+        <SocialLink
+          href="https://www.youtube.com/@pythnetwork"
+          icon={YoutubeLogo}
+        >
+          YouTube
+        </SocialLink>
+      </div>
+    </MaxWidth>
+    <MaxWidth className="flex flex-col gap-6 sm:flex-row sm:justify-between">
+      <small className="text-xs text-stone-600 dark:text-steel-400">
+        © 2024 Pyth Data Association
+      </small>
+      <div className="space-x-6 text-xs">
+        <Link href="https://www.pyth.network/privacy-policy" target="_blank">
+          Privacy Policy
+        </Link>
+        <Link href="https://www.pyth.network/terms-of-use" target="_blank">
+          Terms of Use
+        </Link>
+      </div>
+    </MaxWidth>
+  </footer>
+);
+
+type SocialLinkProps = Omit<
+  ComponentProps<typeof ButtonLink>,
+  "target" | "variant" | "size" | "beforeIcon" | "hideText"
+> & {
+  icon: ComponentProps<typeof ButtonLink>["beforeIcon"];
+};
+
+const SocialLink = ({ icon, ...props }: SocialLinkProps) => (
+  <ButtonLink
+    target="_blank"
+    variant="ghost"
+    size="sm"
+    beforeIcon={icon}
+    hideText
+    {...props}
+  />
+);

+ 51 - 0
apps/insights/src/components/Root/header.tsx

@@ -0,0 +1,51 @@
+import { MagnifyingGlass, Lifebuoy } from "@phosphor-icons/react/dist/ssr";
+import { Button, ButtonLink } from "@pythnetwork/component-library/Button";
+import { Link } from "@pythnetwork/component-library/Link";
+
+import Logo from "./logo.svg";
+import Orb from "./orb.svg";
+import { TabList } from "./tabs";
+import { ThemeSwitch } from "./theme-switch";
+import { MaxWidth } from "../MaxWidth";
+
+export const Header = () => (
+  <header className="sticky top-0 z-10 h-20 w-full bg-white dark:bg-steel-950">
+    <MaxWidth className="flex h-full flex-row items-center justify-between">
+      <div className="flex flex-none flex-row items-center gap-6">
+        <Link href="https://www.pyth.network">
+          <Logo className="mt-[0.56456rem] h-[2.81456rem] w-9" />
+          <div className="sr-only">Pyth Homepage</div>
+        </Link>
+        <div className="inline-block h-9 whitespace-nowrap rounded-full bg-beige-100 pr-6 leading-9 dark:bg-steel-900">
+          <div className="relative inline-block size-9 align-top">
+            <Orb className="h-11 w-9" />
+          </div>
+          <span className="mx-3 text-sm font-medium">Insights</span>
+        </div>
+        <TabList />
+      </div>
+      <div className="flex flex-none flex-row items-center gap-2 lg:-mx-button-padding-sm">
+        <Button
+          beforeIcon={Lifebuoy}
+          variant="ghost"
+          size="sm"
+          className="hidden lg:inline-block"
+        >
+          Support
+        </Button>
+        <Button
+          beforeIcon={MagnifyingGlass}
+          variant="outline"
+          size="sm"
+          className="hidden lg:inline-block"
+        >
+          ⌘ K
+        </Button>
+        <ButtonLink href="https://www.pyth.network" size="sm" target="_blank">
+          Integrate
+        </ButtonLink>
+        <ThemeSwitch className="ml-1 hidden lg:inline-block" />
+      </div>
+    </MaxWidth>
+  </header>
+);

+ 18 - 8
apps/insights/src/components/Root/index.tsx

@@ -1,8 +1,13 @@
 import { sans } from "@pythnetwork/fonts";
 import { Root as BaseRoot } from "@pythnetwork/next-root";
 import clsx from "clsx";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
 import type { ReactNode } from "react";
 
+import { Footer } from "./footer";
+import { Header } from "./header";
+import { MobileMenu } from "./mobile-menu";
+import { TabPanel, TabRoot } from "./tabs";
 import {
   IS_PRODUCTION_SERVER,
   GOOGLE_ANALYTICS_ID,
@@ -18,14 +23,19 @@ export const Root = ({ children }: Props) => (
     amplitudeApiKey={AMPLITUDE_API_KEY}
     googleAnalyticsId={GOOGLE_ANALYTICS_ID}
     enableAccessibilityReporting={!IS_PRODUCTION_SERVER}
+    bodyClassName={clsx(
+      "bg-white font-sans text-steel-900 antialiased selection:bg-violet-600 selection:text-steel-50 dark:bg-steel-950 dark:text-steel-50 dark:selection:bg-violet-400 dark:selection:text-steel-950",
+      sans.variable,
+    )}
+    providers={[NuqsAdapter]}
   >
-    <body
-      className={clsx(
-        "bg-white font-sans text-steel-900 antialiased dark:bg-steel-900 dark:text-steel-50",
-        sans.variable,
-      )}
-    >
-      {children}
-    </body>
+    <TabRoot className="grid min-h-dvh grid-rows-[auto_1fr_auto]">
+      <Header />
+      <main className="pb-12 pt-6">
+        <TabPanel>{children}</TabPanel>
+      </main>
+      <Footer />
+      <MobileMenu />
+    </TabRoot>
   </BaseRoot>
 );

+ 4 - 0
apps/insights/src/components/Root/logo.svg

@@ -0,0 +1,4 @@
+<svg viewBox="0 0 36 46" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+  <path d="M22.5 18.013a4.502 4.502 0 0 1-4.5 4.503v4.504c4.97 0 9-4.033 9-9.007 0-4.974-4.03-9.007-9-9.007a9 9 0 0 0-9 9.007V40.53l4.046 4.049.454.454v-27.02a4.502 4.502 0 0 1 4.5-4.504c2.485 0 4.5 2.017 4.5 4.504Z"/>
+  <path d="M18 0c-3.279 0-6.352.878-9 2.412A18 18 0 0 0 4.5 6.1 17.952 17.952 0 0 0 0 18.014v13.51l4.5 4.503V18.013c0-4 1.738-7.595 4.5-10.07a13.473 13.473 0 0 1 9-3.44c7.455 0 13.5 6.05 13.5 13.51 0 7.461-6.045 13.51-13.5 13.51v4.504c9.942 0 18-8.066 18-18.014C36 8.067 27.942 0 18 0Z"/>
+</svg>

+ 48 - 0
apps/insights/src/components/Root/mobile-menu.tsx

@@ -0,0 +1,48 @@
+import type { Icon } from "@phosphor-icons/react";
+import {
+  PresentationChart,
+  Broadcast,
+  ChartLine,
+  MagnifyingGlass,
+  List,
+} from "@phosphor-icons/react/dist/ssr";
+import type { ComponentProps, ReactNode } from "react";
+
+import { NavLink } from "./nav-link";
+
+export const MobileMenu = () => (
+  <nav className="contents lg:hidden">
+    <ul className="sticky bottom-0 isolate z-20 flex size-full flex-row items-stretch bg-white dark:bg-steel-950">
+      <MobileMenuItem title="Overview" icon={PresentationChart} href="/" />
+      <MobileMenuItem title="Publishers" icon={Broadcast} href="/publishers" />
+      <MobileMenuItem
+        title="Price Feeds"
+        icon={ChartLine}
+        href="/price-feeds"
+      />
+      <MobileMenuItem title="Search" icon={MagnifyingGlass} href="/" />
+      <MobileMenuItem title="More" icon={List} href="/" />
+    </ul>
+  </nav>
+);
+
+type MobileMenuItemProps = ComponentProps<typeof NavLink> & {
+  title: ReactNode;
+  icon: Icon;
+};
+
+const MobileMenuItem = ({
+  title,
+  icon: Icon,
+  ...props
+}: MobileMenuItemProps) => (
+  <li className="contents">
+    <NavLink
+      className="flex grow basis-0 flex-col items-center gap-2 py-4 outline-none transition duration-100 data-[focus-visible]:bg-black/5 data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 data-[selected]:bg-steel-900 data-[selected]:text-steel-50 dark:data-[selected]:bg-steel-50 dark:data-[selected]:text-steel-900"
+      {...props}
+    >
+      <Icon className="size-5" />
+      <div className="text-center text-xs font-medium">{title}</div>
+    </NavLink>
+  </li>
+);

+ 30 - 0
apps/insights/src/components/Root/nav-link.tsx

@@ -0,0 +1,30 @@
+"use client";
+
+import { useSelectedLayoutSegment } from "next/navigation";
+import type { ReactNode } from "react";
+import { Link } from "react-aria-components";
+
+type Props = {
+  href: string;
+  target?: string | undefined;
+  className?: string | undefined;
+  children?: ReactNode | ReactNode[] | undefined;
+};
+
+export const NavLink = ({ href, target, className, children }: Props) => {
+  const layoutSegment = useSelectedLayoutSegment();
+
+  return `/${layoutSegment ?? ""}` === href ? (
+    <div data-selected="" className={className}>
+      {children}
+    </div>
+  ) : (
+    <Link
+      href={href}
+      {...(target !== undefined && { target })}
+      {...(className !== undefined && { className })}
+    >
+      {children}
+    </Link>
+  );
+};

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 15 - 0
apps/insights/src/components/Root/orb.svg


+ 92 - 0
apps/insights/src/components/Root/tabs.tsx

@@ -0,0 +1,92 @@
+"use client";
+
+import type { Icon } from "@phosphor-icons/react";
+import {
+  PresentationChart,
+  Broadcast,
+  ChartLine,
+} from "@phosphor-icons/react/dist/ssr";
+import clsx from "clsx";
+import { m, LazyMotion, domMax } from "framer-motion";
+import { useSelectedLayoutSegment } from "next/navigation";
+import type { ComponentProps } from "react";
+import {
+  Tab as BaseTab,
+  TabPanel as BaseTabPanel,
+  TabList as BaseTabList,
+  Tabs,
+} from "react-aria-components";
+
+export const TabRoot = (
+  props: Omit<ComponentProps<typeof Tabs>, "selectedKey">,
+) => {
+  const layoutSegment = useSelectedLayoutSegment();
+
+  return <Tabs selectedKey={`/${layoutSegment ?? ""}`} {...props} />;
+};
+
+export const TabPanel = (
+  props: Omit<ComponentProps<typeof BaseTabPanel>, "id">,
+) => {
+  const layoutSegment = useSelectedLayoutSegment();
+
+  return <BaseTabPanel id={`/${layoutSegment ?? ""}`} {...props} />;
+};
+
+export const TabList = () => (
+  <LazyMotion features={domMax} strict>
+    <BaseTabList
+      aria-label="Main Navigation"
+      className="hidden flex-row items-center gap-2 lg:flex"
+    >
+      <Tab href="/" icon={PresentationChart}>
+        Overview
+      </Tab>
+      <Tab href="/publishers" icon={Broadcast}>
+        Publishers
+      </Tab>
+      <Tab href="/price-feeds" icon={ChartLine}>
+        Price Feeds
+      </Tab>
+    </BaseTabList>
+  </LazyMotion>
+);
+
+type TabProps = Omit<
+  ComponentProps<typeof BaseTab>,
+  "id" | "href" | "children"
+> & {
+  icon: Icon;
+  href: string;
+  children: string;
+};
+
+const Tab = ({ href, className, children, icon: Icon, ...props }: TabProps) => (
+  <BaseTab
+    className={clsx(
+      "group/tab relative h-9 cursor-pointer whitespace-nowrap rounded-lg border border-transparent px-button-padding-sm text-sm font-medium leading-9 text-stone-900 outline-none data-[selected]:cursor-default data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 dark:text-steel-50 dark:data-[hovered]:bg-white/5 dark:data-[pressed]:bg-white/10",
+      className,
+    )}
+    id={href}
+    href={href}
+    {...props}
+  >
+    {(args) => (
+      <>
+        {args.isSelected && (
+          <m.span
+            layoutId="bubble"
+            // @ts-expect-error looks like framer-motion isn't typed correctly
+            className="absolute inset-0 z-10 rounded-lg bg-white mix-blend-difference outline-2 outline-offset-2 outline-white group-data-[focus-visible]/tab:outline"
+            transition={{ type: "spring", bounce: 0.3, duration: 0.6 }}
+            style={{ originY: "top" }}
+          />
+        )}
+        <span className="inline-grid h-full place-content-center align-top">
+          <Icon className="relative size-5" />
+        </span>
+        <span className="px-2">{children}</span>
+      </>
+    )}
+  </BaseTab>
+);

+ 40 - 0
apps/insights/src/components/Root/theme-switch.tsx

@@ -0,0 +1,40 @@
+"use client";
+
+import { Sun, Moon } from "@phosphor-icons/react/dist/ssr";
+import { Button } from "@pythnetwork/component-library/Button";
+import clsx from "clsx";
+import { useTheme } from "next-themes";
+import { type ComponentProps, useCallback } from "react";
+
+type Props = Omit<
+  ComponentProps<typeof Button>,
+  "beforeIcon" | "variant" | "size" | "hideText" | "children" | "onPress"
+>;
+
+export const ThemeSwitch = (props: Props) => {
+  const { theme, setTheme } = useTheme();
+
+  const toggleTheme = useCallback(() => {
+    setTheme(theme === "dark" ? "light" : "dark");
+  }, [theme, setTheme]);
+
+  return (
+    <Button
+      variant="ghost"
+      size="sm"
+      hideText
+      onPress={toggleTheme}
+      beforeIcon={Icon}
+      {...props}
+    >
+      Dark mode
+    </Button>
+  );
+};
+
+const Icon = ({ className, ...props }: ComponentProps<typeof Sun>) => (
+  <>
+    <Sun className={clsx("hidden dark:block", className)} {...props} />
+    <Moon className={clsx("dark:hidden", className)} {...props} />
+  </>
+);

+ 3 - 0
apps/insights/src/components/Root/wordmark.svg

@@ -0,0 +1,3 @@
+<svg viewBox="0 0 88 18" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+  <path d="M5.417 12.996V9.952h7.222c1.104 0 1.966-.313 2.584-.94.618-.627.927-1.453.927-2.474 0-1.022-.309-1.864-.927-2.474-.618-.61-1.48-.916-2.584-.916H3.117V18H0V0h12.639c1.039 0 1.966.153 2.784.458.815.305 1.504.734 2.065 1.288a5.5 5.5 0 0 1 1.287 2.016c.297.792.446 1.683.446 2.674 0 .991-.149 1.86-.446 2.662-.296.8-.726 1.49-1.287 2.067a5.784 5.784 0 0 1-2.066 1.35c-.815.32-1.744.481-2.783.481H5.417ZM29.45 17.996v-6.833L19.905 0h4.23l7 8.292L38.157 0h4.008l-9.573 11.163v6.833h-3.141ZM52.995 17.996V3.144h-8.272V0h19.698v3.144h-8.284v14.852h-3.142ZM84.073 0h3.13v17.996h-3.13V0ZM70.57 17.996v-7.79h11.642V7.422H70.571V0h-3.13v17.996h3.13Z"/>
+</svg>

+ 27 - 20
apps/insights/src/config/server.ts

@@ -2,11 +2,6 @@
 // and load all env variables.
 /* eslint-disable n/no-process-env */
 
-// Disable the following rule because variables in this file are only loaded at
-// runtime and do not influence the build outputs, thus they need not be
-// declared to turbo for it to be able to cache build outputs correctly.
-/* eslint-disable turbo/no-undeclared-env-vars */
-
 import "server-only";
 
 /**
@@ -21,25 +16,37 @@ const demand = (key: string): string => {
   }
 };
 
+class MissingEnvironmentError extends Error {
+  constructor(name: string) {
+    super(`Missing environment variable: ${name}!`);
+    this.name = "MissingEnvironmentError";
+  }
+}
+
+const getEnvOrDefault = (key: string, defaultValue: string) =>
+  process.env[key] ?? defaultValue;
+
 /**
  * Indicates that this server is the live customer-facing production server.
  */
 export const IS_PRODUCTION_SERVER = process.env.VERCEL_ENV === "production";
 
-/**
- * Throw if the env var `key` is not set in the live customer-facing production
- * server, but allow it to be unset in any other environment.
- */
-const demandInProduction = IS_PRODUCTION_SERVER
-  ? demand
+const defaultInProduction = IS_PRODUCTION_SERVER
+  ? getEnvOrDefault
   : (key: string) => process.env[key];
 
-export const GOOGLE_ANALYTICS_ID = demandInProduction("GOOGLE_ANALYTICS_ID");
-export const AMPLITUDE_API_KEY = demandInProduction("AMPLITUDE_API_KEY");
-
-class MissingEnvironmentError extends Error {
-  constructor(name: string) {
-    super(`Missing environment variable: ${name}!`);
-    this.name = "MissingEnvironmentError";
-  }
-}
+export const GOOGLE_ANALYTICS_ID = defaultInProduction(
+  "GOOGLE_ANALYTICS_ID",
+  "G-E1QSY256EQ",
+);
+export const AMPLITUDE_API_KEY = defaultInProduction(
+  "AMPLITUDE_API_KEY",
+  "6faa78c51eff33087eb19f0f3dc76f33",
+);
+export const CLICKHOUSE = {
+  url:
+    process.env.CLICKHOUSE_URL ??
+    "https://oxcuvjrqq7.eu-west-2.aws.clickhouse.cloud:8443",
+  username: process.env.CLICKHOUSE_USERNAME ?? "insights",
+  password: demand("CLICKHOUSE_PASSWORD"),
+};

+ 972 - 0
apps/insights/src/icons.tsx

@@ -0,0 +1,972 @@
+import Pac from "cryptocurrency-icons/svg/color/$pac.svg";
+import ZeroXbtc from "cryptocurrency-icons/svg/color/0xbtc.svg";
+import Oneinch from "cryptocurrency-icons/svg/color/1inch.svg";
+import Twogive from "cryptocurrency-icons/svg/color/2give.svg";
+import Aave from "cryptocurrency-icons/svg/color/aave.svg";
+import Abt from "cryptocurrency-icons/svg/color/abt.svg";
+import Act from "cryptocurrency-icons/svg/color/act.svg";
+import Actn from "cryptocurrency-icons/svg/color/actn.svg";
+import Ada from "cryptocurrency-icons/svg/color/ada.svg";
+import Add from "cryptocurrency-icons/svg/color/add.svg";
+import Adx from "cryptocurrency-icons/svg/color/adx.svg";
+import Ae from "cryptocurrency-icons/svg/color/ae.svg";
+import Aeon from "cryptocurrency-icons/svg/color/aeon.svg";
+import Aeur from "cryptocurrency-icons/svg/color/aeur.svg";
+import Agi from "cryptocurrency-icons/svg/color/agi.svg";
+import Agrs from "cryptocurrency-icons/svg/color/agrs.svg";
+import Aion from "cryptocurrency-icons/svg/color/aion.svg";
+import Algo from "cryptocurrency-icons/svg/color/algo.svg";
+import Amb from "cryptocurrency-icons/svg/color/amb.svg";
+import Amp from "cryptocurrency-icons/svg/color/amp.svg";
+import Ampl from "cryptocurrency-icons/svg/color/ampl.svg";
+import Ankr from "cryptocurrency-icons/svg/color/ankr.svg";
+import Ant from "cryptocurrency-icons/svg/color/ant.svg";
+import Ape from "cryptocurrency-icons/svg/color/ape.svg";
+import Apex from "cryptocurrency-icons/svg/color/apex.svg";
+import Appc from "cryptocurrency-icons/svg/color/appc.svg";
+import Ardr from "cryptocurrency-icons/svg/color/ardr.svg";
+import Arg from "cryptocurrency-icons/svg/color/arg.svg";
+import Ark from "cryptocurrency-icons/svg/color/ark.svg";
+import Arn from "cryptocurrency-icons/svg/color/arn.svg";
+import Arnx from "cryptocurrency-icons/svg/color/arnx.svg";
+import Ary from "cryptocurrency-icons/svg/color/ary.svg";
+import Ast from "cryptocurrency-icons/svg/color/ast.svg";
+import Atlas from "cryptocurrency-icons/svg/color/atlas.svg";
+import Atm from "cryptocurrency-icons/svg/color/atm.svg";
+import Atom from "cryptocurrency-icons/svg/color/atom.svg";
+import Audr from "cryptocurrency-icons/svg/color/audr.svg";
+import Aury from "cryptocurrency-icons/svg/color/aury.svg";
+import Auto from "cryptocurrency-icons/svg/color/auto.svg";
+import Avax from "cryptocurrency-icons/svg/color/avax.svg";
+import Aywa from "cryptocurrency-icons/svg/color/aywa.svg";
+import Bab from "cryptocurrency-icons/svg/color/bab.svg";
+import Bal from "cryptocurrency-icons/svg/color/bal.svg";
+import Band from "cryptocurrency-icons/svg/color/band.svg";
+import Bat from "cryptocurrency-icons/svg/color/bat.svg";
+import Bay from "cryptocurrency-icons/svg/color/bay.svg";
+import Bcbc from "cryptocurrency-icons/svg/color/bcbc.svg";
+import Bcc from "cryptocurrency-icons/svg/color/bcc.svg";
+import Bcd from "cryptocurrency-icons/svg/color/bcd.svg";
+import Bch from "cryptocurrency-icons/svg/color/bch.svg";
+import Bcio from "cryptocurrency-icons/svg/color/bcio.svg";
+import Bcn from "cryptocurrency-icons/svg/color/bcn.svg";
+import Bco from "cryptocurrency-icons/svg/color/bco.svg";
+import Bcpt from "cryptocurrency-icons/svg/color/bcpt.svg";
+import Bdl from "cryptocurrency-icons/svg/color/bdl.svg";
+import Beam from "cryptocurrency-icons/svg/color/beam.svg";
+import Bela from "cryptocurrency-icons/svg/color/bela.svg";
+import Bix from "cryptocurrency-icons/svg/color/bix.svg";
+import Blcn from "cryptocurrency-icons/svg/color/blcn.svg";
+import Blk from "cryptocurrency-icons/svg/color/blk.svg";
+import Block from "cryptocurrency-icons/svg/color/block.svg";
+import Blz from "cryptocurrency-icons/svg/color/blz.svg";
+import Bnb from "cryptocurrency-icons/svg/color/bnb.svg";
+import Bnt from "cryptocurrency-icons/svg/color/bnt.svg";
+import Bnty from "cryptocurrency-icons/svg/color/bnty.svg";
+import Booty from "cryptocurrency-icons/svg/color/booty.svg";
+import Bos from "cryptocurrency-icons/svg/color/bos.svg";
+import Bpt from "cryptocurrency-icons/svg/color/bpt.svg";
+import Bq from "cryptocurrency-icons/svg/color/bq.svg";
+import Brd from "cryptocurrency-icons/svg/color/brd.svg";
+import Bsd from "cryptocurrency-icons/svg/color/bsd.svg";
+import Bsv from "cryptocurrency-icons/svg/color/bsv.svg";
+import Btc from "cryptocurrency-icons/svg/color/btc.svg";
+import Btcd from "cryptocurrency-icons/svg/color/btcd.svg";
+import Btch from "cryptocurrency-icons/svg/color/btch.svg";
+import Btcp from "cryptocurrency-icons/svg/color/btcp.svg";
+import Btcz from "cryptocurrency-icons/svg/color/btcz.svg";
+import Btdx from "cryptocurrency-icons/svg/color/btdx.svg";
+import Btg from "cryptocurrency-icons/svg/color/btg.svg";
+import Btm from "cryptocurrency-icons/svg/color/btm.svg";
+import Bts from "cryptocurrency-icons/svg/color/bts.svg";
+import Btt from "cryptocurrency-icons/svg/color/btt.svg";
+import Btx from "cryptocurrency-icons/svg/color/btx.svg";
+import Burst from "cryptocurrency-icons/svg/color/burst.svg";
+import Bze from "cryptocurrency-icons/svg/color/bze.svg";
+import Call from "cryptocurrency-icons/svg/color/call.svg";
+import Cc from "cryptocurrency-icons/svg/color/cc.svg";
+import Cdn from "cryptocurrency-icons/svg/color/cdn.svg";
+import Cdt from "cryptocurrency-icons/svg/color/cdt.svg";
+import Cenz from "cryptocurrency-icons/svg/color/cenz.svg";
+import Chain from "cryptocurrency-icons/svg/color/chain.svg";
+import Chat from "cryptocurrency-icons/svg/color/chat.svg";
+import Chips from "cryptocurrency-icons/svg/color/chips.svg";
+import Chsb from "cryptocurrency-icons/svg/color/chsb.svg";
+import Chz from "cryptocurrency-icons/svg/color/chz.svg";
+import Cix from "cryptocurrency-icons/svg/color/cix.svg";
+import Clam from "cryptocurrency-icons/svg/color/clam.svg";
+import Cloak from "cryptocurrency-icons/svg/color/cloak.svg";
+import Cmm from "cryptocurrency-icons/svg/color/cmm.svg";
+import Cmt from "cryptocurrency-icons/svg/color/cmt.svg";
+import Cnd from "cryptocurrency-icons/svg/color/cnd.svg";
+import Cnx from "cryptocurrency-icons/svg/color/cnx.svg";
+import Cny from "cryptocurrency-icons/svg/color/cny.svg";
+import Cob from "cryptocurrency-icons/svg/color/cob.svg";
+import Colx from "cryptocurrency-icons/svg/color/colx.svg";
+import Comp from "cryptocurrency-icons/svg/color/comp.svg";
+import Coqui from "cryptocurrency-icons/svg/color/coqui.svg";
+import Cred from "cryptocurrency-icons/svg/color/cred.svg";
+import Crpt from "cryptocurrency-icons/svg/color/crpt.svg";
+import Crv from "cryptocurrency-icons/svg/color/crv.svg";
+import Crw from "cryptocurrency-icons/svg/color/crw.svg";
+import Cs from "cryptocurrency-icons/svg/color/cs.svg";
+import Ctr from "cryptocurrency-icons/svg/color/ctr.svg";
+import Ctxc from "cryptocurrency-icons/svg/color/ctxc.svg";
+import Cvc from "cryptocurrency-icons/svg/color/cvc.svg";
+import D from "cryptocurrency-icons/svg/color/d.svg";
+import Dai from "cryptocurrency-icons/svg/color/dai.svg";
+import Dash from "cryptocurrency-icons/svg/color/dash.svg";
+import Dat from "cryptocurrency-icons/svg/color/dat.svg";
+import Data from "cryptocurrency-icons/svg/color/data.svg";
+import Dbc from "cryptocurrency-icons/svg/color/dbc.svg";
+import Dcn from "cryptocurrency-icons/svg/color/dcn.svg";
+import Dcr from "cryptocurrency-icons/svg/color/dcr.svg";
+import Deez from "cryptocurrency-icons/svg/color/deez.svg";
+import Dent from "cryptocurrency-icons/svg/color/dent.svg";
+import Dew from "cryptocurrency-icons/svg/color/dew.svg";
+import Dgb from "cryptocurrency-icons/svg/color/dgb.svg";
+import Dgd from "cryptocurrency-icons/svg/color/dgd.svg";
+import Dlt from "cryptocurrency-icons/svg/color/dlt.svg";
+import Dnt from "cryptocurrency-icons/svg/color/dnt.svg";
+import Dock from "cryptocurrency-icons/svg/color/dock.svg";
+import Doge from "cryptocurrency-icons/svg/color/doge.svg";
+import Dot from "cryptocurrency-icons/svg/color/dot.svg";
+import Drgn from "cryptocurrency-icons/svg/color/drgn.svg";
+import Drop from "cryptocurrency-icons/svg/color/drop.svg";
+import Dta from "cryptocurrency-icons/svg/color/dta.svg";
+import Dth from "cryptocurrency-icons/svg/color/dth.svg";
+import Dtr from "cryptocurrency-icons/svg/color/dtr.svg";
+import Ebst from "cryptocurrency-icons/svg/color/ebst.svg";
+import Eca from "cryptocurrency-icons/svg/color/eca.svg";
+import Edg from "cryptocurrency-icons/svg/color/edg.svg";
+import Edo from "cryptocurrency-icons/svg/color/edo.svg";
+import Edoge from "cryptocurrency-icons/svg/color/edoge.svg";
+import Ela from "cryptocurrency-icons/svg/color/ela.svg";
+import Elec from "cryptocurrency-icons/svg/color/elec.svg";
+import Elf from "cryptocurrency-icons/svg/color/elf.svg";
+import Elix from "cryptocurrency-icons/svg/color/elix.svg";
+import Ella from "cryptocurrency-icons/svg/color/ella.svg";
+import Emb from "cryptocurrency-icons/svg/color/emb.svg";
+import Emc from "cryptocurrency-icons/svg/color/emc.svg";
+import Emc2 from "cryptocurrency-icons/svg/color/emc2.svg";
+import Eng from "cryptocurrency-icons/svg/color/eng.svg";
+import Enj from "cryptocurrency-icons/svg/color/enj.svg";
+import Entrp from "cryptocurrency-icons/svg/color/entrp.svg";
+import Eon from "cryptocurrency-icons/svg/color/eon.svg";
+import Eop from "cryptocurrency-icons/svg/color/eop.svg";
+import Eos from "cryptocurrency-icons/svg/color/eos.svg";
+import Eqli from "cryptocurrency-icons/svg/color/eqli.svg";
+import Equa from "cryptocurrency-icons/svg/color/equa.svg";
+import Etc from "cryptocurrency-icons/svg/color/etc.svg";
+import Eth from "cryptocurrency-icons/svg/color/eth.svg";
+import Ethos from "cryptocurrency-icons/svg/color/ethos.svg";
+import Etn from "cryptocurrency-icons/svg/color/etn.svg";
+import Etp from "cryptocurrency-icons/svg/color/etp.svg";
+import Eur from "cryptocurrency-icons/svg/color/eur.svg";
+import Evx from "cryptocurrency-icons/svg/color/evx.svg";
+import Exmo from "cryptocurrency-icons/svg/color/exmo.svg";
+import Exp from "cryptocurrency-icons/svg/color/exp.svg";
+import Fair from "cryptocurrency-icons/svg/color/fair.svg";
+import Fct from "cryptocurrency-icons/svg/color/fct.svg";
+import Fida from "cryptocurrency-icons/svg/color/fida.svg";
+import Fil from "cryptocurrency-icons/svg/color/fil.svg";
+import Fjc from "cryptocurrency-icons/svg/color/fjc.svg";
+import Fldc from "cryptocurrency-icons/svg/color/fldc.svg";
+import Flo from "cryptocurrency-icons/svg/color/flo.svg";
+import Flux from "cryptocurrency-icons/svg/color/flux.svg";
+import Fsn from "cryptocurrency-icons/svg/color/fsn.svg";
+import Ftc from "cryptocurrency-icons/svg/color/ftc.svg";
+import Fuel from "cryptocurrency-icons/svg/color/fuel.svg";
+import Fun from "cryptocurrency-icons/svg/color/fun.svg";
+import Game from "cryptocurrency-icons/svg/color/game.svg";
+import Gas from "cryptocurrency-icons/svg/color/gas.svg";
+import Gbp from "cryptocurrency-icons/svg/color/gbp.svg";
+import Gbx from "cryptocurrency-icons/svg/color/gbx.svg";
+import Gbyte from "cryptocurrency-icons/svg/color/gbyte.svg";
+import Generic from "cryptocurrency-icons/svg/color/generic.svg";
+import Gin from "cryptocurrency-icons/svg/color/gin.svg";
+import Glxt from "cryptocurrency-icons/svg/color/glxt.svg";
+import Gmr from "cryptocurrency-icons/svg/color/gmr.svg";
+import Gmt from "cryptocurrency-icons/svg/color/gmt.svg";
+import Gno from "cryptocurrency-icons/svg/color/gno.svg";
+import Gnt from "cryptocurrency-icons/svg/color/gnt.svg";
+import Gold from "cryptocurrency-icons/svg/color/gold.svg";
+import Grc from "cryptocurrency-icons/svg/color/grc.svg";
+import Grin from "cryptocurrency-icons/svg/color/grin.svg";
+import Grs from "cryptocurrency-icons/svg/color/grs.svg";
+import Grt from "cryptocurrency-icons/svg/color/grt.svg";
+import Gsc from "cryptocurrency-icons/svg/color/gsc.svg";
+import Gto from "cryptocurrency-icons/svg/color/gto.svg";
+import Gup from "cryptocurrency-icons/svg/color/gup.svg";
+import Gusd from "cryptocurrency-icons/svg/color/gusd.svg";
+import Gvt from "cryptocurrency-icons/svg/color/gvt.svg";
+import Gxs from "cryptocurrency-icons/svg/color/gxs.svg";
+import Gzr from "cryptocurrency-icons/svg/color/gzr.svg";
+import Hight from "cryptocurrency-icons/svg/color/hight.svg";
+import Hns from "cryptocurrency-icons/svg/color/hns.svg";
+import Hodl from "cryptocurrency-icons/svg/color/hodl.svg";
+import Hot from "cryptocurrency-icons/svg/color/hot.svg";
+import Hpb from "cryptocurrency-icons/svg/color/hpb.svg";
+import Hsr from "cryptocurrency-icons/svg/color/hsr.svg";
+import Ht from "cryptocurrency-icons/svg/color/ht.svg";
+import Html from "cryptocurrency-icons/svg/color/html.svg";
+import Huc from "cryptocurrency-icons/svg/color/huc.svg";
+import Husd from "cryptocurrency-icons/svg/color/husd.svg";
+import Hush from "cryptocurrency-icons/svg/color/hush.svg";
+import Icn from "cryptocurrency-icons/svg/color/icn.svg";
+import Icp from "cryptocurrency-icons/svg/color/icp.svg";
+import Icx from "cryptocurrency-icons/svg/color/icx.svg";
+import Ignis from "cryptocurrency-icons/svg/color/ignis.svg";
+import Ilk from "cryptocurrency-icons/svg/color/ilk.svg";
+import Ink from "cryptocurrency-icons/svg/color/ink.svg";
+import Ins from "cryptocurrency-icons/svg/color/ins.svg";
+import Ion from "cryptocurrency-icons/svg/color/ion.svg";
+import Iop from "cryptocurrency-icons/svg/color/iop.svg";
+import Iost from "cryptocurrency-icons/svg/color/iost.svg";
+import Iotx from "cryptocurrency-icons/svg/color/iotx.svg";
+import Iq from "cryptocurrency-icons/svg/color/iq.svg";
+import Itc from "cryptocurrency-icons/svg/color/itc.svg";
+import Jnt from "cryptocurrency-icons/svg/color/jnt.svg";
+import Jpy from "cryptocurrency-icons/svg/color/jpy.svg";
+import Kcs from "cryptocurrency-icons/svg/color/kcs.svg";
+import Kin from "cryptocurrency-icons/svg/color/kin.svg";
+import Klown from "cryptocurrency-icons/svg/color/klown.svg";
+import Kmd from "cryptocurrency-icons/svg/color/kmd.svg";
+import Knc from "cryptocurrency-icons/svg/color/knc.svg";
+import Krb from "cryptocurrency-icons/svg/color/krb.svg";
+import Ksm from "cryptocurrency-icons/svg/color/ksm.svg";
+import Lbc from "cryptocurrency-icons/svg/color/lbc.svg";
+import Lend from "cryptocurrency-icons/svg/color/lend.svg";
+import Leo from "cryptocurrency-icons/svg/color/leo.svg";
+import Link from "cryptocurrency-icons/svg/color/link.svg";
+import Lkk from "cryptocurrency-icons/svg/color/lkk.svg";
+import Loom from "cryptocurrency-icons/svg/color/loom.svg";
+import Lpt from "cryptocurrency-icons/svg/color/lpt.svg";
+import Lrc from "cryptocurrency-icons/svg/color/lrc.svg";
+import Lsk from "cryptocurrency-icons/svg/color/lsk.svg";
+import Ltc from "cryptocurrency-icons/svg/color/ltc.svg";
+import Lun from "cryptocurrency-icons/svg/color/lun.svg";
+import Maid from "cryptocurrency-icons/svg/color/maid.svg";
+import Mana from "cryptocurrency-icons/svg/color/mana.svg";
+import Matic from "cryptocurrency-icons/svg/color/matic.svg";
+import Max from "cryptocurrency-icons/svg/color/max.svg";
+import Mcap from "cryptocurrency-icons/svg/color/mcap.svg";
+import Mco from "cryptocurrency-icons/svg/color/mco.svg";
+import Mda from "cryptocurrency-icons/svg/color/mda.svg";
+import Mds from "cryptocurrency-icons/svg/color/mds.svg";
+import Med from "cryptocurrency-icons/svg/color/med.svg";
+import Meetone from "cryptocurrency-icons/svg/color/meetone.svg";
+import Mft from "cryptocurrency-icons/svg/color/mft.svg";
+import Miota from "cryptocurrency-icons/svg/color/miota.svg";
+import Mith from "cryptocurrency-icons/svg/color/mith.svg";
+import Mkr from "cryptocurrency-icons/svg/color/mkr.svg";
+import Mln from "cryptocurrency-icons/svg/color/mln.svg";
+import Mnx from "cryptocurrency-icons/svg/color/mnx.svg";
+import Mnz from "cryptocurrency-icons/svg/color/mnz.svg";
+import Moac from "cryptocurrency-icons/svg/color/moac.svg";
+import Mod from "cryptocurrency-icons/svg/color/mod.svg";
+import Mona from "cryptocurrency-icons/svg/color/mona.svg";
+import Msr from "cryptocurrency-icons/svg/color/msr.svg";
+import Mth from "cryptocurrency-icons/svg/color/mth.svg";
+import Mtl from "cryptocurrency-icons/svg/color/mtl.svg";
+import Music from "cryptocurrency-icons/svg/color/music.svg";
+import Mzc from "cryptocurrency-icons/svg/color/mzc.svg";
+import Nano from "cryptocurrency-icons/svg/color/nano.svg";
+import Nas from "cryptocurrency-icons/svg/color/nas.svg";
+import Nav from "cryptocurrency-icons/svg/color/nav.svg";
+import Ncash from "cryptocurrency-icons/svg/color/ncash.svg";
+import Ndz from "cryptocurrency-icons/svg/color/ndz.svg";
+import Nebl from "cryptocurrency-icons/svg/color/nebl.svg";
+import Neo from "cryptocurrency-icons/svg/color/neo.svg";
+import Neos from "cryptocurrency-icons/svg/color/neos.svg";
+import Neu from "cryptocurrency-icons/svg/color/neu.svg";
+import Nexo from "cryptocurrency-icons/svg/color/nexo.svg";
+import Ngc from "cryptocurrency-icons/svg/color/ngc.svg";
+import Nio from "cryptocurrency-icons/svg/color/nio.svg";
+import Nkn from "cryptocurrency-icons/svg/color/nkn.svg";
+import Nlc2 from "cryptocurrency-icons/svg/color/nlc2.svg";
+import Nlg from "cryptocurrency-icons/svg/color/nlg.svg";
+import Nmc from "cryptocurrency-icons/svg/color/nmc.svg";
+import Nmr from "cryptocurrency-icons/svg/color/nmr.svg";
+import Npxs from "cryptocurrency-icons/svg/color/npxs.svg";
+import Ntbc from "cryptocurrency-icons/svg/color/ntbc.svg";
+import Nuls from "cryptocurrency-icons/svg/color/nuls.svg";
+import Nxs from "cryptocurrency-icons/svg/color/nxs.svg";
+import Nxt from "cryptocurrency-icons/svg/color/nxt.svg";
+import Oax from "cryptocurrency-icons/svg/color/oax.svg";
+import Ok from "cryptocurrency-icons/svg/color/ok.svg";
+import Omg from "cryptocurrency-icons/svg/color/omg.svg";
+import Omni from "cryptocurrency-icons/svg/color/omni.svg";
+import One from "cryptocurrency-icons/svg/color/one.svg";
+import Ong from "cryptocurrency-icons/svg/color/ong.svg";
+import Ont from "cryptocurrency-icons/svg/color/ont.svg";
+import Oot from "cryptocurrency-icons/svg/color/oot.svg";
+import Ost from "cryptocurrency-icons/svg/color/ost.svg";
+import Ox from "cryptocurrency-icons/svg/color/ox.svg";
+import Oxt from "cryptocurrency-icons/svg/color/oxt.svg";
+import Oxy from "cryptocurrency-icons/svg/color/oxy.svg";
+import Part from "cryptocurrency-icons/svg/color/part.svg";
+import Pasc from "cryptocurrency-icons/svg/color/pasc.svg";
+import Pasl from "cryptocurrency-icons/svg/color/pasl.svg";
+import Pax from "cryptocurrency-icons/svg/color/pax.svg";
+import Paxg from "cryptocurrency-icons/svg/color/paxg.svg";
+import Pay from "cryptocurrency-icons/svg/color/pay.svg";
+import Payx from "cryptocurrency-icons/svg/color/payx.svg";
+import Pink from "cryptocurrency-icons/svg/color/pink.svg";
+import Pirl from "cryptocurrency-icons/svg/color/pirl.svg";
+import Pivx from "cryptocurrency-icons/svg/color/pivx.svg";
+import Plr from "cryptocurrency-icons/svg/color/plr.svg";
+import Poa from "cryptocurrency-icons/svg/color/poa.svg";
+import Poe from "cryptocurrency-icons/svg/color/poe.svg";
+import Polis from "cryptocurrency-icons/svg/color/polis.svg";
+import Poly from "cryptocurrency-icons/svg/color/poly.svg";
+import Pot from "cryptocurrency-icons/svg/color/pot.svg";
+import Powr from "cryptocurrency-icons/svg/color/powr.svg";
+import Ppc from "cryptocurrency-icons/svg/color/ppc.svg";
+import Ppp from "cryptocurrency-icons/svg/color/ppp.svg";
+import Ppt from "cryptocurrency-icons/svg/color/ppt.svg";
+import Pre from "cryptocurrency-icons/svg/color/pre.svg";
+import Prl from "cryptocurrency-icons/svg/color/prl.svg";
+import Pungo from "cryptocurrency-icons/svg/color/pungo.svg";
+import Pura from "cryptocurrency-icons/svg/color/pura.svg";
+import Qash from "cryptocurrency-icons/svg/color/qash.svg";
+import Qiwi from "cryptocurrency-icons/svg/color/qiwi.svg";
+import Qlc from "cryptocurrency-icons/svg/color/qlc.svg";
+import Qnt from "cryptocurrency-icons/svg/color/qnt.svg";
+import Qrl from "cryptocurrency-icons/svg/color/qrl.svg";
+import Qsp from "cryptocurrency-icons/svg/color/qsp.svg";
+import Qtum from "cryptocurrency-icons/svg/color/qtum.svg";
+import R from "cryptocurrency-icons/svg/color/r.svg";
+import Rads from "cryptocurrency-icons/svg/color/rads.svg";
+import Rap from "cryptocurrency-icons/svg/color/rap.svg";
+import Ray from "cryptocurrency-icons/svg/color/ray.svg";
+import Rcn from "cryptocurrency-icons/svg/color/rcn.svg";
+import Rdd from "cryptocurrency-icons/svg/color/rdd.svg";
+import Rdn from "cryptocurrency-icons/svg/color/rdn.svg";
+import Ren from "cryptocurrency-icons/svg/color/ren.svg";
+import Rep from "cryptocurrency-icons/svg/color/rep.svg";
+import Repv2 from "cryptocurrency-icons/svg/color/repv2.svg";
+import Req from "cryptocurrency-icons/svg/color/req.svg";
+import Rhoc from "cryptocurrency-icons/svg/color/rhoc.svg";
+import Ric from "cryptocurrency-icons/svg/color/ric.svg";
+import Rise from "cryptocurrency-icons/svg/color/rise.svg";
+import Rlc from "cryptocurrency-icons/svg/color/rlc.svg";
+import Rpx from "cryptocurrency-icons/svg/color/rpx.svg";
+import Rub from "cryptocurrency-icons/svg/color/rub.svg";
+import Rvn from "cryptocurrency-icons/svg/color/rvn.svg";
+import Ryo from "cryptocurrency-icons/svg/color/ryo.svg";
+import Safe from "cryptocurrency-icons/svg/color/safe.svg";
+import Safemoon from "cryptocurrency-icons/svg/color/safemoon.svg";
+import Sai from "cryptocurrency-icons/svg/color/sai.svg";
+import Salt from "cryptocurrency-icons/svg/color/salt.svg";
+import San from "cryptocurrency-icons/svg/color/san.svg";
+import Sand from "cryptocurrency-icons/svg/color/sand.svg";
+import Sbd from "cryptocurrency-icons/svg/color/sbd.svg";
+import Sberbank from "cryptocurrency-icons/svg/color/sberbank.svg";
+import Sc from "cryptocurrency-icons/svg/color/sc.svg";
+import Ser from "cryptocurrency-icons/svg/color/ser.svg";
+import Shift from "cryptocurrency-icons/svg/color/shift.svg";
+import Sib from "cryptocurrency-icons/svg/color/sib.svg";
+import Sin from "cryptocurrency-icons/svg/color/sin.svg";
+import Skl from "cryptocurrency-icons/svg/color/skl.svg";
+import Sky from "cryptocurrency-icons/svg/color/sky.svg";
+import Slr from "cryptocurrency-icons/svg/color/slr.svg";
+import Sls from "cryptocurrency-icons/svg/color/sls.svg";
+import Smart from "cryptocurrency-icons/svg/color/smart.svg";
+import Sngls from "cryptocurrency-icons/svg/color/sngls.svg";
+import Snm from "cryptocurrency-icons/svg/color/snm.svg";
+import Snt from "cryptocurrency-icons/svg/color/snt.svg";
+import Snx from "cryptocurrency-icons/svg/color/snx.svg";
+import Soc from "cryptocurrency-icons/svg/color/soc.svg";
+import Sol from "cryptocurrency-icons/svg/color/sol.svg";
+import Spacehbit from "cryptocurrency-icons/svg/color/spacehbit.svg";
+import Spank from "cryptocurrency-icons/svg/color/spank.svg";
+import Sphtx from "cryptocurrency-icons/svg/color/sphtx.svg";
+import Srn from "cryptocurrency-icons/svg/color/srn.svg";
+import Stak from "cryptocurrency-icons/svg/color/stak.svg";
+import Start from "cryptocurrency-icons/svg/color/start.svg";
+import Steem from "cryptocurrency-icons/svg/color/steem.svg";
+import Storj from "cryptocurrency-icons/svg/color/storj.svg";
+import Storm from "cryptocurrency-icons/svg/color/storm.svg";
+import Stox from "cryptocurrency-icons/svg/color/stox.svg";
+import Stq from "cryptocurrency-icons/svg/color/stq.svg";
+import Strat from "cryptocurrency-icons/svg/color/strat.svg";
+import Stx from "cryptocurrency-icons/svg/color/stx.svg";
+import Sub from "cryptocurrency-icons/svg/color/sub.svg";
+import Sumo from "cryptocurrency-icons/svg/color/sumo.svg";
+import Sushi from "cryptocurrency-icons/svg/color/sushi.svg";
+import Sys from "cryptocurrency-icons/svg/color/sys.svg";
+import Taas from "cryptocurrency-icons/svg/color/taas.svg";
+import Tau from "cryptocurrency-icons/svg/color/tau.svg";
+import Tbx from "cryptocurrency-icons/svg/color/tbx.svg";
+import Tel from "cryptocurrency-icons/svg/color/tel.svg";
+import Ten from "cryptocurrency-icons/svg/color/ten.svg";
+import Tern from "cryptocurrency-icons/svg/color/tern.svg";
+import Tgch from "cryptocurrency-icons/svg/color/tgch.svg";
+import Theta from "cryptocurrency-icons/svg/color/theta.svg";
+import Tix from "cryptocurrency-icons/svg/color/tix.svg";
+import Tkn from "cryptocurrency-icons/svg/color/tkn.svg";
+import Tks from "cryptocurrency-icons/svg/color/tks.svg";
+import Tnb from "cryptocurrency-icons/svg/color/tnb.svg";
+import Tnc from "cryptocurrency-icons/svg/color/tnc.svg";
+import Tnt from "cryptocurrency-icons/svg/color/tnt.svg";
+import Tomo from "cryptocurrency-icons/svg/color/tomo.svg";
+import Tpay from "cryptocurrency-icons/svg/color/tpay.svg";
+import Trig from "cryptocurrency-icons/svg/color/trig.svg";
+import Trtl from "cryptocurrency-icons/svg/color/trtl.svg";
+import Trx from "cryptocurrency-icons/svg/color/trx.svg";
+import Tusd from "cryptocurrency-icons/svg/color/tusd.svg";
+import Tzc from "cryptocurrency-icons/svg/color/tzc.svg";
+import Ubq from "cryptocurrency-icons/svg/color/ubq.svg";
+import Uma from "cryptocurrency-icons/svg/color/uma.svg";
+import Uni from "cryptocurrency-icons/svg/color/uni.svg";
+import Unity from "cryptocurrency-icons/svg/color/unity.svg";
+import Usd from "cryptocurrency-icons/svg/color/usd.svg";
+import Usdc from "cryptocurrency-icons/svg/color/usdc.svg";
+import Usdt from "cryptocurrency-icons/svg/color/usdt.svg";
+import Utk from "cryptocurrency-icons/svg/color/utk.svg";
+import Veri from "cryptocurrency-icons/svg/color/veri.svg";
+import Vet from "cryptocurrency-icons/svg/color/vet.svg";
+import Via from "cryptocurrency-icons/svg/color/via.svg";
+import Vib from "cryptocurrency-icons/svg/color/vib.svg";
+import Vibe from "cryptocurrency-icons/svg/color/vibe.svg";
+import Vivo from "cryptocurrency-icons/svg/color/vivo.svg";
+import Vrc from "cryptocurrency-icons/svg/color/vrc.svg";
+import Vrsc from "cryptocurrency-icons/svg/color/vrsc.svg";
+import Vtc from "cryptocurrency-icons/svg/color/vtc.svg";
+import Vtho from "cryptocurrency-icons/svg/color/vtho.svg";
+import Wabi from "cryptocurrency-icons/svg/color/wabi.svg";
+import Wan from "cryptocurrency-icons/svg/color/wan.svg";
+import Waves from "cryptocurrency-icons/svg/color/waves.svg";
+import Wax from "cryptocurrency-icons/svg/color/wax.svg";
+import Wbtc from "cryptocurrency-icons/svg/color/wbtc.svg";
+import Wgr from "cryptocurrency-icons/svg/color/wgr.svg";
+import Wicc from "cryptocurrency-icons/svg/color/wicc.svg";
+import Wings from "cryptocurrency-icons/svg/color/wings.svg";
+import Wpr from "cryptocurrency-icons/svg/color/wpr.svg";
+import Wtc from "cryptocurrency-icons/svg/color/wtc.svg";
+import X from "cryptocurrency-icons/svg/color/x.svg";
+import Xas from "cryptocurrency-icons/svg/color/xas.svg";
+import Xbc from "cryptocurrency-icons/svg/color/xbc.svg";
+import Xbp from "cryptocurrency-icons/svg/color/xbp.svg";
+import Xby from "cryptocurrency-icons/svg/color/xby.svg";
+import Xcp from "cryptocurrency-icons/svg/color/xcp.svg";
+import Xdn from "cryptocurrency-icons/svg/color/xdn.svg";
+import Xem from "cryptocurrency-icons/svg/color/xem.svg";
+import Xin from "cryptocurrency-icons/svg/color/xin.svg";
+import Xlm from "cryptocurrency-icons/svg/color/xlm.svg";
+import Xmcc from "cryptocurrency-icons/svg/color/xmcc.svg";
+import Xmg from "cryptocurrency-icons/svg/color/xmg.svg";
+import Xmo from "cryptocurrency-icons/svg/color/xmo.svg";
+import Xmr from "cryptocurrency-icons/svg/color/xmr.svg";
+import Xmy from "cryptocurrency-icons/svg/color/xmy.svg";
+import Xp from "cryptocurrency-icons/svg/color/xp.svg";
+import Xpa from "cryptocurrency-icons/svg/color/xpa.svg";
+import Xpm from "cryptocurrency-icons/svg/color/xpm.svg";
+import Xpr from "cryptocurrency-icons/svg/color/xpr.svg";
+import Xrp from "cryptocurrency-icons/svg/color/xrp.svg";
+import Xsg from "cryptocurrency-icons/svg/color/xsg.svg";
+import Xtz from "cryptocurrency-icons/svg/color/xtz.svg";
+import Xuc from "cryptocurrency-icons/svg/color/xuc.svg";
+import Xvc from "cryptocurrency-icons/svg/color/xvc.svg";
+import Xvg from "cryptocurrency-icons/svg/color/xvg.svg";
+import Xzc from "cryptocurrency-icons/svg/color/xzc.svg";
+import Yfi from "cryptocurrency-icons/svg/color/yfi.svg";
+import Yoyow from "cryptocurrency-icons/svg/color/yoyow.svg";
+import Zcl from "cryptocurrency-icons/svg/color/zcl.svg";
+import Zec from "cryptocurrency-icons/svg/color/zec.svg";
+import Zel from "cryptocurrency-icons/svg/color/zel.svg";
+import Zen from "cryptocurrency-icons/svg/color/zen.svg";
+import Zest from "cryptocurrency-icons/svg/color/zest.svg";
+import Zil from "cryptocurrency-icons/svg/color/zil.svg";
+import Zilla from "cryptocurrency-icons/svg/color/zilla.svg";
+import Zrx from "cryptocurrency-icons/svg/color/zrx.svg";
+
+export const icons = {
+  $PAC: Pac,
+  "0XBTC": ZeroXbtc,
+  "1INCH": Oneinch,
+  "2GIVE": Twogive,
+  AAVE: Aave,
+  ABT: Abt,
+  ACTN: Actn,
+  ACT: Act,
+  ADA: Ada,
+  ADD: Add,
+  ADX: Adx,
+  AEON: Aeon,
+  AE: Ae,
+  AEUR: Aeur,
+  AGI: Agi,
+  AGRS: Agrs,
+  AION: Aion,
+  ALGO: Algo,
+  AMB: Amb,
+  AMPL: Ampl,
+  AMP: Amp,
+  ANKR: Ankr,
+  ANT: Ant,
+  APE: Ape,
+  APEX: Apex,
+  APPC: Appc,
+  ARDR: Ardr,
+  ARG: Arg,
+  ARK: Ark,
+  ARN: Arn,
+  ARNX: Arnx,
+  ARY: Ary,
+  AST: Ast,
+  ATLAS: Atlas,
+  ATM: Atm,
+  ATOM: Atom,
+  AUDR: Audr,
+  AURY: Aury,
+  AUTO: Auto,
+  AVAX: Avax,
+  AYWA: Aywa,
+  BAB: Bab,
+  BAL: Bal,
+  BAND: Band,
+  BAT: Bat,
+  BAY: Bay,
+  BCBC: Bcbc,
+  BCC: Bcc,
+  BCD: Bcd,
+  BCH: Bch,
+  BCIO: Bcio,
+  BCN: Bcn,
+  BCO: Bco,
+  BCPT: Bcpt,
+  BDL: Bdl,
+  BEAM: Beam,
+  BELA: Bela,
+  BIX: Bix,
+  BLCN: Blcn,
+  BLK: Blk,
+  BLOCK: Block,
+  BLZ: Blz,
+  BNB: Bnb,
+  BNT: Bnt,
+  BNTY: Bnty,
+  BOOTY: Booty,
+  BOS: Bos,
+  BPT: Bpt,
+  BQ: Bq,
+  BRD: Brd,
+  BSD: Bsd,
+  BSV: Bsv,
+  BTCD: Btcd,
+  BTCH: Btch,
+  BTCP: Btcp,
+  BTC: Btc,
+  BTCZ: Btcz,
+  BTDX: Btdx,
+  BTG: Btg,
+  BTM: Btm,
+  BTS: Bts,
+  BTT: Btt,
+  BTX: Btx,
+  BURST: Burst,
+  BZE: Bze,
+  CALL: Call,
+  CC: Cc,
+  CDN: Cdn,
+  CDT: Cdt,
+  CENZ: Cenz,
+  CHAIN: Chain,
+  CHAT: Chat,
+  CHIPS: Chips,
+  CHSB: Chsb,
+  CHZ: Chz,
+  CIX: Cix,
+  CLAM: Clam,
+  CLOAK: Cloak,
+  CMM: Cmm,
+  CMT: Cmt,
+  CND: Cnd,
+  CNX: Cnx,
+  CNY: Cny,
+  COB: Cob,
+  COLX: Colx,
+  COMP: Comp,
+  COQUI: Coqui,
+  CRED: Cred,
+  CRPT: Crpt,
+  CRV: Crv,
+  CRW: Crw,
+  CS: Cs,
+  CTR: Ctr,
+  CTXC: Ctxc,
+  CVC: Cvc,
+  DAI: Dai,
+  DASH: Dash,
+  DATA: Data,
+  DAT: Dat,
+  DBC: Dbc,
+  DCN: Dcn,
+  DCR: Dcr,
+  DEEZ: Deez,
+  DENT: Dent,
+  DEW: Dew,
+  DGB: Dgb,
+  DGD: Dgd,
+  DLT: Dlt,
+  DNT: Dnt,
+  DOCK: Dock,
+  DOGE: Doge,
+  DOT: Dot,
+  DRGN: Drgn,
+  DROP: Drop,
+  D: D,
+  DTA: Dta,
+  DTH: Dth,
+  DTR: Dtr,
+  EBST: Ebst,
+  ECA: Eca,
+  EDG: Edg,
+  EDOGE: Edoge,
+  EDO: Edo,
+  ELA: Ela,
+  ELEC: Elec,
+  ELF: Elf,
+  ELIX: Elix,
+  ELLA: Ella,
+  EMB: Emb,
+  EMC2: Emc2,
+  EMC: Emc,
+  ENG: Eng,
+  ENJ: Enj,
+  ENTRP: Entrp,
+  EON: Eon,
+  EOP: Eop,
+  EOS: Eos,
+  EQLI: Eqli,
+  EQUA: Equa,
+  ETC: Etc,
+  ETHOS: Ethos,
+  ETH: Eth,
+  ETN: Etn,
+  ETP: Etp,
+  EUR: Eur,
+  EVX: Evx,
+  EXMO: Exmo,
+  EXP: Exp,
+  FAIR: Fair,
+  FCT: Fct,
+  FIDA: Fida,
+  FIL: Fil,
+  FJC: Fjc,
+  FLDC: Fldc,
+  FLO: Flo,
+  FLUX: Flux,
+  FSN: Fsn,
+  FTC: Ftc,
+  FUEL: Fuel,
+  FUN: Fun,
+  GAME: Game,
+  GAS: Gas,
+  GBP: Gbp,
+  GBX: Gbx,
+  GBYTE: Gbyte,
+  GENERIC: Generic,
+  GIN: Gin,
+  GLXT: Glxt,
+  GMR: Gmr,
+  GMT: Gmt,
+  GNO: Gno,
+  GNT: Gnt,
+  GOLD: Gold,
+  GRC: Grc,
+  GRIN: Grin,
+  GRS: Grs,
+  GRT: Grt,
+  GSC: Gsc,
+  GTO: Gto,
+  GUP: Gup,
+  GUSD: Gusd,
+  GVT: Gvt,
+  GXS: Gxs,
+  GZR: Gzr,
+  HIGHT: Hight,
+  HNS: Hns,
+  HODL: Hodl,
+  HOT: Hot,
+  HPB: Hpb,
+  HSR: Hsr,
+  HTML: Html,
+  HT: Ht,
+  HUC: Huc,
+  HUSD: Husd,
+  HUSH: Hush,
+  ICN: Icn,
+  ICP: Icp,
+  ICX: Icx,
+  IGNIS: Ignis,
+  ILK: Ilk,
+  INK: Ink,
+  INS: Ins,
+  ION: Ion,
+  IOP: Iop,
+  IOST: Iost,
+  IOTX: Iotx,
+  IQ: Iq,
+  ITC: Itc,
+  JNT: Jnt,
+  JPY: Jpy,
+  KCS: Kcs,
+  KIN: Kin,
+  KLOWN: Klown,
+  KMD: Kmd,
+  KNC: Knc,
+  KRB: Krb,
+  KSM: Ksm,
+  LBC: Lbc,
+  LEND: Lend,
+  LEO: Leo,
+  LINK: Link,
+  LKK: Lkk,
+  LOOM: Loom,
+  LPT: Lpt,
+  LRC: Lrc,
+  LSK: Lsk,
+  LTC: Ltc,
+  LUN: Lun,
+  MAID: Maid,
+  MANA: Mana,
+  MATIC: Matic,
+  MAX: Max,
+  MCAP: Mcap,
+  MCO: Mco,
+  MDA: Mda,
+  MDS: Mds,
+  MED: Med,
+  MEETONE: Meetone,
+  MFT: Mft,
+  MIOTA: Miota,
+  MITH: Mith,
+  MKR: Mkr,
+  MLN: Mln,
+  MNX: Mnx,
+  MNZ: Mnz,
+  MOAC: Moac,
+  MOD: Mod,
+  MONA: Mona,
+  MSR: Msr,
+  MTH: Mth,
+  MTL: Mtl,
+  MUSIC: Music,
+  MZC: Mzc,
+  NANO: Nano,
+  NAS: Nas,
+  NAV: Nav,
+  NCASH: Ncash,
+  NDZ: Ndz,
+  NEBL: Nebl,
+  NEOS: Neos,
+  NEO: Neo,
+  NEU: Neu,
+  NEXO: Nexo,
+  NGC: Ngc,
+  NIO: Nio,
+  NKN: Nkn,
+  NLC2: Nlc2,
+  NLG: Nlg,
+  NMC: Nmc,
+  NMR: Nmr,
+  NPXS: Npxs,
+  NTBC: Ntbc,
+  NULS: Nuls,
+  NXS: Nxs,
+  NXT: Nxt,
+  OAX: Oax,
+  OK: Ok,
+  OMG: Omg,
+  OMNI: Omni,
+  ONE: One,
+  ONG: Ong,
+  ONT: Ont,
+  OOT: Oot,
+  OST: Ost,
+  OX: Ox,
+  OXT: Oxt,
+  OXY: Oxy,
+  PART: Part,
+  PASC: Pasc,
+  PASL: Pasl,
+  PAXG: Paxg,
+  PAX: Pax,
+  PAY: Pay,
+  PAYX: Payx,
+  PINK: Pink,
+  PIRL: Pirl,
+  PIVX: Pivx,
+  PLR: Plr,
+  POA: Poa,
+  POE: Poe,
+  POLIS: Polis,
+  POLY: Poly,
+  POT: Pot,
+  POWR: Powr,
+  PPC: Ppc,
+  PPP: Ppp,
+  PPT: Ppt,
+  PRE: Pre,
+  PRL: Prl,
+  PUNGO: Pungo,
+  PURA: Pura,
+  QASH: Qash,
+  QIWI: Qiwi,
+  QLC: Qlc,
+  QNT: Qnt,
+  QRL: Qrl,
+  QSP: Qsp,
+  QTUM: Qtum,
+  RADS: Rads,
+  RAP: Rap,
+  RAY: Ray,
+  RCN: Rcn,
+  RDD: Rdd,
+  RDN: Rdn,
+  REN: Ren,
+  REP: Rep,
+  REPV2: Repv2,
+  REQ: Req,
+  RHOC: Rhoc,
+  RIC: Ric,
+  RISE: Rise,
+  RLC: Rlc,
+  RPX: Rpx,
+  R: R,
+  RUB: Rub,
+  RVN: Rvn,
+  RYO: Ryo,
+  SAFEMOON: Safemoon,
+  SAFE: Safe,
+  SAI: Sai,
+  SALT: Salt,
+  SAND: Sand,
+  SAN: San,
+  SBD: Sbd,
+  SBERBANK: Sberbank,
+  SC: Sc,
+  SER: Ser,
+  SHIFT: Shift,
+  SIB: Sib,
+  SIN: Sin,
+  SKL: Skl,
+  SKY: Sky,
+  SLR: Slr,
+  SLS: Sls,
+  SMART: Smart,
+  SNGLS: Sngls,
+  SNM: Snm,
+  SNT: Snt,
+  SNX: Snx,
+  SOC: Soc,
+  SOL: Sol,
+  SPACEHBIT: Spacehbit,
+  SPANK: Spank,
+  SPHTX: Sphtx,
+  SRN: Srn,
+  STAK: Stak,
+  START: Start,
+  STEEM: Steem,
+  STORJ: Storj,
+  STORM: Storm,
+  STOX: Stox,
+  STQ: Stq,
+  STRAT: Strat,
+  STX: Stx,
+  SUB: Sub,
+  SUMO: Sumo,
+  SUSHI: Sushi,
+  SYS: Sys,
+  TAAS: Taas,
+  TAU: Tau,
+  TBX: Tbx,
+  TEL: Tel,
+  TEN: Ten,
+  TERN: Tern,
+  TGCH: Tgch,
+  THETA: Theta,
+  TIX: Tix,
+  TKN: Tkn,
+  TKS: Tks,
+  TNB: Tnb,
+  TNC: Tnc,
+  TNT: Tnt,
+  TOMO: Tomo,
+  TPAY: Tpay,
+  TRIG: Trig,
+  TRTL: Trtl,
+  TRX: Trx,
+  TUSD: Tusd,
+  TZC: Tzc,
+  UBQ: Ubq,
+  UMA: Uma,
+  UNI: Uni,
+  UNITY: Unity,
+  USDC: Usdc,
+  USD: Usd,
+  USDT: Usdt,
+  UTK: Utk,
+  VERI: Veri,
+  VET: Vet,
+  VIA: Via,
+  VIBE: Vibe,
+  VIB: Vib,
+  VIVO: Vivo,
+  VRC: Vrc,
+  VRSC: Vrsc,
+  VTC: Vtc,
+  VTHO: Vtho,
+  WABI: Wabi,
+  WAN: Wan,
+  WAVES: Waves,
+  WAX: Wax,
+  WBTC: Wbtc,
+  WGR: Wgr,
+  WICC: Wicc,
+  WINGS: Wings,
+  WPR: Wpr,
+  WTC: Wtc,
+  XAS: Xas,
+  XBC: Xbc,
+  XBP: Xbp,
+  XBY: Xby,
+  XCP: Xcp,
+  XDN: Xdn,
+  XEM: Xem,
+  XIN: Xin,
+  XLM: Xlm,
+  XMCC: Xmcc,
+  XMG: Xmg,
+  XMO: Xmo,
+  XMR: Xmr,
+  XMY: Xmy,
+  XPA: Xpa,
+  XPM: Xpm,
+  XPR: Xpr,
+  XP: Xp,
+  XRP: Xrp,
+  XSG: Xsg,
+  X: X,
+  XTZ: Xtz,
+  XUC: Xuc,
+  XVC: Xvc,
+  XVG: Xvg,
+  XZC: Xzc,
+  YFI: Yfi,
+  YOYOW: Yoyow,
+  ZCL: Zcl,
+  ZEC: Zec,
+  ZEL: Zel,
+  ZEN: Zen,
+  ZEST: Zest,
+  ZILLA: Zilla,
+  ZIL: Zil,
+  ZRX: Zrx,
+};
+
+export const getIcon = (symbol: string) =>
+  symbol in icons ? icons[symbol as keyof typeof icons] : undefined;

+ 24 - 0
apps/insights/src/pyth.ts

@@ -0,0 +1,24 @@
+import {
+  PythHttpClient,
+  PythConnection,
+  getPythClusterApiUrl,
+  getPythProgramKeyForCluster,
+} from "@pythnetwork/client";
+import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection";
+import { Connection, PublicKey } from "@solana/web3.js";
+
+const CLUSTER = "pythnet";
+
+export const connection = new Connection(getPythClusterApiUrl(CLUSTER));
+export const programKey = getPythProgramKeyForCluster(CLUSTER);
+export const client = new PythHttpClient(connection, programKey);
+export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => {
+  const pythConn = new PythConnection(
+    connection,
+    programKey,
+    "confirmed",
+    feeds,
+  );
+  pythConn.onPriceChange(cb);
+  return pythConn;
+};

+ 18 - 0
apps/insights/src/zod-utils.ts

@@ -0,0 +1,18 @@
+import { type ZodSchema, type ZodTypeDef, z } from "zod";
+
+export const singletonArray = <Output, Def extends ZodTypeDef, Input>(
+  schema: ZodSchema<Output, Def, Input>,
+) =>
+  z
+    .array(schema)
+    .length(1)
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    .transform((value) => value[0]!);
+
+export const optionalSingletonArray = <Output, Def extends ZodTypeDef, Input>(
+  schema: ZodSchema<Output, Def, Input>,
+) =>
+  z
+    .array(schema)
+    .max(1)
+    .transform((value) => value[0]);

+ 1 - 0
apps/insights/tailwind.config.ts

@@ -5,6 +5,7 @@ import type { Config } from "tailwindcss";
 const tailwindConfig = {
   content: [tailwindGlob, "src/components/**/*.{ts,tsx}"],
   presets: [componentLibraryConfig],
+  darkMode: "selector",
 } satisfies Config;
 
 export default tailwindConfig;

+ 14 - 2
apps/insights/turbo.json

@@ -3,11 +3,23 @@
   "extends": ["//"],
   "tasks": {
     "build": {
-      "dependsOn": ["^build"],
+      "dependsOn": ["pull:env", "^build"],
       "outputs": [".next/**", "!.next/cache/**"],
-      "env": ["GOOGLE_ANALYTICS_ID", "AMPLITUDE_API_KEY"]
+      "env": [
+        "VERCEL_ENV",
+        "GOOGLE_ANALYTICS_ID",
+        "AMPLITUDE_API_KEY",
+        "CLICKHOUSE_URL",
+        "CLICKHOUSE_USERNAME",
+        "CLICKHOUSE_PASSWORD"
+      ]
+    },
+    "pull:env": {
+      "outputs": [".env.local"],
+      "cache": false
     },
     "start:dev": {
+      "dependsOn": ["pull:env"],
       "persistent": true,
       "cache": false
     },

+ 3 - 3
flake.lock

@@ -72,11 +72,11 @@
     },
     "nixpkgs_2": {
       "locked": {
-        "lastModified": 1729707871,
-        "narHash": "sha256-AesCJV2teiYTIqNd8xtrYOtkECxxbHT8MkY51zLCvlg=",
+        "lastModified": 1730766284,
+        "narHash": "sha256-tcnXzz0R44syorl18AeAU/JoUpfz3XfCQNIu/qzM9p4=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "95ee0b6830b0bed1517dd188b10e30044bae1ca9",
+        "rev": "f218f8ffb4b3ec17809a657cc22ce58ef28de8f1",
         "type": "github"
       },
       "original": {

+ 2 - 2
governance/xc_admin/packages/xc_admin_frontend/package.json

@@ -8,8 +8,8 @@
   "scripts": {
     "build": "next build",
     "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_TCjesnm3pxM7Ay8oxlTH4xLkkmP9 vercel env pull",
-    "start:dev": "next dev --port 3003",
-    "start:prod": "next start --port 3003",
+    "start:dev": "next dev --port 3004",
+    "start:prod": "next start --port 3004",
     "test:lint": "next lint"
   },
   "dependencies": {

+ 3 - 3
package.json

@@ -1,10 +1,10 @@
 {
   "name": "@pythnetwork/pyth-crosschain",
   "private": true,
-  "packageManager": "pnpm@9.12.1",
+  "packageManager": "pnpm@9.12.3",
   "engines": {
-    "node": "^20.17.0",
-    "pnpm": "^9.12.1"
+    "node": "^20.18.0",
+    "pnpm": "^9.12.3"
   },
   "scripts": {
     "example:hermes-client": "pnpm i && turbo example --filter @pythnetwork/hermes-client --ui stream",

+ 4 - 0
packages/component-library/package.json

@@ -3,6 +3,9 @@
   "version": "0.0.0",
   "private": true,
   "type": "module",
+  "engines": {
+    "node": "20"
+  },
   "exports": {
     ".": "./src/index.ts",
     "./tailwind-config": "./tailwind.config.ts",
@@ -21,6 +24,7 @@
     "react": "catalog:"
   },
   "dependencies": {
+    "@react-hookz/web": "catalog:",
     "clsx": "catalog:",
     "react-aria": "catalog:",
     "react-aria-components": "catalog:"

+ 23 - 21
packages/component-library/src/Button/index.tsx

@@ -1,11 +1,12 @@
 import clsx from "clsx";
-import type { ComponentType, ReactNode, SVGProps } from "react";
+import type { ComponentType, ReactNode } from "react";
 import {
   type ButtonProps as BaseButtonProps,
   type LinkProps as BaseLinkProps,
 } from "react-aria-components";
 
-import { Button as BaseButton, Link } from "./react-aria-buttons.js";
+import { UnstyledButton } from "../UnstyledButton/index.js";
+import { UnstyledLink } from "../UnstyledLink/index.js";
 
 export const VARIANTS = [
   "primary",
@@ -21,8 +22,9 @@ export const SIZES = ["xs", "sm", "md", "lg"] as const;
 type OwnProps = {
   variant?: (typeof VARIANTS)[number] | undefined;
   size?: (typeof SIZES)[number] | undefined;
-  rounded?: boolean;
-  children?: string | undefined;
+  rounded?: boolean | undefined;
+  hideText?: boolean | undefined;
+  children: string;
   beforeIcon?: Icon | undefined;
   afterIcon?: Icon | undefined;
 };
@@ -31,7 +33,7 @@ export type ButtonProps = Omit<BaseButtonProps, keyof OwnProps> & OwnProps;
 
 export const Button = ({ className, ...props }: ButtonProps) => (
   <ButtonImpl
-    component={BaseButton}
+    component={UnstyledButton}
     className={clsx(
       // Pending
       "data-[pending]:data-[variant]:cursor-wait data-[pending]:data-[variant]:border-transparent data-[pending]:data-[variant]:bg-stone-200 data-[pending]:data-[variant]:text-stone-400 data-[pending]:data-[variant]:data-[focus-visible]:outline-stone-300 dark:data-[pending]:data-[variant]:bg-steel-600 dark:data-[pending]:data-[variant]:text-steel-400 dark:data-[pending]:data-[variant]:outline-steel-500",
@@ -45,7 +47,7 @@ export const Button = ({ className, ...props }: ButtonProps) => (
 export type ButtonLinkProps = Omit<BaseLinkProps, keyof OwnProps> & OwnProps;
 
 export const ButtonLink = (props: ButtonLinkProps) => (
-  <ButtonImpl component={Link} {...props} />
+  <ButtonImpl component={UnstyledLink} {...props} />
 );
 
 type ButtonImplProps = OwnProps & {
@@ -65,6 +67,7 @@ const ButtonImpl = ({
   children,
   beforeIcon,
   afterIcon,
+  hideText = false,
   ...inputProps
 }: ButtonImplProps) => (
   <Component
@@ -72,14 +75,13 @@ const ButtonImpl = ({
     data-variant={variant}
     data-size={size}
     data-rounded={rounded ? "" : undefined}
+    data-text-hidden={hideText ? "" : undefined}
     className={clsx(baseClasses, className)}
   >
     {beforeIcon !== undefined && <Icon icon={beforeIcon} />}
-    {children !== undefined && children !== "" && (
-      <span className="group-data-[size=lg]/button:px-3 group-data-[size=md]/button:px-2 group-data-[size=sm]/button:px-2 group-data-[size=xs]/button:px-1">
-        {children}
-      </span>
-    )}
+    <span className="group-data-[text-hidden]/button:sr-only group-data-[size=lg]/button:px-3 group-data-[size=md]/button:px-2 group-data-[size=sm]/button:px-2 group-data-[size=xs]/button:px-1 group-data-[size=lg]/button:leading-[3.5rem] group-data-[size=md]/button:leading-[3rem] group-data-[size=sm]/button:leading-9 group-data-[size=xs]/button:leading-6">
+      {children}
+    </span>
     {afterIcon !== undefined && <Icon icon={afterIcon} />}
   </Component>
 );
@@ -91,19 +93,19 @@ const Icon = ({ icon: IconComponent }: { icon: Icon }) => (
 );
 
 const baseClasses = clsx(
-  "group/button inline-block cursor-pointer whitespace-nowrap border border-transparent font-medium outline-none outline-0 transition-colors duration-100 data-[size]:data-[rounded]:rounded-full data-[focus-visible]:outline-2",
+  "group/button inline-block cursor-pointer whitespace-nowrap font-medium outline-none outline-0 transition-colors duration-100 data-[size]:data-[rounded]:rounded-full data-[focus-visible]:outline-2",
 
   // xs
-  "data-[size=xs]:h-6 data-[size=xs]:rounded-md data-[size=xs]:px-1.5 data-[size=xs]:text-[0.6875rem] data-[size=xs]:leading-6",
+  "data-[size=xs]:h-6 data-[size=xs]:rounded-md data-[size=xs]:px-button-padding-xs data-[size=xs]:text-[0.6875rem]",
 
   // sm
-  "data-[size=sm]:h-9 data-[size=sm]:rounded-lg data-[size=sm]:px-2 data-[size=sm]:text-sm data-[size=sm]:leading-9",
+  "data-[size=sm]:h-9 data-[size=sm]:rounded-lg data-[size=sm]:px-button-padding-sm data-[size=sm]:text-sm",
 
   // md (default)
-  "data-[size=md]:h-12 data-[size=md]:rounded-xl data-[size=md]:px-3 data-[size=md]:text-base data-[size=md]:leading-[3rem]",
+  "data-[size=md]:h-12 data-[size=md]:rounded-xl data-[size=md]:px-3 data-[size=md]:text-base",
 
   // lg
-  "data-[size=lg]:h-14 data-[size=lg]:rounded-2xl data-[size=lg]:px-4 data-[size=lg]:text-xl data-[size=lg]:leading-[3.5rem]",
+  "data-[size=lg]:h-14 data-[size=lg]:rounded-2xl data-[size=lg]:px-4 data-[size=lg]:text-xl",
 
   // Primary (default)
   "data-[variant=primary]:bg-violet-700 data-[variant=primary]:data-[hovered]:bg-violet-800 data-[variant=primary]:data-[pressed]:bg-violet-900 data-[variant=primary]:text-white data-[variant=primary]:outline-violet-700",
@@ -124,16 +126,16 @@ const baseClasses = clsx(
   "dark:data-[variant=solid]:bg-steel-50 dark:data-[variant=solid]:data-[hovered]:bg-steel-200 dark:data-[variant=solid]:data-[pressed]:bg-steel-50 dark:data-[variant=solid]:text-steel-900 dark:data-[variant=solid]:outline-steel-300",
 
   // Outline
-  "data-[variant=outline]:border-stone-300 data-[variant=outline]:bg-transparent data-[variant=outline]:data-[hovered]:bg-black/5 data-[variant=outline]:data-[pressed]:bg-black/10 data-[variant=outline]:text-stone-900 data-[variant=outline]:outline-stone-400",
+  "data-[variant=outline]:border data-[variant=outline]:border-stone-300 data-[variant=outline]:bg-transparent data-[variant=outline]:data-[hovered]:bg-black/5 data-[variant=outline]:data-[pressed]:bg-black/10 data-[variant=outline]:text-stone-900 data-[variant=outline]:outline-steel-600",
 
   // Dark Mode Outline
-  "dark:data-[variant=outline]:border-steel-600 dark:data-[variant=outline]:bg-transparent dark:data-[variant=outline]:data-[hovered]:bg-white/5 dark:data-[variant=outline]:data-[pressed]:bg-white/10 dark:data-[variant=outline]:text-steel-50 dark:data-[variant=outline]:outline-steel-500",
+  "dark:data-[variant=outline]:border-steel-600 dark:data-[variant=outline]:bg-transparent dark:data-[variant=outline]:data-[hovered]:bg-white/5 dark:data-[variant=outline]:data-[pressed]:bg-white/10 dark:data-[variant=outline]:text-steel-50 dark:data-[variant=outline]:outline-steel-300",
 
   // Ghost
-  "data-[variant=ghost]:bg-transparent data-[variant=ghost]:data-[hovered]:bg-black/5 data-[variant=ghost]:data-[pressed]:bg-black/10 data-[variant=ghost]:text-stone-900 data-[variant=ghost]:outline-stone-400",
+  "data-[variant=ghost]:bg-transparent data-[variant=ghost]:data-[hovered]:bg-black/5 data-[variant=ghost]:data-[pressed]:bg-black/10 data-[variant=ghost]:text-stone-900 data-[variant=ghost]:outline-steel-600",
 
   // Dark Mode Ghost
-  "dark:data-[variant=ghost]:bg-transparent dark:data-[variant=ghost]:data-[hovered]:bg-white/5 dark:data-[variant=ghost]:data-[pressed]:bg-white/10 dark:data-[variant=ghost]:text-steel-50 dark:data-[variant=ghost]:outline-steel-500",
+  "dark:data-[variant=ghost]:bg-transparent dark:data-[variant=ghost]:data-[hovered]:bg-white/5 dark:data-[variant=ghost]:data-[pressed]:bg-white/10 dark:data-[variant=ghost]:text-steel-50 dark:data-[variant=ghost]:outline-steel-300",
 
   // Success
   "data-[variant=success]:bg-emerald-500 data-[variant=success]:data-[hovered]:bg-emerald-600 data-[variant=success]:data-[pressed]:bg-emerald-700 data-[variant=success]:text-violet-50 data-[variant=success]:outline-emerald-500",
@@ -151,4 +153,4 @@ const baseClasses = clsx(
   "data-[disabled]:data-[variant]:cursor-not-allowed data-[disabled]:data-[variant]:border-transparent data-[disabled]:data-[variant]:bg-stone-200 data-[disabled]:data-[variant]:text-stone-400 dark:data-[disabled]:data-[variant]:bg-steel-600 dark:data-[disabled]:data-[variant]:text-steel-400",
 );
 
-type Icon = ComponentType<SVGProps<SVGSVGElement>>;
+type Icon = ComponentType<{ className: string }>;

+ 37 - 0
packages/component-library/src/Card/index.tsx

@@ -0,0 +1,37 @@
+import clsx from "clsx";
+import type { ComponentProps, ReactNode } from "react";
+
+import { UnstyledToolbar } from "../UnstyledToolbar/index.js";
+
+type Props = ComponentProps<"div"> & {
+  header: ReactNode | ReactNode[];
+  children: ReactNode | ReactNode[];
+  full?: boolean;
+} & (
+    | { toolbar?: undefined }
+    | { toolbar: ReactNode | ReactNode[]; toolbarLabel: string }
+  );
+
+export const Card = ({ header, children, full, ...props }: Props) => (
+  <div className="rounded-2xl border border-stone-300 dark:border-steel-600">
+    <div className="flex w-full flex-row items-center justify-between overflow-hidden rounded-t-2xl bg-beige-100 p-4 dark:bg-steel-900">
+      <h2 className="text-lg font-medium">{header}</h2>
+      {props.toolbar && (
+        <UnstyledToolbar
+          aria-label={props.toolbarLabel}
+          className="flex flex-row gap-2"
+        >
+          {props.toolbar}
+        </UnstyledToolbar>
+      )}
+    </div>
+    <div
+      className={clsx({
+        "overflow-hidden rounded-b-2xl bg-beige-100 px-4 pb-4 dark:bg-steel-900":
+          !full,
+      })}
+    >
+      {children}
+    </div>
+  </div>
+);

+ 4 - 2
packages/component-library/src/Link/index.tsx

@@ -1,8 +1,10 @@
 import clsx from "clsx";
-import { Link as BaseLink, type LinkProps } from "react-aria-components";
+import type { LinkProps } from "react-aria-components";
+
+import { UnstyledLink } from "../UnstyledLink/index.js";
 
 export const Link = ({ className, ...props }: LinkProps) => (
-  <BaseLink
+  <UnstyledLink
     className={clsx(
       "underline outline-0 outline-offset-4 outline-inherit data-[disabled]:cursor-not-allowed data-[disabled]:text-stone-400 data-[disabled]:no-underline data-[focus-visible]:outline-2 hover:no-underline dark:data-[disabled]:text-steel-400",
       className,

+ 103 - 0
packages/component-library/src/Select/index.tsx

@@ -0,0 +1,103 @@
+import clsx from "clsx";
+import { type ComponentProps, type ReactNode, useCallback } from "react";
+import {
+  type PopoverProps,
+  Label,
+  Select as BaseSelect,
+  Popover,
+  ListBox,
+  ListBoxItem,
+} from "react-aria-components";
+
+import { Button } from "../Button/index.js";
+
+type Props<T> = Omit<
+  ComponentProps<typeof BaseSelect>,
+  "selectedKey" | "onSelectionChange"
+> & {
+  selectedKey: T;
+  onSelectionChange: (newValue: T) => void;
+  options: readonly T[];
+  show?: (value: T) => string;
+  variant?: ComponentProps<typeof Button>["variant"];
+  size?: ComponentProps<typeof Button>["size"];
+  rounded?: ComponentProps<typeof Button>["rounded"];
+  hideText?: ComponentProps<typeof Button>["hideText"];
+  beforeIcon?: ComponentProps<typeof Button>["beforeIcon"];
+  placement?: PopoverProps["placement"] | undefined;
+  label: ReactNode;
+  hideLabel?: boolean | undefined;
+};
+
+export const Select = <T extends string | number>({
+  options,
+  show,
+  selectedKey,
+  onSelectionChange,
+  variant,
+  size,
+  rounded,
+  hideText,
+  beforeIcon,
+  label,
+  hideLabel,
+  placement,
+  ...props
+}: Props<T>) => {
+  const handleSelectionChange = useCallback(
+    (newKey: T) => {
+      if (newKey !== selectedKey) {
+        onSelectionChange(newKey);
+      }
+    },
+    [onSelectionChange, selectedKey],
+  );
+  return (
+    <BaseSelect
+      selectedKey={selectedKey}
+      // @ts-expect-error react-aria coerces everything to Key for some reason...
+      onSelectionChange={handleSelectionChange}
+      {...props}
+    >
+      <Label className={clsx({ "sr-only": hideLabel })}>{label}</Label>
+      <Button
+        afterIcon={DropdownCaretDown}
+        variant={variant}
+        size={size}
+        rounded={rounded}
+        hideText={hideText}
+        beforeIcon={beforeIcon}
+      >
+        {show?.(selectedKey) ?? selectedKey.toString()}
+      </Button>
+      <Popover
+        {...(placement && { placement })}
+        className="min-w-[--trigger-width] bg-white data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:fade-in data-[exiting]:fade-out dark:bg-steel-950"
+      >
+        <ListBox
+          className="bg-pythpurple-100 text-pythpurple-950 flex origin-top-right flex-col rounded border border-neutral-400 py-1 text-sm shadow shadow-neutral-400 outline-none"
+          items={options.map((id) => ({ id }))}
+        >
+          {({ id }) => (
+            <ListBoxItem className="cursor-pointer whitespace-nowrap px-2 py-1 text-xs outline-none data-[disabled]:cursor-default data-[selected]:cursor-default data-[focused]:bg-black/10 data-[selected]:data-[focused]:bg-transparent data-[selected]:font-bold dark:data-[focused]:bg-white/10">
+              {show?.(id) ?? id}
+            </ListBoxItem>
+          )}
+        </ListBox>
+      </Popover>
+    </BaseSelect>
+  );
+};
+
+const DropdownCaretDown = (
+  props: Omit<ComponentProps<"svg">, "xmlns" | "viewBox" | "fill">,
+) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 20 20"
+    fill="currentColor"
+    {...props}
+  >
+    <path d="m13.346 9.284-3.125 3.125a.311.311 0 0 1-.442 0L6.654 9.284a.312.312 0 0 1 .221-.534h6.25a.312.312 0 0 1 .221.534Z" />
+  </svg>
+);

+ 12 - 0
packages/component-library/src/Skeleton/index.tsx

@@ -0,0 +1,12 @@
+import clsx from "clsx";
+import type { ComponentProps } from "react";
+
+type Props = Omit<ComponentProps<"span">, "children">;
+
+export const Skeleton = ({ className, ...props }: Props) => (
+  <span className="animate-pulse rounded-lg bg-stone-200 dark:bg-steel-800">
+    <span className={clsx("inline-block", className)} {...props}>
+      <span className="sr-only">Loading</span>
+    </span>
+  </span>
+);

+ 134 - 0
packages/component-library/src/Table/index.tsx

@@ -0,0 +1,134 @@
+"use client";
+
+import { useDebouncedEffect } from "@react-hookz/web";
+import clsx from "clsx";
+import { type ReactNode, useState } from "react";
+import type {
+  RowProps,
+  ColumnProps,
+  TableBodyProps,
+} from "react-aria-components";
+
+import {
+  UnstyledCell,
+  UnstyledColumn,
+  UnstyledRow,
+  UnstyledTable,
+  UnstyledTableBody,
+  UnstyledTableHeader,
+} from "../UnstyledTable/index.js";
+
+type TableProps<T extends string> = {
+  label: string;
+  columns: ColumnConfig<T>[];
+  rows: RowConfig<T>[];
+  isLoading?: boolean | undefined;
+  renderEmptyState?: TableBodyProps<T>["renderEmptyState"];
+};
+
+type ColumnConfig<T extends string> = Omit<ColumnProps, "children"> & {
+  name: ReactNode;
+  id: T;
+  fill?: boolean | undefined;
+  alignment?: Alignment;
+};
+
+type Alignment = "left" | "center" | "right" | undefined;
+
+type RowConfig<T extends string> = Omit<
+  RowProps<T>,
+  "columns" | "children" | "value"
+> & {
+  id: string | number;
+  data: Record<T, ReactNode>;
+};
+
+export const Table = <T extends string>({
+  label,
+  rows,
+  columns,
+  isLoading,
+  renderEmptyState,
+}: TableProps<T>) => {
+  const [debouncedRows, setDebouncedRows] = useState(rows);
+
+  useDebouncedEffect(
+    () => {
+      setDebouncedRows(rows);
+    },
+    [rows],
+    500,
+  );
+
+  return (
+    <div className="relative">
+      {isLoading && (
+        <div
+          className={clsx(
+            "absolute left-0 right-0 top-8 z-10 h-0.5 overflow-hidden opacity-0 transition",
+            {
+              "opacity-100": true,
+            },
+          )}
+        >
+          <div className="size-full origin-left animate-progress bg-violet-500" />
+        </div>
+      )}
+      <UnstyledTable aria-label={label}>
+        <UnstyledTableHeader
+          columns={columns}
+          className="border-b border-stone-300 bg-beige-100 pb-4 text-xs text-stone-600 dark:border-steel-600 dark:bg-steel-900 dark:text-steel-400"
+        >
+          {(column: ColumnConfig<T>) => (
+            <UnstyledColumn
+              className={clsx(
+                "whitespace-nowrap pb-4 font-medium",
+                cellClassName(columns, column),
+              )}
+              {...column}
+            >
+              {column.name}
+            </UnstyledColumn>
+          )}
+        </UnstyledTableHeader>
+        <UnstyledTableBody
+          items={debouncedRows}
+          className="text-sm"
+          {...(renderEmptyState !== undefined && { renderEmptyState })}
+        >
+          {({ className: rowClassName, data, ...row }: RowConfig<T>) => (
+            <UnstyledRow
+              className={clsx(
+                "h-16 transition-colors duration-100 data-[hovered]:bg-black/5 data-[pressed]:bg-black/10 dark:data-[hovered]:bg-white/5 dark:data-[pressed]:bg-white/10",
+                { "cursor-pointer": "href" in row },
+                rowClassName,
+              )}
+              columns={columns}
+              {...row}
+            >
+              {(column: ColumnConfig<T>) => (
+                <UnstyledCell className={cellClassName(columns, column)}>
+                  {data[column.id]}
+                </UnstyledCell>
+              )}
+            </UnstyledRow>
+          )}
+        </UnstyledTableBody>
+      </UnstyledTable>
+    </div>
+  );
+};
+
+const cellClassName = <T extends string>(
+  columns: ColumnConfig<T>[],
+  column: ColumnConfig<T>,
+) =>
+  clsx("px-2", {
+    "pl-4": column === columns[0],
+    "pr-4": column === columns.at(-1),
+    "text-left": column.alignment === "left",
+    "text-right": column.alignment === "right",
+    "text-center":
+      column.alignment === "center" || column.alignment === undefined,
+    "w-full": column.fill,
+  });

+ 9 - 0
packages/component-library/src/UnstyledButton/index.tsx

@@ -0,0 +1,9 @@
+/**
+ * The react-aria components aren't marked as "use client" so it's a bit
+ * obnoxious to use them; this file just adds a client boundary and re-exports
+ * the react-aria components to avoid that problem.
+ */
+
+"use client";
+
+export { Button as UnstyledButton } from "react-aria-components";

+ 1 - 1
packages/component-library/src/Button/react-aria-buttons.tsx → packages/component-library/src/UnstyledLink/index.tsx

@@ -6,4 +6,4 @@
 
 "use client";
 
-export { Button, Link } from "react-aria-components";
+export { Link as UnstyledLink } from "react-aria-components";

+ 16 - 0
packages/component-library/src/UnstyledTable/index.tsx

@@ -0,0 +1,16 @@
+/**
+ * The react-aria components aren't marked as "use client" so it's a bit
+ * obnoxious to use them; this file just adds a client boundary and re-exports
+ * the react-aria components to avoid that problem.
+ */
+
+"use client";
+
+export {
+  Cell as UnstyledCell,
+  Column as UnstyledColumn,
+  Row as UnstyledRow,
+  Table as UnstyledTable,
+  TableBody as UnstyledTableBody,
+  TableHeader as UnstyledTableHeader,
+} from "react-aria-components";

+ 9 - 0
packages/component-library/src/UnstyledToolbar/index.tsx

@@ -0,0 +1,9 @@
+/**
+ * The react-aria components aren't marked as "use client" so it's a bit
+ * obnoxious to use them; this file just adds a client boundary and re-exports
+ * the react-aria components to avoid that problem.
+ */
+
+"use client";
+
+export { Toolbar as UnstyledToolbar } from "react-aria-components";

+ 15 - 1
packages/component-library/tailwind.config.ts

@@ -8,7 +8,7 @@ import { tailwindGlob } from "./src/index.js";
 
 const tailwindConfig = {
   content: [tailwindGlob, ".storybook/**/*.tsx"],
-  darkMode: "class",
+  darkMode: "selector",
   theme: {
     extend: {
       fontFamily: {
@@ -43,6 +43,20 @@ const tailwindConfig = {
           950: "#050217",
         },
       },
+      spacing: {
+        "button-padding-xs": "0.25rem",
+        "button-padding-sm": "0.5rem",
+      },
+      animation: {
+        progress: "progress 1s infinite linear",
+      },
+      keyframes: {
+        progress: {
+          "0%": { transform: " translateX(0) scaleX(0)" },
+          "40%": { transform: "translateX(0) scaleX(0.4)" },
+          "100%": { transform: "translateX(100%) scaleX(0.5)" },
+        },
+      },
     },
   },
   plugins: [

+ 2 - 0
packages/known-publishers/.prettierignore

@@ -0,0 +1,2 @@
+coverage/
+node_modules/

+ 1 - 0
packages/known-publishers/README.md

@@ -0,0 +1 @@
+# @pythnetwork/known-publishers

+ 1 - 0
packages/known-publishers/eslint.config.js

@@ -0,0 +1 @@
+export { react as default } from "@cprussin/eslint-config";

+ 1 - 0
packages/known-publishers/jest.config.js

@@ -0,0 +1 @@
+export { base as default } from "@cprussin/jest-config";

+ 32 - 0
packages/known-publishers/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "@pythnetwork/known-publishers",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "exports": {
+    ".": "./src/index.tsx"
+  },
+  "scripts": {
+    "fix:format": "prettier --write .",
+    "fix:lint": "eslint --fix .",
+    "test:format": "jest --selectProjects format",
+    "test:lint": "jest --selectProjects lint",
+    "test:types": "tsc"
+  },
+  "peerDependencies": {
+    "react": "catalog:"
+  },
+  "devDependencies": {
+    "@cprussin/eslint-config": "catalog:",
+    "@cprussin/jest-config": "catalog:",
+    "@cprussin/prettier-config": "catalog:",
+    "@cprussin/tsconfig": "catalog:",
+    "@types/jest": "catalog:",
+    "@types/react": "catalog:",
+    "eslint": "catalog:",
+    "jest": "catalog:",
+    "prettier": "catalog:",
+    "react": "catalog:",
+    "typescript": "catalog:"
+  }
+}

+ 1 - 0
packages/known-publishers/prettier.config.js

@@ -0,0 +1 @@
+export { base as default } from "@cprussin/prettier-config";

+ 6 - 0
packages/known-publishers/src/icons/blocksize.svg

@@ -0,0 +1,6 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 277.6 277.6">
+  <path d="M0 .01h277.6v22.72H0zm0 254.86h277.6v22.73H0z"/>
+  <path d="M0 0h22.72v277.6H0zm254.88 0h22.72v277.6h-22.72z"/>
+  <path d="M131.87 127.44h138.8v22.72h-138.8z"/>
+  <path d="M127.61 0h22.72v277.6h-22.72z"/>
+</svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 27 - 0
packages/known-publishers/src/icons/color/finazon.svg


+ 12 - 0
packages/known-publishers/src/icons/color/sentio.svg

@@ -0,0 +1,12 @@
+<svg viewBox="0 0 98 100" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_23414_5573)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M95.3453 52.8565C94.4804 52.2511 93.529 51.6457 92.5777 51.0402C91.0209 50.0889 89.4642 49.0511 87.8209 48.0997C84.1885 45.9375 80.3831 43.8619 76.4047 41.8727L73.2047 40.3159H73.1182C70.0912 38.8457 66.9777 37.5484 63.7777 36.2511C45.2696 28.7267 26.5885 24.7484 9.72363 24.1429C14.9993 14.8889 22.0047 6.15376 27.4534 3.81862C39.475 -1.19759 70.1777 4.94295 76.1452 13.2457C77.702 15.4078 80.2966 19.7321 83.1507 24.9213L84.6209 27.6024L85.1398 28.5538C85.2263 28.7267 85.3128 28.8997 85.3993 29.0727L85.4858 29.1592C86.0912 30.37 86.6966 31.4943 87.302 32.7051C90.675 39.624 93.875 46.9754 95.4317 52.6835L95.3453 52.8565Z" fill="#36F7F7"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M96.2962 58.8228C96.2962 58.9957 96.2962 59.1687 96.2097 59.3417C94.7395 71.5363 70.0043 92.639 62.3935 96.5309C55.3881 100.077 33.5935 96.7903 20.7935 92.293H20.707C33.507 85.2011 46.307 75.5147 58.1557 63.5795C63.7773 57.9579 68.7935 52.0768 73.3773 46.1092L74.6746 44.8984C82.026 48.5309 88.8584 52.5957 95.0854 56.9201C95.2584 57.0065 95.3449 57.093 95.5178 57.266C96.0368 57.612 96.2962 58.1309 96.2962 58.8228Z" fill="#BD24B5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M38.2635 76.5529C31.6905 81.5691 25.031 85.9799 18.3716 89.5259C18.2851 89.6124 18.2851 89.6124 18.1986 89.6124C17.9391 89.7854 17.5932 89.7853 17.2473 89.7853C16.6418 89.7853 16.1229 89.5259 15.6905 89.1799C15.5175 89.007 15.3446 88.7475 15.1716 88.5745C6.34996 78.6286 1.67969 50.0016 1.67969 45.1583C1.67969 43.1691 2.28509 40.488 3.32293 37.5475C4.27428 34.8664 5.57158 31.9259 7.12834 28.8989C7.21482 28.8124 7.21482 28.7259 7.30131 28.6394C7.64725 28.034 8.25266 27.688 8.94455 27.6016C9.11752 27.6016 9.20401 27.6016 9.37698 27.6016C14.5662 27.7745 19.9283 28.2935 25.4635 29.1583L38.2635 76.5529Z" fill="#0756D5"/>
+</g>
+<defs>
+<clipPath id="clip0_23414_5573">
+<rect width="94.6162" height="96" fill="white" transform="translate(1.67969 2)"/>
+</clipPath>
+</defs>
+</svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 0
packages/known-publishers/src/icons/elfomo.svg


+ 5 - 0
packages/known-publishers/src/icons/finazon.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 121 120">
+  <path d="m91.453 13.6 11.45 6.484v12.963l-67.75 37.94-11.45-6.484V51.54l67.75-37.94Z"/>
+  <path d="m35.154 75.728-11.45-6.484v12.962l11.45 6.484 45.855-25.93V49.797L35.155 75.728Z"/>
+  <path d="m35.154 93.431-11.45-6.484v12.969l11.45 6.484 11.456-6.484V86.947l-11.456 6.484Z"/>
+</svg>

+ 6 - 0
packages/known-publishers/src/icons/monochrome/blocksize.svg

@@ -0,0 +1,6 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 277.6 277.6">
+  <path d="M0 .01h277.6v22.72H0zm0 254.86h277.6v22.73H0z"/>
+  <path d="M0 0h22.72v277.6H0zm254.88 0h22.72v277.6h-22.72z"/>
+  <path d="M131.87 127.44h138.8v22.72h-138.8z"/>
+  <path d="M127.61 0h22.72v277.6h-22.72z"/>
+</svg>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 0
packages/known-publishers/src/icons/monochrome/elfomo.svg


+ 5 - 0
packages/known-publishers/src/icons/monochrome/finazon.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 121 120">
+  <path d="m91.453 13.6 11.45 6.484v12.963l-67.75 37.94-11.45-6.484V51.54l67.75-37.94Z"/>
+  <path d="m35.154 75.728-11.45-6.484v12.962l11.45 6.484 45.855-25.93V49.797L35.155 75.728Z"/>
+  <path d="m35.154 93.431-11.45-6.484v12.969l11.45 6.484 11.456-6.484V86.947l-11.456 6.484Z"/>
+</svg>

+ 4 - 0
packages/known-publishers/src/icons/monochrome/sentio.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 98 100">
+  <path fill-rule="evenodd" d="M95.345 52.856c-.865-.605-1.816-1.21-2.767-1.816-1.557-.951-3.114-1.989-4.757-2.94a177.536 177.536 0 0 0-11.416-6.227l-3.2-1.557h-.087c-3.027-1.47-6.14-2.768-9.34-4.065-18.508-7.524-37.19-11.503-54.054-12.108 5.275-9.254 12.28-17.99 17.73-20.324 12.021-5.017 42.724 1.124 48.691 9.427 1.557 2.162 4.152 6.486 7.006 11.675l1.47 2.681.519.952.26.519.086.086c.605 1.211 1.21 2.335 1.816 3.546 3.373 6.919 6.573 14.27 8.13 19.979z"/>
+  <path fill-rule="evenodd" d="M96.296 58.823c0 .173 0 .346-.086.519-1.47 12.194-26.206 33.297-33.816 37.189-7.006 3.546-28.8.26-41.6-4.238h-.087c12.8-7.092 25.6-16.778 37.449-28.713 5.621-5.622 10.638-11.503 15.221-17.47l1.298-1.212a160.458 160.458 0 0 1 20.41 12.022c.173.087.26.173.433.346.519.346.778.865.778 1.557zm-58.032 17.73C31.69 81.569 25.03 85.98 18.372 89.526c-.087.086-.087.086-.173.086-.26.173-.606.173-.952.173-.605 0-1.124-.26-1.556-.605-.174-.173-.346-.433-.52-.606C6.35 78.63 1.68 50.002 1.68 45.159c0-1.989.605-4.67 1.643-7.61.951-2.682 2.249-5.622 3.805-8.65.087-.086.087-.172.173-.259.346-.605.952-.951 1.644-1.037h.432c5.19.172 10.551.691 16.087 1.556z"/>
+</svg>

+ 4 - 0
packages/known-publishers/src/icons/sentio.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 98 100">
+  <path fill-rule="evenodd" d="M95.345 52.856c-.865-.605-1.816-1.21-2.767-1.816-1.557-.951-3.114-1.989-4.757-2.94a177.536 177.536 0 0 0-11.416-6.227l-3.2-1.557h-.087c-3.027-1.47-6.14-2.768-9.34-4.065-18.508-7.524-37.19-11.503-54.054-12.108 5.275-9.254 12.28-17.99 17.73-20.324 12.021-5.017 42.724 1.124 48.691 9.427 1.557 2.162 4.152 6.486 7.006 11.675l1.47 2.681.519.952.26.519.086.086c.605 1.211 1.21 2.335 1.816 3.546 3.373 6.919 6.573 14.27 8.13 19.979z"/>
+  <path fill-rule="evenodd" d="M96.296 58.823c0 .173 0 .346-.086.519-1.47 12.194-26.206 33.297-33.816 37.189-7.006 3.546-28.8.26-41.6-4.238h-.087c12.8-7.092 25.6-16.778 37.449-28.713 5.621-5.622 10.638-11.503 15.221-17.47l1.298-1.212a160.458 160.458 0 0 1 20.41 12.022c.173.087.26.173.433.346.519.346.778.865.778 1.557zm-58.032 17.73C31.69 81.569 25.03 85.98 18.372 89.526c-.087.086-.087.086-.173.086-.26.173-.606.173-.952.173-.605 0-1.124-.26-1.556-.605-.174-.173-.346-.433-.52-.606C6.35 78.63 1.68 50.002 1.68 45.159c0-1.989.605-4.67 1.643-7.61.951-2.682 2.249-5.622 3.805-8.65.087-.086.087-.172.173-.259.346-.605.952-.951 1.644-1.037h.432c5.19.172 10.551.691 16.087 1.556z"/>
+</svg>

+ 42 - 0
packages/known-publishers/src/index.tsx

@@ -0,0 +1,42 @@
+import finazonColor from "./icons/color/finazon.svg";
+import sentioColor from "./icons/color/sentio.svg";
+import blocksize from "./icons/monochrome/blocksize.svg";
+import elfomo from "./icons/monochrome/elfomo.svg";
+import finazonMonochrome from "./icons/monochrome/finazon.svg";
+import sentioMonochrome from "./icons/monochrome/sentio.svg";
+
+export const knownPublishers = {
+  CfVkYofcLC1iVBcYFzgdYPeiX25SVRmWvBQVHorP1A3y: {
+    name: "BLOCKSIZE",
+    icon: {
+      monochrome: blocksize,
+      color: blocksize,
+    },
+  },
+  "89ijemG1TUL2kdV2RtCrhXzY5QhyKHsWqCmP5iobvLUF": {
+    name: "Sentio",
+    icon: {
+      monochrome: sentioMonochrome,
+      color: sentioColor,
+    },
+  },
+  Fq5zaoF76WYshMEYUn1q8cB8MrG61swhaWHRUCWeP5Vo: {
+    name: "Finazon",
+    icon: {
+      monochrome: finazonMonochrome,
+      color: finazonColor,
+    },
+  },
+  "5giNPEh9PytXcnKNgufofmQPdS4jHoySgFpiu8f7QxP4": {
+    name: "Elfomo",
+    icon: {
+      monochrome: elfomo,
+      color: elfomo,
+    },
+  },
+};
+
+export const lookup = (value: string) =>
+  value in knownPublishers
+    ? knownPublishers[value as keyof typeof knownPublishers]
+    : undefined;

+ 6 - 0
packages/known-publishers/svg.d.ts

@@ -0,0 +1,6 @@
+declare module "*.svg" {
+  import type { ReactElement, SVGProps } from "react";
+
+  const content: (props: SVGProps<SVGElement>) => ReactElement;
+  export default content;
+}

+ 3 - 0
packages/known-publishers/tsconfig.json

@@ -0,0 +1,3 @@
+{
+  "extends": "@cprussin/tsconfig/react.json"
+}

+ 2 - 1
packages/next-root/package.json

@@ -25,7 +25,8 @@
     "@axe-core/react": "catalog:",
     "@next/third-parties": "catalog:",
     "@pythnetwork/app-logger": "workspace:*",
-    "bcp-47": "catalog:"
+    "bcp-47": "catalog:",
+    "next-themes": "catalog:"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 11 - 2
packages/next-root/src/index.tsx

@@ -1,6 +1,7 @@
 import { GoogleAnalytics } from "@next/third-parties/google";
 import { LoggerProvider } from "@pythnetwork/app-logger/provider";
 import dynamic from "next/dynamic";
+import { ThemeProvider } from "next-themes";
 import type { ComponentProps, ReactNode } from "react";
 
 import { Amplitude } from "./amplitude";
@@ -19,6 +20,7 @@ type Props = Omit<ComponentProps<typeof HtmlWithLang>, "children"> & {
   amplitudeApiKey?: string | undefined;
   googleAnalyticsId?: string | undefined;
   providers?: ComponentProps<typeof ComposeProviders>["providers"] | undefined;
+  bodyClassName?: string | undefined;
 };
 
 export const Root = ({
@@ -27,6 +29,7 @@ export const Root = ({
   amplitudeApiKey,
   googleAnalyticsId,
   enableAccessibilityReporting,
+  bodyClassName,
   ...props
 }: Props) => (
   <ComposeProviders
@@ -37,8 +40,14 @@ export const Root = ({
       ...(providers ?? []),
     ]}
   >
-    <HtmlWithLang {...props}>
-      {children}
+    <HtmlWithLang
+      // See https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
+      suppressHydrationWarning
+      {...props}
+    >
+      <body className={bodyClassName}>
+        <ThemeProvider attribute="class">{children}</ThemeProvider>
+      </body>
       {googleAnalyticsId && <GoogleAnalytics gaId={googleAnalyticsId} />}
       {amplitudeApiKey && <Amplitude apiKey={amplitudeApiKey} />}
       {enableAccessibilityReporting && <ReportAccessibility />}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 252 - 170
pnpm-lock.yaml


+ 9 - 1
pnpm-workspace.yaml

@@ -34,12 +34,16 @@ catalog:
   "@amplitude/analytics-browser": 2.11.8
   "@amplitude/plugin-autocapture-browser": 1.0.0
   "@axe-core/react": 4.9.1
+  "@clickhouse/client": 1.8.0
   "@cprussin/eslint-config": 3.0.0
   "@cprussin/jest-config": 1.4.1
   "@cprussin/prettier-config": 2.1.1
   "@cprussin/tsconfig": 3.0.1
   "@next/third-parties": 15.0.2
   "@phosphor-icons/react": 2.1.7
+  "@pythnetwork/client": 2.22.0
+  "@react-hookz/web": 24.0.4
+  "@solana/web3.js": 1.95.4
   "@storybook/addon-essentials": 8.3.5
   "@storybook/addon-styling-webpack": 1.0.0
   "@storybook/addon-themes": 8.3.5
@@ -55,11 +59,14 @@ catalog:
   autoprefixer: 10.4.20
   bcp-47: 2.1.0
   clsx: 2.1.1
+  cryptocurrency-icons: 0.18.1
   css-loader: 7.1.2
   eslint: 9.13.0
   framer-motion: 11.11.10
   jest: 29.7.0
-  next: 15.0.2
+  next-themes: 0.3.0
+  next: 15.0.3
+  nuqs: 2.1.2
   pino: 9.5.0
   postcss-loader: 8.1.1
   postcss: 8.4.47
@@ -75,3 +82,4 @@ catalog:
   tailwindcss: 3.4.14
   typescript: 5.6.3
   vercel: 37.12.1
+  zod: 3.23.8

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels