mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-11 07:08:07 -05:00
feat: General fixes 4 (#388)
* feat: general fixes wip * 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) * feat: "new articles" use new blog card component * feat: make articles card link more visible
This commit is contained in:
@@ -43,5 +43,5 @@ export async function generateMetadata({
|
||||
export default async function ProjectDetailPage({ params }: PageProps) {
|
||||
const lang = params?.lang as LocaleTypes
|
||||
|
||||
return <ProjectContent lang={lang} id={params?.id} />
|
||||
return <ProjectContent lang={lang} id={params?.id?.toLowerCase()} />
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ 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,
|
||||
lang = "en",
|
||||
@@ -231,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>
|
||||
|
||||
|
||||
97
app/api/youtube/videos/route.ts
Normal file
97
app/api/youtube/videos/route.ts
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useRouter } from "next/navigation"
|
||||
export const ArticleListCard = ({
|
||||
lang,
|
||||
article,
|
||||
lineClamp = false,
|
||||
}: {
|
||||
lang: string
|
||||
article: Article
|
||||
lineClamp?: boolean
|
||||
}) => {
|
||||
const url = `/${lang}/blog/${article.id}`
|
||||
|
||||
@@ -21,10 +23,15 @@ export const ArticleListCard = ({
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tldr = lineClamp
|
||||
? (article.tldr || "").replace(/\n/g, " ").substring(0, 120) +
|
||||
(article.tldr && article.tldr.length > 120 ? "..." : "")
|
||||
: article.tldr || ""
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="flex-1 w-full group cursor-pointer"
|
||||
className="full group cursor-pointer hover:scale-105 duration-300"
|
||||
onClick={() => {
|
||||
router.push(url)
|
||||
}}
|
||||
@@ -88,7 +95,7 @@ export const ArticleListCard = ({
|
||||
img: ({ src, alt }) => null,
|
||||
}}
|
||||
>
|
||||
{article.tldr || ""}
|
||||
{tldr}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Markdown } from "../ui/markdown"
|
||||
import { BlogArticleCard } from "./blog-article-card"
|
||||
import { BlogArticleRelatedProjects } from "./blog-article-related-projects"
|
||||
import { LocaleTypes } from "@/app/i18n/settings"
|
||||
import { ArticleListCard } from "./article-list-card"
|
||||
|
||||
interface BlogContentProps {
|
||||
post: Article
|
||||
@@ -53,6 +54,11 @@ export function BlogContent({ post, lang }: BlogContentProps) {
|
||||
<Markdown>{post?.content ?? ""}</Markdown>
|
||||
</div>
|
||||
|
||||
<BlogArticleRelatedProjects
|
||||
projectsIds={post.projects ?? []}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
{moreArticles?.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -66,50 +72,20 @@ export function BlogContent({ post, lang }: BlogContentProps) {
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{moreArticles.map(
|
||||
({
|
||||
id,
|
||||
title,
|
||||
image,
|
||||
tldr = "",
|
||||
date,
|
||||
content,
|
||||
authors,
|
||||
}: Article) => {
|
||||
const url = `/blog/${id}`
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex flex-col min-h-[400px] w-full"
|
||||
>
|
||||
<Link
|
||||
href={url}
|
||||
className="flex h-full w-full group hover:opacity-90 transition-opacity duration-300 rounded-xl overflow-hidden bg-white shadow-sm border border-slate-900/10"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<BlogArticleCard
|
||||
id={id}
|
||||
image={image}
|
||||
title={title}
|
||||
date={date}
|
||||
content={content}
|
||||
authors={authors}
|
||||
tldr={tldr}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
<div className="flex flex-col gap-10">
|
||||
{moreArticles.map((article: Article) => {
|
||||
return (
|
||||
<ArticleListCard
|
||||
key={article.id}
|
||||
lang={lang}
|
||||
article={article}
|
||||
lineClamp
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BlogArticleRelatedProjects
|
||||
projectsIds={post.projects ?? []}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
</AppContent>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
89
components/project/project-team.tsx
Normal file
89
components/project/project-team.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -83,14 +83,14 @@ export default function ProjectCard({
|
||||
projectCardVariants({ showLinks, border, className })
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(`/projects/${id}`)
|
||||
router.push(`/projects/${id?.toLowerCase()}`)
|
||||
}}
|
||||
>
|
||||
{showBanner && (
|
||||
<div
|
||||
className="relative flex flex-col border-b border-black/10 cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push(`/projects/${id}`)
|
||||
router.push(`/projects/${id?.toLowerCase()}`)
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/hooks/useGlobalSearch"
|
||||
import { CategoryTag } from "../ui/categoryTag"
|
||||
import { Markdown } from "../ui/markdown"
|
||||
import React from "react"
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
@@ -69,7 +70,9 @@ function Hit({
|
||||
const content = hit.content || hit.description || hit.excerpt || ""
|
||||
|
||||
const snippet =
|
||||
content.length > 150 ? content.substring(0, 150) + "..." : content
|
||||
content.length > 200
|
||||
? content.substring(0, 200).replace(/\S+$/, "") + "..."
|
||||
: content
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -91,11 +94,26 @@ function Hit({
|
||||
h5: ({ children }) => null,
|
||||
h6: ({ children }) => null,
|
||||
img: ({ src, alt }) => null,
|
||||
a: ({ href, children }) => (
|
||||
<span className="text-tuatara-700 font-sans text-base font-normal">
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
a: ({ href, children }) => {
|
||||
let textContent = ""
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (typeof child === "string") {
|
||||
textContent += child
|
||||
} else if (React.isValidElement(child)) {
|
||||
const childText = child.props?.children
|
||||
if (typeof childText === "string") {
|
||||
textContent += childText
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span className="text-tuatara-700 font-sans text-base font-normal">
|
||||
{textContent}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
|
||||
126
components/sections/ProjectYouTubeVideos.tsx
Normal file
126
components/sections/ProjectYouTubeVideos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -37,14 +37,16 @@ const SideNavigationItem = ({
|
||||
<li
|
||||
key={id}
|
||||
className={cn(
|
||||
"flex min-h-8 items-center border-l-2 border-l-anakiwa-200 px-2 duration-200 cursor-pointer w-full",
|
||||
"flex min-h-8 items-center border-l-2 border-l-anakiwa-200 px-2 duration-200 cursor-pointer w-full pb-2",
|
||||
{
|
||||
"border-l-anakiwa-500 text-anakiwa-500 font-medium":
|
||||
activeSection === id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={onClick}>{text}</button>
|
||||
<button onClick={onClick} className="text-left">
|
||||
{text}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -156,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
|
||||
|
||||
@@ -193,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"
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -100,6 +100,51 @@ This is the result
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
## 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 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.
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 you’re interested in collaborating, contributing research, or running your own Plasma Fold client, we’d 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",
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ProjectInterface } from "./types"
|
||||
|
||||
export const getProjectById = (id: string | number, lang = "en") => {
|
||||
const project: ProjectInterface =
|
||||
projects.filter((project) => String(project.id) === id)[0] ?? {}
|
||||
projects.filter((project) => String(project.id?.toLowerCase()) === id)[0] ??
|
||||
{}
|
||||
|
||||
const content = project?.content?.[lang]
|
||||
|
||||
|
||||
11
lib/types.ts
11
lib/types.ts
@@ -113,9 +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[]
|
||||
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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
|
||||
BIN
public/project/example-project-team.png
Normal file
BIN
public/project/example-project-team.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
BIN
public/project/example-project-video.png
Normal file
BIN
public/project/example-project-video.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -132,8 +132,14 @@ export const filterProjects = ({
|
||||
searchPattern = "",
|
||||
activeFilters = {},
|
||||
findAnyMatch = false,
|
||||
projects: projectList = projects,
|
||||
projects: projectListItems = projects,
|
||||
}: SearchMatchByParamsProps) => {
|
||||
const projectList = projectListItems.map((project: any) => {
|
||||
return {
|
||||
...project,
|
||||
id: project?.id?.toLowerCase(), // force lowercase id
|
||||
}
|
||||
})
|
||||
// keys that will be used for search
|
||||
const keys = [
|
||||
"name",
|
||||
@@ -225,7 +231,12 @@ const sortProjectByFn = ({
|
||||
return sortedProjectList.filter((project) => project.category === category)
|
||||
}
|
||||
|
||||
return sortedProjectList
|
||||
return sortedProjectList.map((project: any) => {
|
||||
return {
|
||||
id: project?.id?.toLowerCase(), // force lowercase id
|
||||
...project,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useProjectFiltersState = create<
|
||||
@@ -320,6 +331,7 @@ export const useProjectFiltersState = create<
|
||||
projects: sortProjectByFn({
|
||||
projects: filteredProjects,
|
||||
sortBy: state.sortBy,
|
||||
ignoreCategories: [], // when filtering, show all projects regardless of category
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user