mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-04-23 03:01:03 -04:00
feat: graduated projects section
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
55
components/project/project-graduated.tsx
Normal file
55
components/project/project-graduated.tsx
Normal 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
271
components/slider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
id: "zkp2p"
|
||||
name: "ZKP2P"
|
||||
graduated: true
|
||||
image: "zkp2p.webp"
|
||||
section: "grant"
|
||||
projectStatus: "active"
|
||||
|
||||
@@ -15,7 +15,7 @@ html {
|
||||
|
||||
/* Custom responsive navigation */
|
||||
.nav-responsive {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user