+ {t(
+ 'CKB Explorer is a blockchain explorer that provides users with a real-time view of the Nervos CKB blockchain. It allows users to search for specific transactions, blocks, and addresses, and provides detailed information on each transaction and block, including the status, timestamp, and fees.',
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('Real-time parsing of data on the chain')}
+
+ {t(
+ `Main chain information, block information, transaction information, contract information and address information all in one place to help you keep track of what's happening on the chain.`,
+ )}
+
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{t('Chain assets summary display')}
+
+ {t(
+ `Nervos DAO, Tokens, and NFT collection assets at a glance to help you understand the status of your on-chain projects right away.`,
+ )}
+
+ {isMobile ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
{t('Multidimensional analysis of data charts')}
+
+ {t(
+ `Provide multi-dimensional, multi-type data chart display, is a good helper for you to analyze data and make decisions.`,
+ )}
+
+ {isMobile ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
{t('Experience CKB Explorer Now')}
+
+ {t(`Easy to grasp information on the chain to help you start your journey to the world of CKB`)}
+
+
+
+ {isMobile ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const Emphasis: FC = ({ children }) => (
+
+ {children}
+
+
+)
+
+export const getStaticProps: GetStaticProps = async ({ locale = 'en' }) => {
+ const release = await getLatestRelease()
+ const lng = await serverSideTranslations(locale, ['common', 'home'])
+
+ const props: PageProps = {
+ locale,
+ release,
+ ...lng,
+ }
+
+ return { props }
+}
+
+export default Home
diff --git a/packages/explorer/src/pages/home/oval.svg b/packages/explorer/src/pages/home/oval.svg
new file mode 100644
index 0000000..1b390bf
--- /dev/null
+++ b/packages/explorer/src/pages/home/oval.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/explorer/src/pages/home/overview.png b/packages/explorer/src/pages/home/overview.png
new file mode 100644
index 0000000..05c0a46
Binary files /dev/null and b/packages/explorer/src/pages/home/overview.png differ
diff --git a/packages/explorer/src/pages/home/overviewMb.png b/packages/explorer/src/pages/home/overviewMb.png
new file mode 100644
index 0000000..d5958dd
Binary files /dev/null and b/packages/explorer/src/pages/home/overviewMb.png differ
diff --git a/packages/explorer/src/pages/home/summary.png b/packages/explorer/src/pages/home/summary.png
new file mode 100644
index 0000000..234bee6
Binary files /dev/null and b/packages/explorer/src/pages/home/summary.png differ
diff --git a/packages/explorer/src/pages/home/summaryMb1.png b/packages/explorer/src/pages/home/summaryMb1.png
new file mode 100644
index 0000000..8423ed6
Binary files /dev/null and b/packages/explorer/src/pages/home/summaryMb1.png differ
diff --git a/packages/explorer/src/pages/home/summaryMb2.png b/packages/explorer/src/pages/home/summaryMb2.png
new file mode 100644
index 0000000..8803f0d
Binary files /dev/null and b/packages/explorer/src/pages/home/summaryMb2.png differ
diff --git a/packages/explorer/src/pages/home/summaryMb3.png b/packages/explorer/src/pages/home/summaryMb3.png
new file mode 100644
index 0000000..e67c875
Binary files /dev/null and b/packages/explorer/src/pages/home/summaryMb3.png differ
diff --git a/packages/explorer/src/pages/home/title-shadow.svg b/packages/explorer/src/pages/home/title-shadow.svg
new file mode 100644
index 0000000..9013854
--- /dev/null
+++ b/packages/explorer/src/pages/home/title-shadow.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/explorer/src/pages/home/top-shadow.svg b/packages/explorer/src/pages/home/top-shadow.svg
new file mode 100644
index 0000000..acf43c5
--- /dev/null
+++ b/packages/explorer/src/pages/home/top-shadow.svg
@@ -0,0 +1,22 @@
+
diff --git a/packages/explorer/src/pages/index.page.tsx b/packages/explorer/src/pages/index.page.tsx
new file mode 100644
index 0000000..6d04e7d
--- /dev/null
+++ b/packages/explorer/src/pages/index.page.tsx
@@ -0,0 +1 @@
+export { default, getStaticProps } from './home/index.page'
diff --git a/packages/explorer/src/server/api/root.ts b/packages/explorer/src/server/api/root.ts
new file mode 100644
index 0000000..f808b3c
--- /dev/null
+++ b/packages/explorer/src/server/api/root.ts
@@ -0,0 +1,17 @@
+/**
+ * There is no practical use for it at the moment, it's just reserved to make it easier to create api's in the future.
+ */
+import { createTRPCRouter } from './trpc'
+import { uptimeRouter } from './routers/uptime'
+
+/**
+ * This is the primary router for your server.
+ *
+ * All routers added in /api/routers should be manually added here.
+ */
+export const appRouter = createTRPCRouter({
+ uptime: uptimeRouter,
+})
+
+// export type definition of API
+export type AppRouter = typeof appRouter
diff --git a/packages/explorer/src/server/api/routers/uptime.ts b/packages/explorer/src/server/api/routers/uptime.ts
new file mode 100644
index 0000000..8d84c74
--- /dev/null
+++ b/packages/explorer/src/server/api/routers/uptime.ts
@@ -0,0 +1,28 @@
+import { UPTIME_KEY } from '../../../utils'
+import { createTRPCRouter, publicProcedure } from '../trpc'
+
+export const uptimeRouter = createTRPCRouter({
+ // TODO: need cache?
+ aggregateState: publicProcedure.query(async () => {
+ const resp = await fetch('https://betteruptime.com/api/v2/status-pages', {
+ headers: {
+ Authorization: `Bearer ${UPTIME_KEY}`,
+ },
+ })
+ // response type by https://betterstack.com/docs/uptime/api/status-pages-api-response-params/
+ const respData = (await resp.json()) as {
+ data: {
+ attributes: {
+ aggregate_state: 'operational' | 'downtime' | 'degraded'
+ }
+ }[]
+ }
+
+ // This try-catch is just for preventing unexpected situations, and not targeting any specific problem.
+ try {
+ return respData.data[0]?.attributes.aggregate_state ?? 'unknown'
+ } catch {
+ return 'unknown'
+ }
+ }),
+})
diff --git a/packages/explorer/src/server/api/trpc.ts b/packages/explorer/src/server/api/trpc.ts
new file mode 100644
index 0000000..26808c9
--- /dev/null
+++ b/packages/explorer/src/server/api/trpc.ts
@@ -0,0 +1,82 @@
+/**
+ * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
+ * 1. You want to modify request context (see Part 1).
+ * 2. You want to create a new middleware or type of procedure (see Part 3).
+ *
+ * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
+ * need to use are documented accordingly near the end.
+ */
+
+/**
+ * 1. CONTEXT
+ *
+ * This section defines the "contexts" that are available in the backend API.
+ *
+ * These allow you to access things when processing a request, like the database, the session, etc.
+ */
+import { type CreateNextContextOptions } from '@trpc/server/adapters/next'
+
+/** Replace this with an object if you want to pass things to `createContextInner`. */
+type CreateContextOptions = Record
+
+/**
+ * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
+ * it from here.
+ *
+ * Examples of things you may need it for:
+ * - testing, so we don't have to mock Next.js' req/res
+ * - tRPC's `createSSGHelpers`, where we don't have req/res
+ *
+ * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
+ */
+const createInnerTRPCContext = (_opts: CreateContextOptions) => {
+ return {}
+}
+
+/**
+ * This is the actual context you will use in your router. It will be used to process every request
+ * that goes through your tRPC endpoint.
+ *
+ * @see https://trpc.io/docs/context
+ */
+export const createTRPCContext = (_opts: CreateNextContextOptions) => {
+ return createInnerTRPCContext({})
+}
+
+/**
+ * 2. INITIALIZATION
+ *
+ * This is where the tRPC API is initialized, connecting the context and transformer.
+ */
+import { initTRPC } from '@trpc/server'
+import superjson from 'superjson'
+
+const t = initTRPC.context().create({
+ transformer: superjson,
+ errorFormatter({ shape }) {
+ return shape
+ },
+})
+
+/**
+ * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
+ *
+ * These are the pieces you use to build your tRPC API. You should import these a lot in the
+ * "/src/server/api/routers" directory.
+ */
+
+/**
+ * This is how you create new routers and sub-routers in your tRPC API.
+ *
+ * @see https://trpc.io/docs/router
+ */
+export const createTRPCRouter = t.router
+
+/**
+ * Public (unauthenticated) procedure
+ *
+ * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
+ * guarantee that a user querying is authorized, but you can still access user session data if they
+ * are logged in.
+ */
+export const publicProcedure = t.procedure
diff --git a/packages/explorer/src/services/AppSettings/index.ts b/packages/explorer/src/services/AppSettings/index.ts
new file mode 100644
index 0000000..ff12308
--- /dev/null
+++ b/packages/explorer/src/services/AppSettings/index.ts
@@ -0,0 +1,28 @@
+import { BehaviorSubject, tap } from 'rxjs'
+import { PersistenceService, persistenceService } from '../PersistenceService'
+
+export const KEY_DARK_MODE = 'darkMode'
+
+export class AppSettings {
+ constructor(private persistenceService: PersistenceService) {
+ this.darkMode$
+ .pipe(
+ tap(value => {
+ this.persistenceService.set(KEY_DARK_MODE, value)
+ return value
+ }),
+ )
+ .subscribe()
+ }
+
+ darkMode$ = new BehaviorSubject(this.persistenceService.get(KEY_DARK_MODE, getBrowserDarkMode()))
+ setDarkMode(value: boolean) {
+ this.darkMode$.next(value)
+ }
+}
+
+function getBrowserDarkMode() {
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+export const appSettings = new AppSettings(persistenceService)
diff --git a/packages/explorer/src/services/PersistenceService/index.ts b/packages/explorer/src/services/PersistenceService/index.ts
new file mode 100644
index 0000000..92122d5
--- /dev/null
+++ b/packages/explorer/src/services/PersistenceService/index.ts
@@ -0,0 +1,19 @@
+export class PersistenceService {
+ get(key: string, defaultValue: T): T
+ get(key: string, defaultValue?: T): T | undefined {
+ // in SSR mode
+ if (typeof localStorage === 'undefined') return defaultValue
+ const jsonStr = localStorage.getItem(key)
+ if (!jsonStr) return defaultValue
+ return JSON.parse(jsonStr) as T
+ }
+
+ set(key: string, value: T): T {
+ // in SSR mode
+ if (typeof localStorage === 'undefined') return value
+ localStorage.setItem(key, JSON.stringify(value))
+ return value
+ }
+}
+
+export const persistenceService = new PersistenceService()
diff --git a/packages/explorer/src/styles/fonts/ProximaNova-Bold.otf b/packages/explorer/src/styles/fonts/ProximaNova-Bold.otf
new file mode 100644
index 0000000..4df9e17
Binary files /dev/null and b/packages/explorer/src/styles/fonts/ProximaNova-Bold.otf differ
diff --git a/packages/explorer/src/styles/fonts/ProximaNova-Regular.otf b/packages/explorer/src/styles/fonts/ProximaNova-Regular.otf
new file mode 100644
index 0000000..27c8d8f
Binary files /dev/null and b/packages/explorer/src/styles/fonts/ProximaNova-Regular.otf differ
diff --git a/packages/explorer/src/styles/fonts/ProximaNova-Semibold.otf b/packages/explorer/src/styles/fonts/ProximaNova-Semibold.otf
new file mode 100644
index 0000000..11a950a
Binary files /dev/null and b/packages/explorer/src/styles/fonts/ProximaNova-Semibold.otf differ
diff --git a/packages/explorer/src/styles/globals.scss b/packages/explorer/src/styles/globals.scss
new file mode 100644
index 0000000..e0762cb
--- /dev/null
+++ b/packages/explorer/src/styles/globals.scss
@@ -0,0 +1,67 @@
+/* stylelint-disable custom-property-pattern */
+/* stylelint-disable selector-class-pattern */
+@import 'src/styles/presets.module';
+@import 'src/styles/variables.module';
+
+html,
+body {
+ scroll-behavior: smooth;
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans',
+ 'Droid Sans', 'Helvetica Neue', sans-serif;
+ background-color: var(--colorPrimaryBg);
+}
+
+body {
+ --contentAreaWidth: 1320px;
+ --contentWrapperPadding: 32px;
+
+ @media (max-width: $mobileBreakPoint) {
+ --contentWrapperPadding: 24px;
+ }
+}
+
+main {
+ max-width: 100%;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+// This is provided to `prepareColorSchemaClasses` in `_document.page.tsx`.
+.themeLight {
+ @include themeLight;
+}
+
+// This is provided to `prepareColorSchemaClasses` in `_document.page.tsx`.
+.themeDark {
+ @include themeDark;
+}
+
+// Modify the default theme of overlayscrollbars.
+// https://github.com/KingSora/OverlayScrollbars/blob/060cf1cd9c677482a3a04274cb237da894f0d4b8/README.md#css-custom-properties
+.os-scrollbar {
+ // handle height = --os-size - padding - border = 6px
+ --os-size: 11px;
+ --os-handle-border: 0.5px solid rgb(153 153 153 / 50%);
+ --os-handle-border-radius: 32px;
+ --os-handle-bg: rgb(0 0 0 / 10%);
+ --os-handle-border-hover: var(--os-handle-border);
+ --os-handle-bg-hover: var(--os-handle-bg);
+ --os-handle-border-active: var(--os-handle-border);
+ --os-handle-bg-active: var(--os-handle-bg);
+
+ transition: opacity 0.2s;
+}
+
+.os-scrollbar-auto-hide.os-scrollbar-auto-hide-hidden {
+ // This is to ensure that the transition of os-scrollbar takes effect correctly.
+ visibility: unset;
+}
diff --git a/packages/explorer/src/styles/presets.module.scss b/packages/explorer/src/styles/presets.module.scss
new file mode 100644
index 0000000..003524e
--- /dev/null
+++ b/packages/explorer/src/styles/presets.module.scss
@@ -0,0 +1,55 @@
+@use 'sass:color';
+
+@mixin themeLight {
+ --colorPrimary: #333;
+ --colorPrimaryBg: #fff;
+ --colorSecondBg: #eaf5f0;
+ --colorItemBg: #eefbf1;
+ --color1: #999;
+ --color2: #222;
+ --color3: #666;
+ --colorBg1: #fff;
+ --colorBg2: #eaf5f0;
+ --colorShadow1: rgb(0 0 0 / 8%);
+ --headerShadow1: 0 1px 1px 0 var(--colorShadow1);
+ --menuShadow1: 0 8px 40px 0 #e5e5e5;
+ --menuBorder1: #e5e5e5;
+ --tocBorder: #e5e5e5;
+ --btnForeground: #fff;
+ --btnForegroundDisabled: #{rgba(#fff, 0.5)};
+ --btnBackground: #aa41f8;
+ --btnBackgroundDisabled: #{color.mix(#fff, #aa41f8, $weight: 50%)};
+ --btnBackgroundHover: #{color.mix(#000, #aa41f8, $weight: 20%)};
+ --btnBackgroundActive: #{color.mix(#000, #aa41f8, $weight: 40%)};
+}
+
+.themeLight {
+ @include themeLight;
+}
+
+@mixin themeDark {
+ --colorPrimary: #f5f5f5;
+ --colorPrimaryBg: #000;
+ --colorSecondBg: #111;
+ --colorItemBg: #222;
+ --color1: #777;
+ --color2: #999;
+ --color3: #999;
+ --colorBg1: #111;
+ --colorBg2: #333;
+ --colorShadow1: rgb(255 255 255 / 8%);
+ --headerShadow1: unset;
+ --menuShadow1: unset;
+ --menuBorder1: rgb(255 255 255 / 20%);
+ --tocBorder: #343e3c;
+ --btnForeground: #333;
+ --btnForegroundDisabled: #{rgba(#333, 0.5)};
+ --btnBackground: #aa41f8;
+ --btnBackgroundDisabled: #{color.mix(#000, #aa41f8, $weight: 50%)};
+ --btnBackgroundHover: #{color.mix(#000, #aa41f8, $weight: 20%)};
+ --btnBackgroundActive: #{color.mix(#000, #aa41f8, $weight: 40%)};
+}
+
+.themeDark {
+ @include themeDark;
+}
diff --git a/packages/explorer/src/styles/variables.module.scss b/packages/explorer/src/styles/variables.module.scss
new file mode 100644
index 0000000..00cccd6
--- /dev/null
+++ b/packages/explorer/src/styles/variables.module.scss
@@ -0,0 +1,6 @@
+// TODO: The code in the shared module should be reused.
+$mobileBreakPoint: 750px;
+
+:export {
+ mobileBreakPoint: $mobileBreakPoint;
+}
diff --git a/packages/explorer/src/typings/chunk-text.d.ts b/packages/explorer/src/typings/chunk-text.d.ts
new file mode 100644
index 0000000..6a7eb55
--- /dev/null
+++ b/packages/explorer/src/typings/chunk-text.d.ts
@@ -0,0 +1,11 @@
+declare module 'chunk-text' {
+ export default function chunkText(
+ text: string,
+ chunkSize: number,
+ options?: {
+ charLengthMask?: number
+ charLengthType?: 'length' | 'TextEncoder'
+ textEncoder?: Pick
+ },
+ ): string[]
+}
diff --git a/packages/explorer/src/typings/react.d.ts b/packages/explorer/src/typings/react.d.ts
new file mode 100644
index 0000000..4ac4dd0
--- /dev/null
+++ b/packages/explorer/src/typings/react.d.ts
@@ -0,0 +1,8 @@
+declare namespace React {
+ import { HTMLAttributes as ReactHTMLAttributes } from 'react'
+
+ export interface HTMLAttributes extends ReactHTMLAttributes {
+ // https://github.com/KingSora/OverlayScrollbars/blob/060cf1cd9c677482a3a04274cb237da894f0d4b8/README.md#bridging-initialization-flickering
+ 'data-overlayscrollbars-initialize'?: boolean
+ }
+}
diff --git a/packages/explorer/src/utils/api.ts b/packages/explorer/src/utils/api.ts
new file mode 100644
index 0000000..e2b42e5
--- /dev/null
+++ b/packages/explorer/src/utils/api.ts
@@ -0,0 +1,67 @@
+/**
+ * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which
+ * contains the Next.js App-wrapper, as well as your type-safe React Query hooks.
+ *
+ * We also create a few inference helpers for input and output types.
+ */
+import { httpBatchLink, loggerLink } from '@trpc/client'
+import { createTRPCNext } from '@trpc/next'
+import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'
+import superjson from 'superjson'
+
+import { type AppRouter } from '../server/api/root'
+
+const getBaseUrl = () => {
+ if (typeof window !== 'undefined') return '' // browser should use relative url
+ if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` // SSR should use vercel url
+ return `http://localhost:${process.env.PORT ?? 3000}` // dev SSR should use localhost
+}
+
+/** A set of type-safe react-query hooks for your tRPC API. */
+export const api = createTRPCNext({
+ config() {
+ return {
+ /**
+ * Transformer used for data de-serialization from the server.
+ *
+ * @see https://trpc.io/docs/data-transformers
+ */
+ transformer: superjson,
+
+ /**
+ * Links used to determine request flow from client to server.
+ *
+ * @see https://trpc.io/docs/links
+ */
+ links: [
+ loggerLink({
+ enabled: opts =>
+ process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
+ }),
+ httpBatchLink({
+ url: `${getBaseUrl()}/api/trpc`,
+ }),
+ ],
+ }
+ },
+ /**
+ * Whether tRPC should await queries when server rendering pages.
+ *
+ * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false
+ */
+ ssr: false,
+})
+
+/**
+ * Inference helper for inputs.
+ *
+ * @example type HelloInput = RouterInputs['example']['hello']
+ */
+export type RouterInputs = inferRouterInputs
+
+/**
+ * Inference helper for outputs.
+ *
+ * @example type HelloOutput = RouterOutputs['example']['hello']
+ */
+export type RouterOutputs = inferRouterOutputs
diff --git a/packages/explorer/src/utils/env.ts b/packages/explorer/src/utils/env.ts
new file mode 100644
index 0000000..cc4503f
--- /dev/null
+++ b/packages/explorer/src/utils/env.ts
@@ -0,0 +1,4 @@
+// TODO: Later, it will be replaced with the implementation using env.mjs.
+export const REPO = process.env.NEXT_PUBLIC_REPO
+export const TOKEN = process.env.GITHUB_TOKEN
+export const UPTIME_KEY = process.env.UPTIME_KEY
diff --git a/packages/explorer/src/utils/github.ts b/packages/explorer/src/utils/github.ts
new file mode 100644
index 0000000..50a692a
--- /dev/null
+++ b/packages/explorer/src/utils/github.ts
@@ -0,0 +1,259 @@
+import { Octokit } from '@octokit/rest'
+import { paginateRest } from '@octokit/plugin-paginate-rest'
+import { components } from '@octokit/openapi-types'
+import {
+ Discussion as GQLDiscussion,
+ DiscussionCategory as GQLDiscussionCategory,
+ Label as GQLLabel,
+ Repository,
+ SearchResultItemConnection,
+} from '@octokit/graphql-schema'
+import { RequestError } from '@octokit/request-error'
+import { BooleanT } from '@magickbase-website/shared'
+import { REPO, TOKEN } from './env'
+
+if (REPO === undefined) throw new Error('NEXT_PUBLIC_REPO is required')
+const repoOwner = REPO.split('/')[0] ?? ''
+const repoName = REPO.split('/')[1] ?? ''
+
+const EnhancedOctokit = Octokit.plugin(paginateRest)
+const octokit = new EnhancedOctokit({ auth: TOKEN })
+
+export type Issue = components['schemas']['issue']
+export type DiscussionCategory = Omit
+export type Discussion = Pick & {
+ category: DiscussionCategory
+ labels: Pick[]
+}
+export type Release = components['schemas']['release']
+
+export interface ParsedAsset {
+ os: string
+ arch: string
+ packageType: string
+ checksum: string
+ packageLink: string
+}
+
+const GQL_CATEGORY_FIELDS = () => `
+ createdAt
+ description
+ emoji
+ emojiHTML
+ id
+ isAnswerable
+ name
+ slug
+ updatedAt
+`
+const GQL_DISCUSSION_FIELDS = (labelFirst = 100) => `
+ id
+ number
+ title
+ body
+ category {
+ ${GQL_CATEGORY_FIELDS()}
+ }
+ createdAt
+ url
+ labels(first: ${labelFirst}) {
+ nodes {
+ id
+ name
+ description
+ }
+ }
+`
+
+export async function getIssues(label?: string, limit = Infinity): Promise {
+ let sum = 0
+ const issues = await octokit.paginate(
+ octokit.rest.issues.listForRepo,
+ {
+ owner: repoOwner,
+ repo: repoName,
+ labels: label,
+ per_page: Math.min(limit, 100),
+ state: 'all',
+ },
+ (response, done) => {
+ sum += response.data.length
+ if (sum >= limit) done()
+ return response.data
+ },
+ )
+ return issues.slice(0, limit)
+}
+
+// TODO: support nullable
+export async function getIssue(issueNumber: number): Promise {
+ const res = await octokit.rest.issues.get({
+ owner: repoOwner,
+ repo: repoName,
+ issue_number: issueNumber,
+ })
+ return res.data
+}
+
+export async function getDiscussionCategories(): Promise {
+ const res = await octokit.graphql<{ repository: Repository }>(
+ `
+ query($repoOwner: String!, $repoName: String!) {
+ repository(owner: $repoOwner, name: $repoName) {
+ discussionCategories(first: 100) {
+ nodes {
+ ${GQL_CATEGORY_FIELDS()}
+ }
+ }
+ }
+ }
+ `,
+ {
+ repoOwner,
+ repoName,
+ },
+ )
+ const discussionCategories = res.repository.discussionCategories.nodes ?? []
+ return discussionCategories.filter(BooleanT())
+}
+
+export async function getDiscussions(categoryId: DiscussionCategory['id']): Promise {
+ // TODO: support paginate
+ const res = await octokit.graphql<{ repository: Repository }>(
+ `
+ query($repoOwner: String!, $repoName: String!, $categoryId: ID) {
+ repository(owner: $repoOwner, name: $repoName) {
+ discussions(first: 100, categoryId: $categoryId) {
+ nodes {
+ ${GQL_DISCUSSION_FIELDS()}
+ }
+ }
+ }
+ }
+ `,
+ {
+ repoOwner,
+ repoName,
+ categoryId,
+ },
+ )
+ const discussions = res.repository.discussions.nodes ?? []
+ return discussions.filter(BooleanT()).map(discussion => ({
+ ...discussion,
+ labels: (discussion.labels?.nodes ?? []).filter(BooleanT()),
+ }))
+}
+
+// TODO: support nullable
+export async function getDiscussion(number: number): Promise {
+ const res = await octokit.graphql<{ repository: Repository }>(
+ `
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
+ repository(owner: $repoOwner, name: $repoName) {
+ discussion(number: $number) {
+ ${GQL_DISCUSSION_FIELDS()}
+ }
+ }
+ }
+ `,
+ {
+ repoOwner,
+ repoName,
+ number,
+ },
+ )
+
+ const discussion = res.repository.discussion
+ if (discussion == null) {
+ throw new Error(`Discussion ${number} not found`)
+ }
+
+ return {
+ ...discussion,
+ labels: (discussion.labels?.nodes ?? []).filter(BooleanT()),
+ }
+}
+
+// This function is currently not in use. it is reserved for a future scenario where there
+// may be a desire to switch to obtaining the top-level menu of Discussions through labels.
+export async function getDiscussionsByLabel(label?: string): Promise {
+ // TODO: support paginate
+ const res = await octokit.graphql<{ search: SearchResultItemConnection }>(
+ `
+ query($searchQuery: String!) {
+ search(type: DISCUSSION, query: $searchQuery, first: 100) {
+ discussionCount
+ nodes {
+ ... on Discussion {
+ ${GQL_DISCUSSION_FIELDS()}
+ }
+ }
+ }
+ }
+ `,
+ {
+ searchQuery: `repo:${repoOwner}/${repoName}${label ? ` label:"${label}"` : ''}`,
+ },
+ )
+ const discussions = res.search.nodes?.filter((node): node is GQLDiscussion => node?.__typename === 'Discussion') ?? []
+ return discussions.map(discussion => ({
+ ...discussion,
+ labels: (discussion.labels?.nodes ?? []).filter(BooleanT()),
+ }))
+}
+
+export async function getReleases(limit = Infinity): Promise {
+ let sum = 0
+ const releases = await octokit.paginate(
+ octokit.rest.repos.listReleases,
+ {
+ owner: repoOwner,
+ repo: repoName,
+ per_page: Math.min(limit, 100),
+ },
+ (response, done) => {
+ sum += response.data.length
+ if (sum >= limit) done()
+ return response.data
+ },
+ )
+ return releases.slice(0, limit)
+}
+
+export async function getLatestRelease(): Promise {
+ try {
+ const res = await octokit.rest.repos.getLatestRelease({
+ owner: repoOwner,
+ repo: repoName,
+ })
+ return res.data
+ } catch (err) {
+ if (err instanceof RequestError && err.status === 404) {
+ return null
+ }
+ throw err
+ }
+}
+
+export async function getRepoFileInfo(path: string) {
+ try {
+ const res = await octokit.rest.repos.getContent({
+ owner: repoOwner,
+ repo: repoName,
+ path,
+ })
+ if (!('type' in res.data) || res.data.type !== 'file') return null
+ return res.data
+ } catch (err) {
+ if (err instanceof RequestError && err.status === 404) {
+ return null
+ }
+ throw err
+ }
+}
+
+export async function getRepoFileWithTextFormat(path: string): Promise {
+ const info = await getRepoFileInfo(path)
+ if (!info) return null
+ return Buffer.from(info.content, 'base64').toString('utf-8')
+}
diff --git a/packages/explorer/src/utils/i18n.ts b/packages/explorer/src/utils/i18n.ts
new file mode 100644
index 0000000..83437f6
--- /dev/null
+++ b/packages/explorer/src/utils/i18n.ts
@@ -0,0 +1,6 @@
+import { Namespace, TFunction } from 'i18next'
+
+export function createI18nKeyAdder(ns: N): TFunction {
+ const addI18nKey = (key: string) => key
+ return addI18nKey as TFunction
+}
diff --git a/packages/explorer/src/utils/index.ts b/packages/explorer/src/utils/index.ts
new file mode 100644
index 0000000..4be885a
--- /dev/null
+++ b/packages/explorer/src/utils/index.ts
@@ -0,0 +1,8 @@
+export function isClient() {
+ return typeof window !== 'undefined'
+}
+
+export * from './api'
+export * from './github'
+export * from './route'
+export * from './env'
diff --git a/packages/explorer/src/utils/route.ts b/packages/explorer/src/utils/route.ts
new file mode 100644
index 0000000..aa17762
--- /dev/null
+++ b/packages/explorer/src/utils/route.ts
@@ -0,0 +1,14 @@
+import { Post } from './posts'
+
+export function getPostURL(post: Pick) {
+ return `/posts/${post.source}/${post.number}`
+}
+
+export function removeURLOrigin(url: string) {
+ try {
+ const urlObj = new URL(url)
+ return url.replace(urlObj.origin, '')
+ } catch {
+ return url
+ }
+}
diff --git a/packages/explorer/tsconfig.json b/packages/explorer/tsconfig.json
new file mode 100644
index 0000000..9df75a0
--- /dev/null
+++ b/packages/explorer/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "@magickbase-website/config/tsconfig-nextjs.json",
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/yarn.lock b/yarn.lock
index 2d02892..2f32309 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2188,6 +2188,67 @@ __metadata:
languageName: node
linkType: hard
+"@magickbase-website/ckb-explorer@workspace:packages/explorer":
+ version: 0.0.0-use.local
+ resolution: "@magickbase-website/ckb-explorer@workspace:packages/explorer"
+ dependencies:
+ "@magickbase-website/config": "workspace:^"
+ "@magickbase-website/eslint-config": "workspace:^"
+ "@magickbase-website/shared": "workspace:^"
+ "@octokit/graphql-schema": ^14.12.0
+ "@octokit/openapi-types": ^17.2.0
+ "@octokit/plugin-paginate-rest": ^6.1.2
+ "@octokit/rest": ^19.0.11
+ "@radix-ui/react-accordion": ^1.1.2
+ "@radix-ui/react-dialog": ^1.0.4
+ "@radix-ui/react-dropdown-menu": ^2.0.5
+ "@radix-ui/react-popover": ^1.0.6
+ "@radix-ui/react-tooltip": ^1.0.6
+ "@svgr/webpack": ^8.0.1
+ "@tanstack/react-query": ^4.29.12
+ "@trpc/client": ^10.28.2
+ "@trpc/next": ^10.28.2
+ "@trpc/react-query": ^10.28.2
+ "@trpc/server": ^10.28.2
+ "@types/eslint": ^8.56.0
+ "@types/node": ^20.2.5
+ "@types/react": ^18.2.46
+ "@types/react-dom": ^18.2.4
+ chunk-text: ^2.0.1
+ clsx: ^1.2.1
+ eslint: ^8.56.0
+ i18next: ^22.5.0
+ i18next-parser: ^8.7.0
+ next: ^13.4.4
+ next-i18next: ^13.2.2
+ overlayscrollbars: ^2.4.5
+ overlayscrollbars-react: ^0.5.3
+ postcss: ^8.4.24
+ postcss-scss: ^4.0.6
+ react: ^18.2.0
+ react-dom: ^18.2.0
+ react-i18next: ^12.3.1
+ react-markdown: ^8.0.7
+ react-resize-detector: ^9.1.0
+ rehype-raw: ^6.1.1
+ rehype-sanitize: ^5.0.1
+ remark-gfm: ^3.0.1
+ rxjs: ^7.8.1
+ sass: ^1.62.1
+ stylelint: ^15.6.2
+ stylelint-config-css-modules: ^4.2.0
+ stylelint-config-prettier-scss: ^1.0.0
+ stylelint-config-rational-order: ^0.1.2
+ stylelint-config-standard-scss: ^9.0.0
+ stylelint-order: ^6.0.3
+ superjson: ^1.12.3
+ typescript: ^5.1.3
+ zod: ^3.21.4
+ peerDependencies:
+ "@octokit/request-error": "*"
+ languageName: unknown
+ linkType: soft
+
"@magickbase-website/config@workspace:^, @magickbase-website/config@workspace:packages/config":
version: 0.0.0-use.local
resolution: "@magickbase-website/config@workspace:packages/config"