feat: new Ecosystem section (#587)

* feat: add new Ecosystem section

Add new Ecosystem page with ecosystem cards and list components.
Includes INCO report and sorting functionality.

* fix: updated private transfers link
This commit is contained in:
Andy
2025-12-16 16:01:20 -06:00
committed by GitHub
parent fb454ace35
commit 820b50f30d
7 changed files with 512 additions and 4 deletions

View File

@@ -0,0 +1,47 @@
import { LABELS } from "@/app/labels"
import { AppLink } from "@/components/app-link"
import { Icons } from "@/components/icons"
import { AppContent } from "@/components/ui/app-content"
import { Button } from "@/components/ui/button"
import { Metadata } from "next"
import { EcosystemList } from "@/components/ecosystem/ecosystem-list"
export const metadata: Metadata = {
title: "Ecosystem",
description:
"Explore our ecosystem exploration artifacts, from reports to maps.",
}
const EcosystemPage = async () => {
return (
<div className="flex flex-col gap-10 pb-[128px] ">
<AppContent className="flex flex-col gap-4 py-10 w-full">
<div className="flex flex-col gap-10">
<h1 className="dark:text-tuatara-100 text-tuatara-950 text-xl lg:text-3xl font-normal font-sans text-center">
{LABELS.ECOSYSTEM_PAGE.TITLE}
</h1>
<div className="lg:!w-4/5 w-full flex lg:flex-row flex-col items-center gap-3 lg:gap-10 mx-auto justify-center">
<span className="text-base lg:text-xl dark:text-tuatara-200 text-tuatara-950 font-sans text-center">
{LABELS.ECOSYSTEM_PAGE.SUBTITLE}
</span>
<AppLink variant="button" href="/about">
<Button
icon={Icons.arrowRight}
iconPosition="right"
className="uppercase"
>
{LABELS.COMMON.LEARN_MORE}
</Button>
</AppLink>
</div>
</div>
</AppContent>
<AppContent className="flex flex-col gap-10">
<EcosystemList />
</AppContent>
</div>
)
}
export default EcosystemPage

View File

@@ -21,6 +21,7 @@ export const LABELS = {
RESEARCH: "Research",
ABOUT: "About",
BLOG: "Blog",
ECOSYSTEM: "Ecosystem",
},
FOOTER: {
DESCRIPTION:
@@ -244,6 +245,14 @@ export const LABELS = {
ACTIVE_RESEARCH: "Active Research",
PAST_RESEARCH: "Past Research",
},
ECOSYSTEM_PAGE: {
TITLE: "Explore Our Ecosystem",
SUBTITLE:
"Explore our ecosystem exploration artifacts, from reports to maps.",
ARTIFACTS: "Ecosystem Artifacts",
EXTERNAL_RESEARCH_REPORTS:
"Explore ecosystem research reports done by other teams outside PSE",
},
RESOURCES_PAGE: {
TITLE: "Resources",
SUBTITLE:

View File

@@ -0,0 +1,195 @@
import { cn } from "@/lib/utils"
import { VariantProps, cva } from "class-variance-authority"
import Image from "next/image"
import Link from "next/link"
import React from "react"
export interface EcosystemItem {
id: string
title: string
description: string
image?: string
imageAlt?: string
href?: string
type?: "report" | "map" | "other"
date?: string
team?: string
cardTags?: {
primary?: string
secondary?: string
wip?: boolean
}
}
interface EcosystemCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof ecosystemCardVariants> {
item: EcosystemItem
showBanner?: boolean
}
const tagCardVariants = cva(
"text-xs font-sans text-white rounded-[3px] py-[2px] px-[6px] dark:text-black",
{
variants: {
variant: {
primary: "bg-[#D8FEA8]",
secondary: "bg-[#C2E8F5]",
team: "bg-[#E8D5FF]",
wip: "bg-[#FFB84D] font-semibold",
},
},
}
)
const ecosystemCardVariants = cva(
"flex flex-col overflow-hidden rounded-lg transition duration-200 ease-in border border-transparent h-full",
{
variants: {
showBanner: {
true: "min-h-[280px]",
false: "min-h-[200px]",
},
border: {
true: "border border-slate-900/20 dark:border-anakiwa-800",
},
},
}
)
export default function EcosystemCard({
item,
showBanner = true,
border = false,
className,
}: EcosystemCardProps) {
const { id, title, description, image, imageAlt, href, date, team, cardTags } = item
// Parse teams - split by comma and trim whitespace
const teams = team
? team
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0)
: []
const cardContent = (
<>
{showBanner && (
<div className="relative flex flex-col border-b border-black/10">
<Image
src={image ? image : "/project-banners/fallback.webp"}
alt={imageAlt || title}
width={1200}
height={630}
className="h-[160px] w-full overflow-hidden rounded-t-lg border-none object-cover"
/>
{!image && (
<span className="absolute w-full px-5 text-xl font-bold text-center text-black -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2">
{imageAlt || title}
</span>
)}
{/* Teams and WIP tag in top-right corner of banner */}
{(teams.length > 0 || cardTags?.wip) && (
<div className="absolute top-2 right-2 flex items-center gap-1 flex-wrap justify-end">
{cardTags?.wip && (
<div className={tagCardVariants({ variant: "wip" })}>
WIP
</div>
)}
{teams.map((teamName, index) => (
<div key={index} className={tagCardVariants({ variant: "team" })}>
{teamName}
</div>
))}
</div>
)}
</div>
)}
<div className="flex flex-col justify-between gap-4 p-4 bg-white rounded-b-lg dark:bg-black relative">
{/* Teams and WIP tag in top-right corner when no banner */}
{!showBanner && (teams.length > 0 || cardTags?.wip) && (
<div className="absolute top-4 right-4 flex items-center gap-1 flex-wrap justify-end">
{cardTags?.wip && (
<div className={tagCardVariants({ variant: "wip" })}>
WIP
</div>
)}
{teams.map((teamName, index) => (
<div key={index} className={tagCardVariants({ variant: "team" })}>
{teamName}
</div>
))}
</div>
)}
<div className="flex flex-col justify-start gap-2 flex-1">
<h3 className="text-2xl font-bold leading-7 text-primary duration-200 cursor-pointer hover:text-anakiwa-500">
{title}
</h3>
{date && (
<p className="text-sm text-tuatara-400 dark:text-tuatara-400">
{date}
</p>
)}
{description?.length > 0 && (
<div className="flex flex-col gap-4">
<p className="text-tuatara-500 text-base line-clamp-4 dark:text-tuatara-200">
{description}
</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 mt-auto">
<div className="flex justify-between items-center">
{cardTags && (
<div className="flex items-center gap-1 flex-wrap">
{cardTags.primary && (
<div className={tagCardVariants({ variant: "primary" })}>
{cardTags.primary}
</div>
)}
{cardTags.secondary && (
<div className={tagCardVariants({ variant: "secondary" })}>
{cardTags.secondary}
</div>
)}
{cardTags.wip && (
<div className={tagCardVariants({ variant: "wip" })}>
WIP
</div>
)}
</div>
)}
</div>
</div>
</div>
</>
)
if (href) {
return (
<Link
href={href}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
className={cn(
"group cursor-pointer",
ecosystemCardVariants({ showBanner, border, className })
)}
>
{cardContent}
</Link>
)
}
return (
<div
className={cn(
"group",
ecosystemCardVariants({ showBanner, border, className })
)}
>
{cardContent}
</div>
)
}

View File

@@ -0,0 +1,255 @@
"use client"
import React from "react"
import EcosystemCard, { EcosystemItem } from "./ecosystem-card"
import { SectionWrapper } from "@/app/components/wrappers/SectionWrapper"
import { LABELS } from "@/app/labels"
import { useState } from "react"
const MOCK_ECOSYSTEM_ITEMS: EcosystemItem[] = [
{
id: "state-of-private-voting-2026",
title: "State of Private Voting 2026",
description:
"An in-depth analysis of private voting protocols in the Ethereum ecosystem.",
type: "report",
href: "https://pse.dev/articles/state-of-private-voting-2026/state-of-private-voting-2026.pdf",
image: "/images/ecosystem/state-of-private-voting-2026.png",
imageAlt: "State of Private Voting 2026",
date: "November 2025",
team: "PSE, Shutter",
cardTags: {
primary: "Report",
secondary: "Voting",
},
},
{
id: "private-transfers",
title: "Private Transfers",
description:
"Research and documentation on private transfer protocols and mechanisms in the Ethereum ecosystem, exploring privacy-preserving transaction technologies.",
type: "report",
href: "hhttps://publish.obsidian.md/private-writes/Private+Writes+Vault",
date: "December 2025",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "Private Transfers",
wip: true,
},
},
{
id: "navigating-privacy-compliance-blockchain",
title: "Navigating Privacy and Compliance in Blockchain Systems",
description:
"An analysis of privacy-preserving technologies and compliance considerations in blockchain systems, exploring the intersection of cryptographic privacy and regulatory requirements.",
type: "report",
href: "https://drive.google.com/file/d/1eeCZbLfKP3nxLECaCVlPinva948V9dKX/view",
date: "December 2025",
team: "Inco, Predicate",
cardTags: {
primary: "Report",
secondary: "Privacy",
},
},
{
id: "research-brief-identity",
title: "Research Brief: Identity",
description:
"A research brief exploring identity systems, privacy-preserving identity solutions, and identity protocols in the Ethereum ecosystem.",
type: "report",
href: "https://pse-team.notion.site/Research-Brief-Identity-4e819dd75a5d4137a4a639080ccbe5a4",
date: "March 2024",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "Identity",
},
},
{
id: "pse-identity-research",
title: "PSE Identity Research",
description:
"Comprehensive research on identity systems, privacy-preserving identity protocols, and identity solutions for the Ethereum ecosystem.",
type: "report",
href: "https://pse-team.notion.site/PSE-Identity-Research-30fc197196494fcdb5f4117c6c734b53?pvs=74",
date: "June 2024",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "Identity",
},
},
{
id: "evaluation-framework-ssi-solutions",
title: "Evaluation Framework for SSI Solutions",
description:
"A comprehensive evaluation framework for assessing Self-Sovereign Identity (SSI) solutions, providing standardized criteria and methodologies for comparing identity systems.",
type: "report",
href: "https://pse-team.notion.site/Evaluation-Framework-for-SSI-Solutions-8eceb793a5b442cb8da65acc3c337d5c",
date: "July 2024",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "Identity",
},
},
{
id: "l2s-report",
title: "L2s Report",
description:
"A comprehensive analysis of Layer 2 scaling solutions for Ethereum, covering rollups, state channels, and other scaling technologies.",
type: "report",
href: "https://cdn.prod.website-files.com/6728e9076a3b5a8ca8ec4816/6931c20f55129e498a8da223_%5BCompressed%5D%20L2s%20Report.pdf",
date: "November 2025",
team: "Etherealize",
cardTags: {
primary: "Report",
secondary: "Layer 2",
},
},
{
id: "open-application-driven-fhe-ethereum",
title: "Open, Application-Driven FHE for Ethereum",
description:
"A comprehensive analysis of Fully Homomorphic Encryption (FHE) for Ethereum, covering use cases, ecosystem players, scheme choices, verifiability, and key management challenges.",
type: "report",
href: "https://ethresear.ch/t/open-application-driven-fhe-for-ethereum/23044",
date: "September 2025",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "FHE",
},
},
{
id: "combined-ssi-research",
title: "Combined SSI Research",
description:
"Comprehensive research on Self-Sovereign Identity (SSI) solutions, covering identity systems, privacy-preserving protocols, and decentralized identity technologies.",
type: "report",
href: "https://pse-team.notion.site/Combined-SSI-research-f7bc6d108f76445bae372abd2b411363?pvs=74",
date: "September 2024",
team: "PSE",
cardTags: {
primary: "Report",
secondary: "Identity",
},
},
]
// Helper function to parse date strings like "November 2025" or "December 2025"
function parseDate(dateString: string | undefined): Date {
if (!dateString) return new Date(0) // Return epoch for items without dates (will sort last)
const months: Record<string, number> = {
january: 0,
february: 1,
march: 2,
april: 3,
may: 4,
june: 5,
july: 6,
august: 7,
september: 8,
october: 9,
november: 10,
december: 11,
}
const parts = dateString.trim().toLowerCase().split(" ")
if (parts.length >= 2) {
const monthName = parts[0]
const year = parseInt(parts[1], 10)
const month = months[monthName] ?? 0
if (!isNaN(year)) {
return new Date(year, month, 1)
}
}
return new Date(0) // Return epoch if parsing fails
}
// Sort function to order by date (most recent first)
function sortByDateDescending(a: EcosystemItem, b: EcosystemItem): number {
const dateA = parseDate(a.date)
const dateB = parseDate(b.date)
return dateB.getTime() - dateA.getTime()
}
export const EcosystemList = () => {
const [ecosystemItems] = useState<EcosystemItem[]>(MOCK_ECOSYSTEM_ITEMS)
// Filter items for PSE reports (reports that include PSE in team)
const pseReports = ecosystemItems
.filter((item) => {
if (!item.team) return false
// Check if team includes "PSE" (case-insensitive)
const teamLower = item.team.toLowerCase()
return teamLower.includes("pse")
})
.sort(sortByDateDescending)
// Filter items for external research reports (reports not from PSE)
const externalResearchReports = ecosystemItems
.filter((item) => {
if (item.type !== "report") return false
if (!item.team) return false
// Check if team doesn't include "PSE" (case-insensitive)
const teamLower = item.team.toLowerCase()
return !teamLower.includes("pse")
})
.sort(sortByDateDescending)
if (ecosystemItems.length === 0) {
return (
<div className="flex flex-col gap-2 pt-24 pb-40 text-center">
<span className="text-2xl font-bold font-display text-primary">
{LABELS.COMMON.NO_RESULTS}
</span>
<span className="text-lg font-normal text-primary">
{LABELS.COMMON.NO_RESULTS_DESCRIPTION}
</span>
</div>
)
}
return (
<div className="relative grid items-start justify-between grid-cols-1">
<div className="flex flex-col justify-between gap-10">
<SectionWrapper title={LABELS.ECOSYSTEM_PAGE.ARTIFACTS.toUpperCase()}>
<div className="grid grid-cols-1 gap-4 md:gap-x-6 md:gap-y-10 lg:grid-cols-4 items-stretch">
{pseReports.map((item: EcosystemItem) => (
<EcosystemCard
key={item.id}
item={item}
showBanner={true}
border
/>
))}
</div>
</SectionWrapper>
{externalResearchReports.length > 0 && (
<div className="flex flex-col gap-10 justify-center dark:bg-anakiwa-975 py-16 lg:py-20 overflow-hidden bg-cover-gradient dark:bg-none">
<span className="dark:text-tuatara-100 text-tuatara-950 text-xl lg:text-3xl lg:leading-[45px] font-normal font-sans text-center lg:px-0 px-4">
{LABELS.ECOSYSTEM_PAGE.EXTERNAL_RESEARCH_REPORTS}
</span>
<div className="lg:px-4 px-4">
<div className="grid grid-cols-1 gap-4 md:gap-x-6 md:gap-y-10 lg:grid-cols-4 items-stretch">
{externalResearchReports.map((item: EcosystemItem) => (
<EcosystemCard
key={item.id}
item={item}
showBanner={true}
border
/>
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -39,9 +39,8 @@ export const ProjectList = () => {
const SCROLL_OFFSET = -400
const [activeId, setActiveId] = useState("")
const [isManualScroll, setIsManualScroll] = useState(false)
const [isMounted, setIsMounted] = useState(false)
const { projects, searchQuery, activeFilters } = useProjects()
const { projects, searchQuery, activeFilters, isLoading } = useProjects()
const hasSearchParams =
searchQuery?.length > 0 ||
Object.values({
@@ -55,7 +54,6 @@ export const ProjectList = () => {
const sectionsRef = useRef<NodeListOf<HTMLElement> | null>(null) // sections are constant so useRef might be better here
useEffect(() => {
setIsMounted(true)
if (typeof window !== "undefined") {
sectionsRef.current = document.querySelectorAll("div[data-section]")
@@ -94,7 +92,7 @@ export const ProjectList = () => {
}, [])
// loading state skeleton
if (!isMounted) {
if (isLoading) {
return (
<div className="grid items-start justify-between w-full grid-cols-1 gap-2 md:grid-cols-4 md:gap-6">
<div className="min-h-[380px] border border-gray-200 rounded-lg overflow-hidden">

View File

@@ -15,6 +15,10 @@ export function useAppSettings() {
title: LABELS.COMMON.MENU.RESEARCH,
href: "/research",
},
{
title: LABELS.COMMON.MENU.ECOSYSTEM,
href: "/ecosystem",
},
{
title: LABELS.COMMON.MENU.BLOG,
href: "/blog",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB