Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .codex/environments/environment.toml
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions .github/workflows/build-and-deploy-dev.yml
Original file line number Diff line number Diff line change
@@ -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"}'
4 changes: 2 additions & 2 deletions app/api/chains/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 22 additions & 10 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Chains> {
Expand Down Expand Up @@ -44,6 +46,7 @@ export default function Home() {
});
const [sortOption, setSortOption] = useState<'Featured' | 'Alphabetical'>('Featured');
const [featuredChains, setFeaturedChains] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('list');

const popularEcosystems = ['Ethereum', 'Polygon', 'Optimism', 'Polkadot', 'Cosmos', 'zkSync', 'Arbitrum'];

Expand Down Expand Up @@ -157,17 +160,25 @@ export default function Home() {
/>
</div>
<div className="w-full mb-6 flex flex-col md:flex-row gap-3 md:gap-0 justify-between items-center">
<div className="text-lg md:text-[22px] font-semibold text-[#6b6b74]">
{sortedAndFilteredChains.length} Results
<div className="flex items-center gap-5">
<div className="text-lg md:text-[22px] font-semibold text-[#6b6b74]">
{sortedAndFilteredChains.length} Results
</div>
<DownloadDropdown chains={sortedAndFilteredChains} />
</div>
<div className="flex w-full md:w-auto items-center gap-3">
<div className="hidden min-[1000px]:block">
<ViewToggle value={viewMode} onChange={setViewMode} />
</div>
<Filters
filters={filters}
setFilters={setFilters}
ecosystems={ecosystems}
appliedFiltersCount={appliedFiltersCount}
sortOption={sortOption}
setSortOption={setSortOption}
/>
</div>
<Filters
filters={filters}
setFilters={setFilters}
ecosystems={ecosystems}
appliedFiltersCount={appliedFiltersCount}
sortOption={sortOption}
setSortOption={setSortOption}
/>
</div>
<AddChainSection />
<ChainList
Expand All @@ -176,6 +187,7 @@ export default function Home() {
isLoading={isLoading}
filters={filters}
featuredChains={featuredChains}
viewMode={viewMode}
/>
</div>
</div>
Expand Down
33 changes: 33 additions & 0 deletions components/ChainBadges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { HOSTING_PROVIDERS, HostingProvider } from '@/utils/constants';

export const hostingColors: Record<HostingProvider, { bg: string; text: string }> = {
'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 }) => (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-sm font-medium bg-[#f4f5f6] text-[#6b6b74]">
{children}
</span>
);

export const HostedByBadge = ({ hostedBy }: { hostedBy: HostingProvider }) => {
const hostedByText = HOSTING_PROVIDERS[hostedBy] || 'Unknown';
const colors = hostingColors[hostedBy] || hostingColors.blockscout;

return (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
style={{ backgroundColor: colors.bg, color: colors.text }}
>
{hostedBy === 'self' ? 'Self-hosted' : `Hosted by ${hostedByText}`}
</span>
);
};
69 changes: 40 additions & 29 deletions components/ChainCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostingProvider, { bg: string; text: string }> = {
'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 }) => (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-sm font-medium bg-[#f4f5f6] text-[#6b6b74]">
{children}
</span>
);
import { ROLLUP_TYPES, RollupType } from '@/utils/constants';
import { HostedByBadge, Tag } from '@/components/ChainBadges';
import LinkIcon from '@/icons/link.svg';

export default function ChainCard({
chainId,
Expand All @@ -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<HTMLElement>) => {
if (isClickFromLink(event.target)) return;
if (!isFullCardClickEnabled()) return;

openExplorer();
};

const handleCardKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (isClickFromLink(event.target)) return;
if (!isFullCardClickEnabled()) return;
if (event.key !== 'Enter' && event.key !== ' ') return;

event.preventDefault();
openExplorer();
};

return (
<div className="bg-white p-6 flex flex-col border rounded-[20px] hover:shadow-[20px_0_40px_rgba(183,183,183,.1),2px_0_20px_rgba(183,183,183,.08)] transition-shadow duration-[400ms] ease-[cubic-bezier(.39, .575, .565, 1)] group">
<div
className="bg-white p-6 flex flex-col border rounded-[20px] hover:shadow-[20px_0_40px_rgba(183,183,183,.1),2px_0_20px_rgba(183,183,183,.08)] transition-shadow duration-[400ms] ease-[cubic-bezier(.39, .575, .565, 1)] group cursor-pointer"
role="link"
tabIndex={0}
aria-label={`Open ${name} explorer`}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
>
<div className="flex justify-between items-center mb-6">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium"
style={{ backgroundColor: colors.bg, color: colors.text }}
>
{hostedBy === 'self' ? 'Self-hosted' : `Hosted by ${hostedByText}`}
</span>
<HostedByBadge hostedBy={hostedBy} />
{featured && (
<Image
src="/star.svg"
Expand Down Expand Up @@ -91,7 +102,7 @@ export default function ChainCard({
<span className="text-sm font-medium text-black group-hover/link:text-blue-600 transition-colors duration-[400ms]">
{text}
</span>
<LinkIcon className="flex-shrink-0 text-[#B1B5C3] group-hover/link:text-blue-600 transition-colors duration-[400ms]"/>
<LinkIcon className="w-3 h-3 flex-shrink-0 text-[#B1B5C3] group-hover/link:text-blue-600 transition-colors duration-[400ms]"/>
</Link>
{index < array.length - 1 && <div className="border-t border-gray-200 my-3"></div>}
</React.Fragment>
Expand Down
66 changes: 55 additions & 11 deletions components/ChainList.tsx
Original file line number Diff line number Diff line change
@@ -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]>;
Expand All @@ -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 (
<div className={`w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4 ${className}`}>
{chains.map(([chainId, data]) => (
<ChainCard key={chainId} chainId={chainId} featured={featuredChains.includes(chainId)} {...data} />
))}
</div>
);
}

function SkeletonCardGrid({ className = '' }: { className?: string }) {
return (
<div className={`w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4 ${className}`}>
{[...Array(16)].map((_, index) => (
<SkeletonCard key={index} />
))}
</div>
);
}

export default function ChainList({ chains, searchTerm, isLoading, filters, featuredChains, viewMode }: Props) {
const [currentPage, setCurrentPage] = useState(1);

const currentChains = useMemo(() => {
Expand All @@ -39,21 +71,33 @@ export default function ChainList({ chains, searchTerm, isLoading, filters, feat

if (isLoading) {
return (
<div className="w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(16)].map((_, index) => (
<SkeletonCard key={index} />
))}
</div>
<>
<SkeletonCardGrid className={viewMode === 'list' ? 'min-[1000px]:hidden' : ''} />
{viewMode === 'list' && (
<div className="hidden w-full min-[1000px]:block">
<SkeletonTable />
</div>
)}
</>
);
}

return (
<>
<div className="w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{currentChains.map(([chainId, data]) => (
<ChainCard key={chainId} chainId={chainId} featured={featuredChains.includes(chainId)} {...data} />
))}
</div>
{viewMode === 'list' ? (
<>
<CardGrid
chains={currentChains}
featuredChains={featuredChains}
className="min-[1000px]:hidden"
/>
<div className="hidden w-full min-[1000px]:block">
<ChainTable chains={currentChains} featuredChains={featuredChains} />
</div>
</>
) : (
<CardGrid chains={currentChains} featuredChains={featuredChains} />
)}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
Expand Down
Loading
Loading