feat: graduated projects section

This commit is contained in:
Kalidou Diagne
2025-09-02 20:29:54 +08:00
parent 31763f7662
commit afa894a81c
15 changed files with 407 additions and 57 deletions

View File

@@ -1,9 +1,9 @@
import { LABELS } from "@/app/labels"
import ProjectFiltersBar from "@/components/project/project-filters-bar"
import { ProjectGraduated } from "@/components/project/project-graduated"
import { ProjectList } from "@/components/project/project-list"
import { ProjectResultBar } from "@/components/project/project-result-bar"
import { AppContent } from "@/components/ui/app-content"
import { Label } from "@/components/ui/label"
import { Metadata } from "next"
import { Suspense } from "react"
@@ -16,26 +16,27 @@ export const metadata: Metadata = {
export default async function ProjectsPage() {
return (
<div className="flex flex-col">
<AppContent className="flex flex-col gap-10 py-10 lg:py-16 w-full">
<div className="flex flex-col gap-5">
<div className="lg:w-1/2 mx-auto 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.PROJECTS_PAGE.TITLE}
</h1>
<AppContent className="flex flex-col gap-10 pt-10 pb-20 lg:py-16 w-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-10">
<div className="lg:w-1/2 mx-auto w-full">
<div className="flex flex-col gap-10">
<h1 className="dark:text-tuatara-100 text-tuatara-950 text-2xl lg:text-3xl lg:leading-[45px] font-normal font-sans text-center">
{LABELS.PROJECTS_PAGE.TITLE}
</h1>
<Suspense fallback={<div>Loading...</div>}>
<ProjectFiltersBar />
</Suspense>
</div>
</div>
</div>
<div className="flex flex-col gap-5">
<Suspense fallback={<div>Loading...</div>}>
<div className="lg:!w-1/2 mx-auto w-full">
<ProjectFiltersBar />
</div>
<ProjectResultBar />
</Suspense>
</div>
<Suspense fallback={<div>Loading...</div>}>
<ProjectResultBar />
</Suspense>
</div>
<ProjectList />
</AppContent>
<ProjectGraduated />
</div>
)
}

View File

@@ -221,6 +221,8 @@ export const LABELS = {
},
PROJECTS_PAGE: {
TITLE: "Explore Our Projects",
GRADUATED_PROJECTS:
"Explore graduated projects that have spun out into the ecosystem",
},
RESEARCH_PAGE: {
TITLE: "Explore Our Research",

View File

@@ -1,5 +1,10 @@
"use client"
import { LABELS } from "@/app/labels"
import { ProjectCategory, ProjectInterface } from "@/lib/types"
import { uniq } from "@/lib/utils"
import { useQuery } from "@tanstack/react-query"
import Fuse from "fuse.js"
import {
createContext,
useContext,
@@ -8,11 +13,6 @@ import {
useMemo,
useCallback,
} from "react"
import { useQuery } from "@tanstack/react-query"
import Fuse from "fuse.js"
import { ProjectCategory, ProjectInterface } from "@/lib/types"
import { uniq } from "@/lib/utils"
import { LABELS } from "@/app/labels"
export type ProjectSortBy = "random" | "asc" | "desc" | "relevance"
export type ProjectFilter =
@@ -47,6 +47,7 @@ export const FilterLabelMapping: Record<ProjectFilter, string> = {
interface ProjectsContextType {
projects: ProjectInterface[]
researchs: ProjectInterface[]
graduatedProjects: ProjectInterface[]
isLoading: boolean
isError: boolean
error: Error | null
@@ -188,10 +189,12 @@ const sortProjectByFn = ({
)
}
return sortedProjectList.map((project: any) => ({
id: project?.id?.toLowerCase(),
...project,
}))
return sortedProjectList
.map((project: any) => ({
id: project?.id?.toLowerCase(),
...project,
}))
.filter((project: any) => project?.graduated !== true)
}
const getProjectFilters = (projects: ProjectInterface[]): FiltersProps => {
@@ -330,6 +333,12 @@ export function ProjectsProvider({ children, tag }: ProjectsProviderProps) {
[filteredProjects, sortBy]
)
const graduatedProjects = useMemo(() => {
return fetchedProjects.filter(
(project: ProjectInterface) => project.graduated === true
)
}, [fetchedProjects])
const toggleFilter = useCallback(
({
tag: filterKey,
@@ -413,6 +422,7 @@ export function ProjectsProvider({ children, tag }: ProjectsProviderProps) {
onSelectTheme,
sortProjectBy,
refetch,
graduatedProjects,
}),
[
projects,
@@ -432,6 +442,7 @@ export function ProjectsProvider({ children, tag }: ProjectsProviderProps) {
onSelectTheme,
sortProjectBy,
refetch,
graduatedProjects,
]
)

View File

@@ -1,24 +0,0 @@
"use client"
import { useEffect, useState } from "react"
export function CSSLoader() {
const [loaded, setLoaded] = useState(false)
useEffect(() => {
// Load main CSS after critical render
const loadCSS = () => {
const link = document.createElement("link")
link.rel = "stylesheet"
link.href = "/globals.css"
link.onload = () => setLoaded(true)
document.head.appendChild(link)
}
// Load CSS after a short delay to not block initial render
const timer = setTimeout(loadCSS, 100)
return () => clearTimeout(timer)
}, [])
return null
}

View File

@@ -72,7 +72,7 @@ export const ArticleInEvidenceCard = ({
{
"px-5 lg:px-16 py-6 lg:py-16": size === "lg",
"px-6 py-4 lg:p-8": size === "sm",
"px-6 lg:p-16": size === "xl",
"px-6 lg:p-16 justify-center": size === "xl",
"!p-0 lg:!p-0": !backgroundCover,
},
contentClassName
@@ -187,7 +187,7 @@ export const ArticleInEvidenceCard = ({
src={article.image ?? "/fallback.webp"}
alt="Article hero image"
fill
className="object-cover -z-10"
className="object-cover z-[0]"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/>

View File

@@ -850,4 +850,34 @@ export const Icons = {
/>
</svg>
),
SliderArrowNext: ({ size = 16, ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
{...props}
>
<path
d="M12.6624 9.7091L0.489595 9.70981V7.71011L12.661 7.7094L7.29762 2.346L8.71183 0.931787L16.49 8.70996L8.71183 16.4881L7.29762 15.0739L12.6624 9.7091Z"
fill="#50C3E0"
/>
</svg>
),
SliderArrowPrev: ({ size = 16, ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
{...props}
>
<path
d="M4.33756 9.7091L16.5104 9.70981V7.71011L4.33898 7.7094L9.70238 2.346L8.28817 0.931787L0.509993 8.70996L8.28817 16.4881L9.70238 15.0739L4.33756 9.7091Z"
fill="#50C3E0"
/>
</svg>
),
}

View File

@@ -0,0 +1,55 @@
"use client"
import { Slider } from "../slider"
import { LABELS } from "@/app/labels"
import { useProjects } from "@/app/providers/ProjectsProvider"
import { ProjectInterface } from "@/lib/types"
import Link from "next/link"
export const ProjectGraduated = () => {
const { graduatedProjects } = useProjects()
return (
<div className="flex flex-col gap-10 justify-center dark:bg-anakiwa-975 py-16 lg:py-20">
<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.PROJECTS_PAGE.GRADUATED_PROJECTS}
</span>
<div className="lg:px-0 px-4">
<Slider slidesToShow={4.2} gap="24px">
{graduatedProjects.map((project: ProjectInterface) => {
const website = project.links?.website
?.split("//")[1]
?.replace("/", "")
.replace("www.", "")
return (
<Link
href={project?.links?.website ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-4 w-full lg:max-w-[400px] p-8 rounded-[10px] bg-white border border-anakiwa-300 dark:border-anakiwa-400 dark:bg-anakiwa-975 hover:bg-anakiwa-100 dark:hover:bg-anakiwa-950 transition-all duration-300 cursor-pointer"
>
<div className="flex flex-col gap-4">
<span className="font-sans text-2xl font-bold text-tuatara-950 dark:text-anakiwa-400">
{project.name}
</span>
<span className="font-sans text-base font-normal text-tuatara-500 dark:text-tuatara-100 line-clamp-3">
{project.tldr}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-sans text-base font-normal text-tuatara-500 dark:text-tuatara-200 underline">
{website}
</span>
<div className="text-xs dark:text-black dark:bg-anakiwa-300 text-black bg-anakiwa-300 rounded-[3px] px-[6px] py-[2px]">
Graduated
</div>
</div>
</Link>
)
})}
</Slider>
</div>
</div>
)
}

271
components/slider.tsx Normal file
View File

@@ -0,0 +1,271 @@
"use client"
import { Icons } from "./icons"
import { Button } from "./ui/button"
import { useState, useRef, useEffect } from "react"
import { twMerge } from "tailwind-merge"
interface SliderProps {
children: React.ReactNode[]
className?: string
showNavigation?: boolean
autoSlide?: boolean
autoSlideInterval?: number
onSlideChange?: (index: number) => void
slidesToShow?: number
gap?: string
withDivider?: boolean
controlsPosition?: "top" | "bottom"
showControls?: boolean
infinite?: boolean
}
export const Slider = ({
children,
className,
showNavigation = true,
autoSlide = false,
autoSlideInterval = 3000,
onSlideChange,
slidesToShow = 1,
gap = "0px",
withDivider = false,
controlsPosition = "bottom",
showControls = true,
infinite = true,
}: SliderProps) => {
const [isMounted, setIsMounted] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [touchStart, setTouchStart] = useState(0)
const [touchEnd, setTouchEnd] = useState(0)
const [isHovered, setIsHovered] = useState(false)
const [isTransitioning, setIsTransitioning] = useState(true)
const sliderRef = useRef<HTMLDivElement>(null)
// Ensure component is mounted before running client-side logic
useEffect(() => {
setIsMounted(true)
// Check if device is mobile
const checkMobile = () => {
setIsMobile(window.innerWidth < 768) // md breakpoint
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
const totalSlides = children.length
const effectiveSlidesToShow = isMobile ? 1 : slidesToShow
const maxSlideIndex = infinite
? totalSlides - 1
: Math.max(0, totalSlides - Math.floor(effectiveSlidesToShow))
// For infinite scroll, we need to duplicate slides
const slidesToClone = Math.ceil(effectiveSlidesToShow)
const infiniteChildren = infinite
? [
...children.slice(-slidesToClone), // Clone last items at beginning
...children, // Original items
...children.slice(0, slidesToClone), // Clone first items at end
]
: children
const [currentSlide, setCurrentSlide] = useState(infinite ? slidesToClone : 0)
// Handle seamless infinite transitions
useEffect(() => {
if (!isMounted || !infinite || !isTransitioning) return
const handleTransitionEnd = () => {
setIsTransitioning(false)
if (currentSlide >= totalSlides + slidesToClone) {
setCurrentSlide(slidesToClone)
} else if (currentSlide < slidesToClone) {
setCurrentSlide(totalSlides + slidesToClone - 1)
}
setTimeout(() => setIsTransitioning(true), 50)
}
const slider = sliderRef.current
if (slider && typeof slider.addEventListener === "function") {
slider.addEventListener("transitionend", handleTransitionEnd)
return () => {
if (slider && typeof slider.removeEventListener === "function") {
slider.removeEventListener("transitionend", handleTransitionEnd)
}
}
}
}, [
isMounted,
infinite,
currentSlide,
totalSlides,
slidesToClone,
isTransitioning,
])
useEffect(() => {
if (!isMounted || !autoSlide || isHovered) return
const interval = setInterval(() => {
setCurrentSlide((prev) => {
if (infinite) {
return prev + 1
} else {
return prev >= maxSlideIndex ? 0 : prev + 1
}
})
}, autoSlideInterval)
return () => clearInterval(interval)
}, [
isMounted,
autoSlide,
autoSlideInterval,
maxSlideIndex,
isHovered,
infinite,
])
useEffect(() => {
if (onSlideChange) {
onSlideChange(currentSlide)
}
}, [currentSlide, onSlideChange])
const goToNext = () => {
if (!isMounted || !isTransitioning) return
setCurrentSlide((prev) => {
if (infinite) {
return prev + 1
} else {
return Math.min(prev + 1, maxSlideIndex)
}
})
}
const goToPrev = () => {
if (!isMounted || !isTransitioning) return
setCurrentSlide((prev) => {
if (infinite) {
return prev - 1
} else {
return Math.max(prev - 1, 0)
}
})
}
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.targetTouches[0].clientX)
}
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.targetTouches[0].clientX)
}
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return
const distance = touchStart - touchEnd
const isLeftSwipe = distance > 50
const isRightSwipe = distance < -50
if (isLeftSwipe) {
goToNext()
} else if (isRightSwipe) {
goToPrev()
}
setTouchStart(0)
setTouchEnd(0)
}
const slideWidth = 100 / effectiveSlidesToShow
const translateX = currentSlide * slideWidth
const currentChildren = infinite ? infiniteChildren : children
// Prevent hydration mismatch
if (!isMounted) {
return (
<div className={twMerge("flex flex-col gap-5", className)}>
<div className="flex overflow-hidden">
<div
className="flex-shrink-0 h-full"
style={{ width: `${slideWidth}%` }}
>
{children[0]}
</div>
</div>
</div>
)
}
return (
<div
className={twMerge(
"flex flex-col gap-5",
controlsPosition === "top" && "flex-col-reverse"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={twMerge(
"flex flex-col gap-5 relative overflow-hidden",
className
)}
>
<div
ref={sliderRef}
className={twMerge(
"flex ease-in-out",
isTransitioning ? "transition-transform duration-300" : "",
withDivider && "divide-x divide-app-color-border"
)}
style={{
transform: `translateX(-${translateX}%)`,
gap: gap,
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{currentChildren.map((child, index) => (
<div
key={index}
className="flex-shrink-0 h-full"
style={{
width: `${slideWidth}%`,
}}
>
{child}
</div>
))}
</div>
</div>
{showNavigation && maxSlideIndex > 0 && showControls && (
<div className="flex gap-[10px] ml-auto justify-end">
<Button
onClick={goToPrev}
disabled={!infinite && currentSlide === 0}
aria-label="Previous slide"
className="!mx-0 !focus:outline-none !focus:ring-0 !focus:ring-offset-0 w-10 h-10"
>
<Icons.SliderArrowPrev />
</Button>
<Button
onClick={goToNext}
disabled={!infinite && currentSlide >= maxSlideIndex}
aria-label="Next slide"
className="!mx-0 !focus:outline-none !focus:ring-0 !focus:ring-offset-0 w-10 h-10"
>
<Icons.SliderArrowNext />
</Button>
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,7 @@
import { InputHTMLAttributes } from "react"
import { Icons } from "../icons"
import * as CheckboxComponent from "@radix-ui/react-checkbox"
import { CheckboxProps as CheckboxComponentProps } from "@radix-ui/react-checkbox"
import { Icons } from "../icons"
import { InputHTMLAttributes } from "react"
type CheckboxInputProps = InputHTMLAttributes<HTMLDivElement> &
CheckboxComponentProps
@@ -18,7 +17,7 @@ const Checkbox = ({ label, name, checked, ...props }: CheckboxProps) => {
<CheckboxComponent.Root
{...props}
checked={checked}
className="flex h-[14px] w-[14px] cursor-pointer items-center justify-center rounded-[1.5px] border-2 border-solid border-tuatara-200 bg-primary duration-100 ease-in aria-checked:border-black aria-checked:bg-black dark:border-anakiwa-800"
className="flex h-[14px] w-[14px] cursor-pointer items-center justify-center rounded-[1.5px] border-2 border-solid border-tuatara-200 bg-transparent duration-100 ease-in aria-checked:border-black aria-checked:bg-black dark:border-anakiwa-800"
id={name}
>
<CheckboxComponent.Indicator className="text-white">

View File

@@ -28,6 +28,7 @@ tldr: "Brief project summary" # Short description of the project
# ========================================
category: "research" # Project category: "research" | "devtools" | "application"
license: "MIT" # Project license
graduated: false # Project graduated
tags: # Project tags
keywords: ["tag1", "tag2", "tag3"] # Relevant keywords
themes: ["privacy", "scalability"] # Project themes

View File

@@ -1,6 +1,7 @@
---
id: "cursive"
name: "Cursive"
graduated: true
image: "cursive.webp"
section: "grant"
projectStatus: "active"
@@ -23,7 +24,7 @@ tags:
themes: ["build", "play"]
links:
github: "https://github.com/cursive-team"
website: "https://www.cursive.team/"
website: "https://www.cursive.computer/"
---
What began as an experimental initiative to transform PSE's theoretical cryptography research into tangible applications has evolved into a dedicated lab that incubates practical ways to connect people through applied cryptography.

View File

@@ -3,6 +3,7 @@ id: "zk-email"
name: "ZK Email"
image: "zk-email.webp"
section: "collaboration"
graduated: true
projectStatus: "active"
category: "application"
tldr: "ZK Email is a library that allows for anonymous verification of email signatures while masking specific data."

View File

@@ -1,6 +1,7 @@
---
id: "zkp2p"
name: "ZKP2P"
graduated: true
image: "zkp2p.webp"
section: "grant"
projectStatus: "active"

View File

@@ -15,7 +15,7 @@ html {
/* Custom responsive navigation */
.nav-responsive {
display: none;
display: none !important;
}
@media (min-width: 768px) {

View File

@@ -84,6 +84,7 @@ export interface ProjectTeamMember {
export interface ProjectInterface {
id: string
hasWiki?: boolean // show project with wiki page template
graduated?: boolean
youtubeLinks?: string[]
team?: ProjectTeamMember[]
license?: string