feat: complete general fixes

- align side wiki
- Enforce kebab-case on URLs (fix #309)
- Search result don’t render markdown (fix #362)
- Move “related projects” before “more articles”in blog page
- Display research projects on projects page (fix #387)
- Add support for youtube videos for projects (fix #384)
- Add support for team member for projects (fix #385)
This commit is contained in:
Kalidou Diagne
2025-05-12 10:32:08 +02:00
parent 8a2ceee5c1
commit c2b8bced8e
19 changed files with 543 additions and 56 deletions

View File

@@ -20,6 +20,8 @@ import { WikiSideNavigation } from "@/components/wiki-side-navigation"
import { useTranslation } from "@/app/i18n/client"
import { LocaleTypes } from "@/app/i18n/settings"
import { ProjectBlogArticles } from "@/components/blog/project-blog-articles"
import { ProjectYouTubeVideos } from "@/components/sections/ProjectYouTubeVideos"
import { ProjectTeamMembers } from "@/components/project/project-team"
export const ProjectContent = ({
id,
@@ -232,8 +234,25 @@ export const ProjectContent = ({
{content?.description}
</Markdown>
)}
{project?.youtubeLinks &&
project.youtubeLinks.length > 0 && (
<div className="mt-5">
<ProjectYouTubeVideos
youtubeLinks={project.youtubeLinks}
lang={lang}
/>
</div>
)}
{project?.team && project.team.length > 0 && (
<div className="mt-5">
<ProjectTeamMembers team={project.team} lang={lang} />
</div>
)}
<ProjectTags project={project} lang={lang} />
</div>
<ProjectExtraLinks project={project} lang={lang} />
</div>

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server"
interface YoutubeVideoResponse {
items: {
id: string
snippet: {
title: string
description: string
publishedAt: string
channelTitle: string
thumbnails: {
high: {
url: string
}
standard?: {
url: string
}
maxres?: {
url: string
}
}
}
}[]
}
// Helper function to add CORS headers
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
}
// Handle OPTIONS requests for CORS preflight
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders() })
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const ids = searchParams.get("ids")
if (!ids) {
return NextResponse.json(
{ error: "No video IDs provided" },
{ status: 400, headers: corsHeaders() }
)
}
// API key should be in env variables
const apiKey = process.env.YOUTUBE_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: "YouTube API key not configured" },
{ status: 500, headers: corsHeaders() }
)
}
const youtubeApiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${ids}&key=${apiKey}`
const response = await fetch(youtubeApiUrl)
if (!response.ok) {
throw new Error(`YouTube API responded with status: ${response.status}`)
}
const data: YoutubeVideoResponse = await response.json()
const formattedVideos = data.items.map((item) => {
// Get best available thumbnail
const thumbnailUrl =
item.snippet.thumbnails.maxres?.url ||
item.snippet.thumbnails.standard?.url ||
item.snippet.thumbnails.high.url
return {
id: item.id,
title: item.snippet.title,
description: item.snippet.description,
thumbnailUrl,
publishedAt: item.snippet.publishedAt,
channelTitle: item.snippet.channelTitle,
}
})
return NextResponse.json(formattedVideos, { headers: corsHeaders() })
} catch (error) {
console.error("Error fetching YouTube videos:", error)
return NextResponse.json(
{ error: "Failed to fetch videos from YouTube API" },
{ status: 500, headers: corsHeaders() }
)
}
}

View File

@@ -99,5 +99,9 @@
"joinOurDiscord": "Join our discord",
"prevBrandImage": "Previous branding",
"editThisPage": "Edit this page",
"contents": "Contents"
"contents": "Contents",
"projectVideos": "Project Videos",
"errorLoadingVideos": "Error loading videos",
"projectTeam": "Team",
"youtubeVideos": "YouTube Videos"
}

View File

@@ -11,6 +11,21 @@ export type Icon = LucideIcon
export const Icons = {
sun: SunMedium,
email: (props: LucideProps) => (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
),
time: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -0,0 +1,89 @@
import Image from "next/image"
import Link from "next/link"
import { ProjectTeamMember, ProjectLinkWebsite } from "@/lib/types"
import { Icons } from "../icons"
import { useTranslation } from "@/app/i18n/client"
import { LocaleTypes } from "@/app/i18n/settings"
import { ProjectLinkIconMap } from "../mappings/project-links"
interface ProjectTeamMembersProps {
team: ProjectTeamMember[]
lang: LocaleTypes
}
export const ProjectTeamMembers = ({ team, lang }: ProjectTeamMembersProps) => {
const { t } = useTranslation(lang, "common")
if (!team || team.length === 0) {
return null
}
return (
<div className="w-full" id="team" data-section-id="team">
<h2 className="text-[22px] font-bold text-tuatara-700 mb-6">
{t("projectTeam") || "Project Team"}
</h2>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{team.map((member, index) => (
<div
key={`${member.name}-${index}`}
className="flex flex-col gap-2 p-4 border border-black/10 rounded-md bg-white"
>
<div className="flex items-center gap-4">
{member.image ? (
<div className="relative h-16 w-16 rounded-full overflow-hidden flex-shrink-0">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
/>
</div>
) : (
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-slate-200 flex-shrink-0"></div>
)}
<div className="flex flex-col">
<h3 className="font-sans text-base font-medium text-tuatara-900">
{member.name}
</h3>
{member.role && (
<p className="font-sans text-sm text-tuatara-600">
{member.role}
</p>
)}
</div>
</div>
{member.email && (
<a
href={`mailto:${member.email}`}
className="font-sans text-sm text-tuatara-600 hover:text-orange transition-colors mt-1 flex items-center gap-1.5"
>
<Icons.email className="h-4 w-4" />
<span>{member.email}</span>
</a>
)}
{member.links && Object.keys(member.links).length > 0 && (
<div className="flex flex-wrap gap-1">
{Object.entries(member.links).map(([key, value]) => (
<Link
key={key}
href={value ?? ""}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-1.5 text-sm text-tuatara-600 hover:text-orange transition-colors"
>
{ProjectLinkIconMap?.[key as ProjectLinkWebsite]}
<span className="capitalize">{key}</span>
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
"use client"
import { useTranslation } from "@/app/i18n/client"
import Image from "next/image"
import Link from "next/link"
import { Icons } from "../icons"
import { useEffect, useState } from "react"
interface VideoCardProps {
videoId: string
}
const VideoCard = ({ videoId }: VideoCardProps) => {
const [title, setTitle] = useState<string>("")
const [isLoading, setIsLoading] = useState<boolean>(true)
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
useEffect(() => {
// Use YouTube's oEmbed API to get video title (no CORS issues)
fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
)
.then((response) => response.json())
.then((data) => {
setTitle(data.title)
setIsLoading(false)
})
.catch(() => {
// If oEmbed fails, we'll just show "YouTube Video"
setIsLoading(false)
})
}, [videoId])
return (
<Link
href={`https://www.youtube.com/watch?v=${videoId}`}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col gap-4"
>
<div className="relative overflow-hidden aspect-video rounded-sm">
<div className="absolute inset-0 bg-black opacity-20 group-hover:opacity-60 transition-opacity duration-300 z-10"></div>
<Image
src={thumbnailUrl}
alt={title || "YouTube video"}
fill
style={{ objectFit: "cover" }}
className="transition-transform duration-300 scale-105 group-hover:scale-110"
onError={(e) => {
// Fallback to medium quality if maxresdefault is not available
const target = e.target as HTMLImageElement
target.src = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
}}
/>
<div className="absolute inset-0 flex items-center justify-center z-20">
<Icons.play />
</div>
</div>
<h3 className="font-sans text-sm font-normal line-clamp-3 text-tuatara-800 group-hover:text-tuatara-600 transition-colors">
{isLoading ? (
<div className="h-5 w-full bg-slate-200 animate-pulse rounded-sm"></div>
) : (
title || "YouTube Video"
)}
</h3>
</Link>
)
}
export const ProjectYouTubeVideos = ({
youtubeLinks,
lang,
}: {
youtubeLinks: string[]
lang: string
}) => {
const { t } = useTranslation(lang, "common")
const [videoIds, setVideoIds] = useState<string[]>([])
const extractVideoId = (url: string): string => {
if (url.includes("youtube.com/watch?v=")) {
const urlObj = new URL(url)
return urlObj.searchParams.get("v") || ""
} else if (url.includes("youtu.be/")) {
return url.split("youtu.be/")[1]?.split("?")[0] || ""
} else if (/^[a-zA-Z0-9_-]{11}$/.test(url)) {
return url
}
return ""
}
useEffect(() => {
if (!youtubeLinks || youtubeLinks.length === 0) {
return
}
// Extract valid video IDs
const ids = youtubeLinks.map(extractVideoId).filter((id) => id)
setVideoIds(ids)
}, [youtubeLinks])
if (!youtubeLinks || youtubeLinks.length === 0 || videoIds.length === 0) {
return null
}
return (
<section
className="w-full py-6"
id="youtube-videos"
data-section-id="youtube-videos"
>
<div className="flex flex-col gap-6">
<h2 className="text-[22px] font-bold text-tuatara-700">
{t("projectVideos") || "Project Videos"}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{videoIds.map((videoId) => (
<VideoCard key={videoId} videoId={videoId} />
))}
</div>
</div>
</section>
)
}

View File

@@ -158,9 +158,14 @@ export const WikiSideNavigation = ({
},
}
const { extraLinks = {} } = project
const { extraLinks = {}, team = [], youtubeLinks = [] } = project
const hasRelatedArticles = articles.length > 0 && !loading
const hasTeam = Array.isArray(team) && team.length > 0
const hasYoutubeVideos =
Array.isArray(youtubeLinks) && youtubeLinks.length > 0
console.log(hasTeam, hasYoutubeVideos, youtubeLinks)
if (sections.length === 0 || content.length === 0) return null
@@ -195,6 +200,27 @@ export const WikiSideNavigation = ({
/>
)
})}
{hasYoutubeVideos && (
<SideNavigationItem
key="youtube-videos"
onClick={() => scrollToSection("youtube-videos")}
activeSection={activeSection}
text={t("youtubeVideos") || "YouTube Videos"}
id="youtube-videos"
/>
)}
{hasTeam && (
<SideNavigationItem
key="team"
onClick={() => scrollToSection("team")}
activeSection={activeSection}
text={t("projectTeam") || "Team"}
id="team"
/>
)}
{hasRelatedArticles && (
<SideNavigationItem
key="related-articles"

View File

@@ -50,7 +50,7 @@ import { privateProofDelegation } from "./projects/private-proof-delegation"
import { pod2 } from "./projects/pod2"
import { postQuantumCryptography } from "./projects/post-quantum-cryptography"
import { machinaIo } from "./projects/machina-iO"
import { plasmaFold } from "./projects/plasma_fold"
import { plasmaFold } from "./projects/plasma-fold"
import { vOPRF } from "./projects/vOPRF"
import { mpz } from "./projects/mpz"
import { clientSideProving } from "./projects/client-side-proving"
@@ -116,5 +116,5 @@ export const projects: ProjectInterface[] = [
machinaIo,
plasmaFold,
vOPRF,
mpz
mpz,
]

View File

@@ -100,6 +100,51 @@ This is the result
![Project links](/public/project/example-project-detail.jpg)
## Adding YouTube Videos to a Project
Add YouTube videos to your project by including a `youtubeLinks` array:
```js
export const projectName: ProjectInterface = {
// other properties...
youtubeLinks: [
"https://www.youtube.com/watch?v=XXXXXXXXXXX",
"https://youtu.be/XXXXXXXXXXX",
"XXXXXXXXXXX" // Just the YouTube video ID
],
}
```
The videos will appear as clickable thumbnails with titles on the project page:
![YouTube Videos](/public/project/example-project-video.png)
## Adding Team Members to a Project
Add team members to your project using the `team` array:
```js
export const projectName: ProjectInterface = {
// other properties...
team: [
{
name: "John Doe",
role: "Lead Developer",
image: "/team/john-doe.jpg", // Optional
email: "john.doe@example.com", // Optional
links: { // Optional
github: "https://github.com/johndoe",
twitter: "https://twitter.com/johndoe"
}
}
],
}
```
Supported link types: `github`, `website`, `discord`, `twitter`, `youtube`, `telegram`
![Project Team Members](/public/project/example-project-team.png)
### Project detail now supports markdown
Please note the keyword and theme is curated by the comms & design team. If you wish to change, please create a PR.

View File

@@ -1,14 +1,14 @@
import {
ProjectCategory,
ProjectContent,
ProjectInterface,
ProjectStatus,
} from "@/lib/types"
const content: ProjectContent = {
en: {
tldr: "Developing efficient zero-knowledge proving systems for mobile devices, enabling private digital ID and secure communication with minimal resources.",
description: `
ProjectCategory,
ProjectContent,
ProjectInterface,
ProjectStatus,
} from "@/lib/types"
const content: ProjectContent = {
en: {
tldr: "Developing efficient zero-knowledge proving systems for mobile devices, enabling private digital ID and secure communication with minimal resources.",
description: `
### Project Overview
The Client-Side Proving project aims to develop practical and efficient zero-knowledge (ZK) proving systems tailored specifically for mobile devices. By exploring various proving systems - including Binius, Spartan, Plonky2, Scribe, and WHIR - we provide benchmarks, insights, and optimized implementations that enable performant client-side applications.
@@ -58,25 +58,32 @@ import {
Benchmark findings and technical write-ups will be released regularly, highlighting the project's research outcomes and performance evaluations.
`,
},
}
export const clientSideProving: ProjectInterface = {
id: "client-side-proving",
category: ProjectCategory.RESEARCH,
section: "pse",
content,
projectStatus: ProjectStatus.ACTIVE,
image: "",
license: "MIT",
name: "Client-Side Proving",
team: [
{
name: "Alex Kuzmin",
email: "alex.kuzmin@pse.dev",
},
}
export const clientSideProving: ProjectInterface = {
id: "client-side-proving",
category: ProjectCategory.RESEARCH,
section: "pse",
content,
projectStatus: ProjectStatus.ACTIVE,
image: "",
license: "MIT",
name: "Client-Side Proving",
tags: {
keywords: ["Zero Knowledge", "Mobile", "Privacy", "Digital Identity"],
themes: ["build", "research"],
types: ["Legos/dev tools", "Benchmarking", "Proof systems"],
{
name: "Guorong Du",
email: "dgr009@pse.dev",
},
extraLinks: {},
}
],
tags: {
keywords: ["Zero Knowledge", "Mobile", "Privacy", "Digital Identity"],
themes: ["build", "research"],
types: ["Legos/dev tools", "Benchmarking", "Proof systems"],
},
extraLinks: {},
}

View File

@@ -56,15 +56,32 @@ MachinaIO can transform blockchain and cryptographic applications, such as trust
- **GitHub Repository:** [Machina iO](https://github.com/MachinaIO/)
- **Project Plan:** [HackMD Plan](https://hackmd.io/@MachinaIO/H1w5iwmDke)
### Contact
- **Enrico Bottazzi:** [enrico@pse.dev](mailto:enrico@pse.dev)
- **Sora Suegami:** [sorasuegami@pse.dev](mailto:sorasuegami@pse.dev)
- [**PSE Discord**](https://discord.com/invite/sF5CT5rzrR)
- On X: [@machina__io](https://x.com/machina__io) `,
`,
},
},
links: {
twitter: "https://x.com/machina__io",
github: "https://github.com/MachinaIO/",
website: "https://hackmd.io/@MachinaIO/H1w5iwmDke",
},
team: [
{
name: "Enrico Bottazzi",
email: "enrico@pse.dev",
},
{
name: "Sora Suegami",
email: "sorasuegami@pse.dev",
},
{
name: "Pia",
email: "pia@pse.dev",
},
{
name: "Pia",
email: "pia@pse.dev",
},
],
tags: {
keywords: [
"indistinguishability obfuscation",
@@ -76,6 +93,5 @@ MachinaIO can transform blockchain and cryptographic applications, such as trust
],
themes: ["cryptography", "privacy", "scalability"],
types: ["research", "development"],
},
}

View File

@@ -2,15 +2,14 @@ import { ProjectCategory, ProjectInterface, ProjectStatus } from "@/lib/types"
export const scalingSemaphore: ProjectInterface = {
id: "scaling-semaphore-pir",
image: "", // add cover image or leave blank
image: "", // add cover image or leave blank
name: "Scaling Semaphore PIR Merkle-Path Retrieval",
section: "pse",
projectStatus: ProjectStatus.ACTIVE,
category: ProjectCategory.RESEARCH,
content: {
en: {
tldr:
"Private Information Retrieval lets Semaphore users fetch their Merkle path from a server without revealing which identity they own, enabling truly private proofs for groups with millions of members.",
tldr: "Private Information Retrieval lets Semaphore users fetch their Merkle path from a server without revealing which identity they own, enabling truly private proofs for groups with millions of members.",
description: `
### Scaling Semaphore Private Merkle-Path Retrieval with PIR
@@ -59,6 +58,15 @@ Traditional PIR protocols were too heavy for on-chain use. Recent schemes—e.g.
`,
},
},
links: {
discord: "https://discord.com/invite/sF5CT5rzrR",
},
team: [
{
name: "Brechy",
email: "brechy@pse.dev",
},
],
tags: {
keywords: [
"Semaphore",

View File

@@ -53,16 +53,23 @@ Most L2s today struggle to scale without relying on increasingly expensive data
## Get Involved
Plasma Fold is part of the Privacy & Scaling Explorations initiative. If youre interested in collaborating, contributing research, or running your own Plasma Fold client, wed love to hear from you.
On X: [@xyz_pierre](https://twitter.com/xyz_pierre)
Email: Pierre Daix-Moreux [pierre@pse.dev](mailto:pierre@pse.dev), Chengru Zhang [winderica@pse.dev](mailto:winderica@pse.dev)
Join the conversation on [PSE Discord](https://discord.com/invite/sF5CT5rzrR)
`,
},
},
team: [
{
name: "Pierre Daix-Moreux",
email: "pierre@pse.dev",
links: {
twitter: "https://twitter.com/xyz_pierre",
},
},
{
name: "Chengru Zhang",
email: "winderica@pse.dev",
},
],
tags: {
keywords: [
"plasma",

View File

@@ -85,8 +85,21 @@ export const privateProofDelegation: ProjectInterface = {
image: "",
imageAlt: "Private proof delegation",
name: "Private proof delegation",
team: [
{
name: "Takamichi Tsutsumi",
email: "tkmct@pse.dev",
},
{
name: "Wanseob Lim",
email: "wanseob@pse.dev",
},
{
name: "Shouki Tsuda",
email: "shouki@pse.dev",
},
],
links: {
github:
"https://github.com/privacy-scaling-explorations/private-proof-delegation-docs",
},

View File

@@ -73,13 +73,18 @@ The protocol enables a broad range of applications requiring privacy and pseudon
## Contact
- **Magamedrasul Ibragimov:** [curryrasul@pse.dev](mailto:curryrasul@pse.dev) | [@curryrasul](https://twitter.com/curryrasul)
- **Join** the [PSE Discord](https://discord.com/invite/sF5CT5rzrR)`,
- **Join** the PSE Discord](https://discord.com/invite/sF5CT5rzrR)`,
},
},
team: [
{
name: "Magamedrasul Ibragimov",
email: "curryrasul@pse.dev",
},
],
tags: {
keywords: ["vOPRF", "nullifiers", "Web2", "privacy", "ZK proofs", "MPC"],
themes: ["privacy", "identity", "zero-knowledge proofs"],
types: ["research", "development"],
},
}

View File

@@ -113,10 +113,20 @@ export interface ProjectContent {
description: string
}
}
export interface ProjectTeamMember {
name: string
role?: string
image?: string
links?: ProjectLinkType
email?: string
}
export interface ProjectInterface {
id: string
hasWiki?: boolean // show project with wiki page template
youTubeLinks?: string[]
youtubeLinks?: string[]
team?: ProjectTeamMember[]
license?: string
content: ProjectContent // project tldr and description with support for multiple language
category?: ProjectCategory // project category used as filter to replace section

View File

@@ -25,7 +25,7 @@ const nextConfig = {
pageExtensions: ["js", "jsx", "mdx", "ts", "tsx", "md"],
reactStrictMode: true,
images: {
domains: ["i.ytimg.com"],
domains: ["i.ytimg.com", "img.youtube.com"],
},
experimental: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB