mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-06 21:03:56 -05:00
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:
47
app/(pages)/ecosystem/page.tsx
Normal file
47
app/(pages)/ecosystem/page.tsx
Normal 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
|
||||
@@ -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:
|
||||
|
||||
195
components/ecosystem/ecosystem-card.tsx
Normal file
195
components/ecosystem/ecosystem-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
components/ecosystem/ecosystem-list.tsx
Normal file
255
components/ecosystem/ecosystem-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/images/ecosystem/state-of-private-voting-2026.png
Normal file
BIN
public/images/ecosystem/state-of-private-voting-2026.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Reference in New Issue
Block a user