diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..0e90454 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "chainscout" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "npm run dev" diff --git a/.github/workflows/build-and-deploy-dev.yml b/.github/workflows/build-and-deploy-dev.yml new file mode 100644 index 0000000..1241bac --- /dev/null +++ b/.github/workflows/build-and-deploy-dev.yml @@ -0,0 +1,73 @@ +on: + push: + branches: + - 'dev' + +name: Build and push docker (dev) + +env: + REGISTRY: ghcr.io + IMAGE_NAME: blockscout/chainscout + +jobs: + push: + name: Docker build and docker push + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: "." + file: "Dockerfile" + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + deploy_dev: + name: Deploy dev instance + needs: push + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Get Vault credentials + id: retrieve-vault-secrets + uses: hashicorp/vault-action@v2.4.1 + with: + url: https://vault.k8s.blockscout.com + role: ci-dev + path: github-jwt + method: jwt + tlsSkipVerify: false + exportToken: true + secrets: | + ci/data/dev/github token | WORKFLOW_TRIGGER_TOKEN ; + - name: Trigger deploy + uses: convictional/trigger-workflow-and-wait@v1.6.1 + with: + owner: blockscout + repo: deployment-values + github_token: ${{ env.WORKFLOW_TRIGGER_TOKEN }} + workflow_file_name: deploy_services.yaml + ref: main + wait_interval: 30 + client_payload: '{ "instance": "chainscout", "globalEnv": "testing"}' diff --git a/app/api/chains/[id]/route.ts b/app/api/chains/[id]/route.ts index bdf4012..6881381 100644 --- a/app/api/chains/[id]/route.ts +++ b/app/api/chains/[id]/route.ts @@ -5,10 +5,10 @@ import { Chains } from '@/types'; export async function GET( request: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { try { - const id = params.id; + const { id } = await params; const filePath = path.join(process.cwd(), 'data', 'chains.json'); const jsonData = await fs.readFile(filePath, 'utf8'); const chainsData: Chains = JSON.parse(jsonData); diff --git a/app/page.tsx b/app/page.tsx index 714489e..978d5df 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,8 @@ import ChainList from '@/components/ChainList'; import Filters from '@/components/Filters'; import PopularEcosystems from '@/components/PopularEcosystems'; import AddChainSection from '@/components/AddChainSection'; +import ViewToggle, { ViewMode } from '@/components/ViewToggle'; +import DownloadDropdown from '@/components/DownloadDropdown'; import { Chains } from '@/types'; async function getChainsData(): Promise { @@ -44,6 +46,7 @@ export default function Home() { }); const [sortOption, setSortOption] = useState<'Featured' | 'Alphabetical'>('Featured'); const [featuredChains, setFeaturedChains] = useState([]); + const [viewMode, setViewMode] = useState('list'); const popularEcosystems = ['Ethereum', 'Polygon', 'Optimism', 'Polkadot', 'Cosmos', 'zkSync', 'Arbitrum']; @@ -157,17 +160,25 @@ export default function Home() { />
-
- {sortedAndFilteredChains.length} Results +
+
+ {sortedAndFilteredChains.length} Results +
+ +
+
+
+ +
+
-
diff --git a/components/ChainBadges.tsx b/components/ChainBadges.tsx new file mode 100644 index 0000000..8b7f642 --- /dev/null +++ b/components/ChainBadges.tsx @@ -0,0 +1,33 @@ +import { HOSTING_PROVIDERS, HostingProvider } from '@/utils/constants'; + +export const hostingColors: Record = { + 'blockscout': { bg: '#91eabf', text: '#006635' }, + 'conduit': { bg: '#31e3e3', text: '#0a0a0a' }, + 'gelato-raas': { bg: '#f37b84', text: '#202020' }, + 'altlayer-raas': { bg: 'hsla(264.6428571428571, 100.00%, 78.04%, 1.00)', text: '#1c1e24' }, + 'protofire': { bg: '#faa807', text: '#1c1e24' }, + 'gateway': { bg: '#9368E8', text: '#ffffff' }, + 'self': { bg: '#c2d9ff', text: '#003180' }, + 'alchemy': { bg: '#363FF9', text: '#ffffff' }, + 'caldera': { bg: '#FC5000', text: '#F7F6F3' }, +}; + +export const Tag = ({ children }: { children: string }) => ( + + {children} + +); + +export const HostedByBadge = ({ hostedBy }: { hostedBy: HostingProvider }) => { + const hostedByText = HOSTING_PROVIDERS[hostedBy] || 'Unknown'; + const colors = hostingColors[hostedBy] || hostingColors.blockscout; + + return ( + + {hostedBy === 'self' ? 'Self-hosted' : `Hosted by ${hostedByText}`} + + ); +}; diff --git a/components/ChainCard.tsx b/components/ChainCard.tsx index 2735030..1b2b8b4 100644 --- a/components/ChainCard.tsx +++ b/components/ChainCard.tsx @@ -2,25 +2,9 @@ import { ChainData } from '@/types'; import Image from 'next/image'; import Link from 'next/link'; import React from 'react'; -import { HOSTING_PROVIDERS, HostingProvider, ROLLUP_TYPES, RollupType } from '@/utils/constants'; -import LinkIcon from '@/public/link.svg'; - -const hostingColors: Record = { - 'blockscout': { bg: '#91eabf', text: '#006635' }, - 'conduit': { bg: '#31e3e3', text: '#0a0a0a' }, - 'gelato-raas': { bg: '#f37b84', text: '#202020' }, - 'altlayer-raas': { bg: 'hsla(264.6428571428571, 100.00%, 78.04%, 1.00)', text: '#1c1e24' }, - 'protofire': { bg: '#faa807', text: '#1c1e24' }, - 'gateway': { bg: '#9368E8', text: '#ffffff' }, - 'self': { bg: '#c2d9ff', text: '#003180' }, - 'alchemy': { bg: '#363FF9', text: '#ffffff' }, -}; - -const Tag = ({ children }: { children: string }) => ( - - {children} - -); +import { ROLLUP_TYPES, RollupType } from '@/utils/constants'; +import { HostedByBadge, Tag } from '@/components/ChainBadges'; +import LinkIcon from '@/icons/link.svg'; export default function ChainCard({ chainId, @@ -36,19 +20,46 @@ export default function ChainCard({ featured, }: ChainData & { chainId: string, featured: boolean }) { const { hostedBy, url } = explorers[0]; - const hostedByText = HOSTING_PROVIDERS[hostedBy as HostingProvider] || 'Unknown'; - const colors = hostingColors[hostedBy as HostingProvider] || hostingColors.blockscout; const ecosystemTags = Array.isArray(ecosystem) ? ecosystem : [ecosystem]; + const isClickFromLink = (target: EventTarget | null) => { + return target instanceof HTMLElement && Boolean(target.closest('a')); + }; + + const openExplorer = () => { + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + const isFullCardClickEnabled = () => { + return window.matchMedia('(min-width: 1000px)').matches; + }; + + const handleCardClick = (event: React.MouseEvent) => { + if (isClickFromLink(event.target)) return; + if (!isFullCardClickEnabled()) return; + + openExplorer(); + }; + + const handleCardKeyDown = (event: React.KeyboardEvent) => { + if (isClickFromLink(event.target)) return; + if (!isFullCardClickEnabled()) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + + event.preventDefault(); + openExplorer(); + }; return ( -
+
- - {hostedBy === 'self' ? 'Self-hosted' : `Hosted by ${hostedByText}`} - + {featured && ( {text} - + {index < array.length - 1 &&
} diff --git a/components/ChainList.tsx b/components/ChainList.tsx index 1ca18c0..43991cc 100644 --- a/components/ChainList.tsx +++ b/components/ChainList.tsx @@ -1,8 +1,11 @@ import React, { useState, useMemo, useEffect } from 'react'; import { ChainData } from '@/types'; import ChainCard from '@/components/ChainCard'; +import ChainTable from '@/components/ChainTable'; import SkeletonCard from '@/components/SkeletonCard'; +import SkeletonTable from '@/components/SkeletonTable'; import Pagination from '@/components/Pagination'; +import { ViewMode } from '@/components/ViewToggle'; type Props = { chains: Array<[string, ChainData]>; @@ -14,11 +17,40 @@ type Props = { ecosystems: string[]; }; featuredChains: string[]; + viewMode: ViewMode; }; const ITEMS_PER_PAGE = 16; -export default function ChainList({ chains, searchTerm, isLoading, filters, featuredChains }: Props) { +function CardGrid({ + chains, + featuredChains, + className = '', +}: { + chains: Array<[string, ChainData]>; + featuredChains: string[]; + className?: string; +}) { + return ( +
+ {chains.map(([chainId, data]) => ( + + ))} +
+ ); +} + +function SkeletonCardGrid({ className = '' }: { className?: string }) { + return ( +
+ {[...Array(16)].map((_, index) => ( + + ))} +
+ ); +} + +export default function ChainList({ chains, searchTerm, isLoading, filters, featuredChains, viewMode }: Props) { const [currentPage, setCurrentPage] = useState(1); const currentChains = useMemo(() => { @@ -39,21 +71,33 @@ export default function ChainList({ chains, searchTerm, isLoading, filters, feat if (isLoading) { return ( -
- {[...Array(16)].map((_, index) => ( - - ))} -
+ <> + + {viewMode === 'list' && ( +
+ +
+ )} + ); } return ( <> -
- {currentChains.map(([chainId, data]) => ( - - ))} -
+ {viewMode === 'list' ? ( + <> + +
+ +
+ + ) : ( + + )} ; + featuredChains: string[]; +}; + +export default function ChainTable({ chains, featuredChains }: ChainTableProps) { + if (chains.length === 0) return null; + + return ( +
+
+
+
Name
+ + ); +} + +const TableTag = ({ children }: { children: string }) => ( + + {children} + +); + +function ChainTableRow({ + name, + description, + layer, + rollupType, + explorers, + isTestnet, + website, + logo, + ecosystem, + featured, +}: ChainData & { chainId: string; featured: boolean }) { + const { hostedBy, url } = explorers[0]; + const ecosystemTags = Array.isArray(ecosystem) ? ecosystem : [ecosystem]; + + return ( +
+
+
+ {featured ? ( + Featured Chain + ) : ( +
+
+ {`${name} + {name} +
+
+ +