Skip to content

Commit

Permalink
Merge pull request #10 from blockscout/ui-improvements
Browse files Browse the repository at this point in the history
UI improvements
  • Loading branch information
maxaleks authored Oct 15, 2024
2 parents 7657da4 + 06cff81 commit 338f8bd
Show file tree
Hide file tree
Showing 22 changed files with 3,050 additions and 170 deletions.
16 changes: 16 additions & 0 deletions app/api/featured/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';

export async function GET() {
try {
const filePath = path.join(process.cwd(), 'data', 'featured.json');
const jsonData = await fs.readFile(filePath, 'utf8');
const featuredNetworks: string[] = JSON.parse(jsonData);

return NextResponse.json(featuredNetworks);
} catch (error) {
console.error('Error reading featured networks data:', error);
return NextResponse.json({ error: 'Failed to load featured networks data' }, { status: 500 });
}
}
14 changes: 9 additions & 5 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@
@tailwind utilities;

body {
background-image: url('/background.png');
background-position: 50% 0;
background-repeat: no-repeat;
background-size: 100%;
background-attachment: scroll;
background-color: #fff;
}

@keyframes pulse {
Expand All @@ -22,3 +18,11 @@ body {
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

.custom-background {
background-image: url('/background.png');
background-position: 50% 0;
background-repeat: no-repeat;
background-size: 100%;
background-attachment: scroll;
}
114 changes: 93 additions & 21 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from 'react';
import SearchBar from '@/components/SearchBar';
import ChainList from '@/components/ChainList';
import Filters from '@/components/Filters';
import PopularEcosystems from '@/components/PopularEcosystems';
import AddChainSection from '@/components/AddChainSection';
import { Chains } from '@/types';

async function getChainsData(): Promise<Chains> {
Expand All @@ -18,6 +20,18 @@ async function getChainsData(): Promise<Chains> {
return res.json();
}

async function getFeaturedChains(): Promise<string[]> {
const res = await fetch('/api/featured', {
headers: {
'Cache-Control': 'no-cache'
}
});
if (!res.ok) {
throw new Error('Failed to fetch featured networks');
}
return res.json();
}

export default function Home() {
const [searchTerm, setSearchTerm] = useState('');
const [chainsData, setChainsData] = useState<Chains>({});
Expand All @@ -28,6 +42,10 @@ export default function Home() {
networkTypes: [] as string[],
ecosystems: [] as string[],
});
const [sortOption, setSortOption] = useState<'Featured' | 'Alphabetical'>('Featured');
const [featuredChains, setFeaturedChains] = useState<string[]>([]);

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

const ecosystems = useMemo(() => {
if (!chainsData) return [];
Expand All @@ -42,8 +60,17 @@ export default function Home() {
return Object.values(filters).reduce((acc, curr) => acc + curr.length, 0);
}, [filters]);

const handleEcosystemSelect = (ecosystem: string) => {
setFilters(prevFilters => ({
...prevFilters,
ecosystems: prevFilters.ecosystems.includes(ecosystem.toLowerCase())
? prevFilters.ecosystems.filter(e => e !== ecosystem.toLowerCase())
: [...prevFilters.ecosystems, ecosystem.toLowerCase()]
}));
};

const filteredChains = useMemo(() => {
return Object.entries(chainsData).filter(([chainId, data]) => {
return Object.fromEntries(Object.entries(chainsData).filter(([chainId, data]) => {
const searchLower = searchTerm.toLowerCase();
const nameMatch = data.name.toLowerCase().includes(searchLower);
const chainIdMatch = chainId.toLowerCase().includes(searchLower);
Expand All @@ -61,7 +88,7 @@ export default function Home() {
: filters.ecosystems.includes(data.ecosystem.toLowerCase()));

return searchMatch && hostingMatch && networkTypeMatch && ecosystemMatch;
});
}));
}, [chainsData, searchTerm, filters]);

useEffect(() => {
Expand All @@ -80,32 +107,77 @@ export default function Home() {
loadChainsData();
}, []);

useEffect(() => {
async function loadFeaturedChains() {
try {
const networks = await getFeaturedChains();
setFeaturedChains(networks);
} catch (err) {
console.error('Failed to load featured networks:', err);
}
}

loadFeaturedChains();
}, []);

const sortedAndFilteredChains = useMemo(() => {
let sorted = Object.entries(filteredChains);

if (sortOption === 'Featured') {
sorted = sorted.sort((a, b) => {
const aIndex = featuredChains.indexOf(a[0]);
const bIndex = featuredChains.indexOf(b[0]);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return 0;
});
} else {
sorted = sorted.sort((a, b) => a[1].name.localeCompare(b[1].name));
}

return sorted;
}, [filteredChains, sortOption, featuredChains]);

if (error) return <div className="text-center text-red-500 mt-8">{error}</div>;

return (
<main className="max-w-[1376px] mx-auto pt-24 pb-[100px] sm:px-6 lg:px-10">
<div className="flex flex-col items-center px-4 sm:px-0">
<h1 className="font-poppins text-[#1d1d1f] text-[42px] md:text-[54px] lg:text-7xl leading-[1.08em] lg:leading-[1.08em] font-semibold text-center mb-12">
Chains & Projects<br />Using Blockscout
</h1>
<SearchBar onSearch={setSearchTerm} />
<div className="w-full mt-16 mb-4 flex justify-between items-center">
<div className="text-[22px] font-semibold text-[#6b6b74]">
{filteredChains.length} Results
<main className="pt-[143px] md:pt-[138px]">
<div className="flex flex-col items-center custom-background">
<div className="flex flex-col items-center px-5 pt-[60px] md:pt-24 w-full max-w-[1376px] mx-auto pb-[100px] sm:px-6 lg:px-10">
<h1 className="font-poppins text-[#1d1d1f] text-[36px] md:text-[54px] lg:text-7xl leading-[1.08em] lg:leading-[1.08em] font-semibold text-center mb-6 md:mb-12">
Chains & Projects<br />Using Blockscout
</h1>
<div className="flex flex-col w-full lg:w-[860px] mb-6 md:mb-[70px]">
<SearchBar onSearch={setSearchTerm} />
<PopularEcosystems
ecosystems={popularEcosystems}
selectedEcosystems={filters.ecosystems}
onSelect={handleEcosystemSelect}
/>
</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>
<Filters
filters={filters}
setFilters={setFilters}
ecosystems={ecosystems}
appliedFiltersCount={appliedFiltersCount}
sortOption={sortOption}
setSortOption={setSortOption}
/>
</div>
<Filters
<AddChainSection />
<ChainList
chains={sortedAndFilteredChains}
searchTerm={searchTerm}
isLoading={isLoading}
filters={filters}
setFilters={setFilters}
ecosystems={ecosystems}
appliedFiltersCount={appliedFiltersCount}
featuredChains={featuredChains}
/>
</div>
<ChainList
chains={Object.fromEntries(filteredChains)}
searchTerm={searchTerm}
isLoading={isLoading}
filters={filters}
/>
</div>
</main>
);
Expand Down
22 changes: 22 additions & 0 deletions components/AddChainSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import Link from 'next/link';

const AddChainSection: React.FC = () => {
return (
<div className="w-full py-5 md:py-2 px-4 bg-[#f7f8fd] border border-[#f2f4fc] rounded-lg flex flex-col md:flex-row items-center justify-center gap-4 mb-6">
<p className="text-[#1d1d1f] text-sm text-center">
Does your network use Blockscout but isn&apos;t listed here? Add your chain now!
</p>
<Link
href="https://github.com/blockscout/chainscout?tab=readme-ov-file#contributing"
target="_blank"
rel="noopener noreferrer"
className="flex items-center h-[34px] border border-[#2563eb] text-[13px] text-[#2563eb] font-semibold px-3 rounded-lg hover:opacity-75 transition-opacity duration-[400ms]"
>
Add your Chain
</Link>
</div>
);
};

export default AddChainSection;
62 changes: 36 additions & 26 deletions components/ChainCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChainData } from '@/types';
import Image from 'next/image';
import Link from 'next/link';
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' },
Expand All @@ -28,33 +29,41 @@ export default function ChainCard({
website,
logo,
ecosystem,
}: ChainData & { chainId: string }) {
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];

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="flex justify-between items-start mb-6">
<div className="flex justify-between items-center mb-6">
<span
className="inline-flex items-center px-2 py-1 rounded text-sm font-medium"
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>
</div>
<div className="flex items-center mb-4 gap-3">
<div className="w-14 h-14 flex-shrink-0">
{featured && (
<Image
src={logo}
alt={`${name} logo`}
width={56}
height={56}
className="rounded-lg"
src="/star.svg"
alt="Featured Chain"
width={24}
height={24}
className="flex-shrink-0"
/>
</div>
<h3 className="text-[22px] font-semibold text-gray-900">{name}</h3>
)}
</div>
<div className="flex items-center mb-4 gap-3">
<Image
src={logo}
alt={`${name} logo`}
width={56}
height={56}
className="rounded-lg w-[48px] h-[48px] md:w-[56px] md:h-[56px] flex-shrink-0"
/>
<h3 className="text-xl md:text-[22px] font-semibold text-gray-900">{name}</h3>
</div>
<div className="flex flex-col flex-1 relative">
<p className="text-gray-600 mb-[60px] flex-1">{description}</p>
Expand All @@ -69,19 +78,20 @@ export default function ChainCard({

{/* Hover effect block */}
<div className="absolute inset-0 bg-white flex flex-col justify-end opacity-0 translate-y-4 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300 ease-in-out">
<Link href={website} className="text-black hover:text-blue-600 mb-4 flex items-center justify-between" target="_blank" rel="noopener noreferrer">
Project Website
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
<div className="border-t border-gray-200 mb-4"></div>
<Link href={url} className="text-black hover:text-blue-600 flex items-center justify-between" target="_blank" rel="noopener noreferrer">
Blockscout Explorer
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
{[
{ href: website, text: 'Project Website' },
{ href: url, text: 'Blockscout Explorer' },
].map(({ href, text }, index, array) => (
<>
<Link href={href} className="group/link flex items-center justify-between py-3" target="_blank" rel="noopener noreferrer">
<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]"/>
</Link>
{index < array.length - 1 && <div className="border-t border-gray-200 my-3"></div>}
</>
))}
</div>
</div>
</div>
Expand Down
20 changes: 11 additions & 9 deletions components/ChainList.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,45 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Chains } from '@/types';
import { ChainData } from '@/types';
import ChainCard from '@/components/ChainCard';
import SkeletonCard from '@/components/SkeletonCard';
import Pagination from '@/components/Pagination';

type Props = {
chains: Chains;
chains: Array<[string, ChainData]>;
searchTerm: string;
isLoading: boolean;
filters: {
hosting: string[];
networkTypes: string[];
ecosystems: string[];
};
featuredChains: string[];
};

const ITEMS_PER_PAGE = 16;

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

const currentChains = useMemo(() => {
const chainsArray = [...chains];
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return Object.entries(chains).slice(startIndex, startIndex + ITEMS_PER_PAGE);
return chainsArray.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [chains, currentPage]);

const totalPages = Math.ceil(Object.keys(chains).length / ITEMS_PER_PAGE);
const totalPages = Math.ceil(chains.length / ITEMS_PER_PAGE);

const handlePageChange = (page: number) => {
setCurrentPage(page);
};

useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filters]);
}, [searchTerm, filters, chains]);

if (isLoading) {
return (
<div className="w-full mt-4 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(16)].map((_, index) => (
<SkeletonCard key={index} />
))}
Expand All @@ -47,9 +49,9 @@ export default function ChainList({ chains, searchTerm, isLoading, filters }: Pr

return (
<>
<div className="w-full mt-4 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="w-full grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{currentChains.map(([chainId, data]) => (
<ChainCard key={chainId} chainId={chainId} {...data} />
<ChainCard key={chainId} chainId={chainId} featured={featuredChains.includes(chainId)} {...data} />
))}
</div>
<Pagination
Expand Down
Loading

0 comments on commit 338f8bd

Please sign in to comment.