mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-04-23 03:01:03 -04:00
wip add filters
This commit is contained in:
@@ -7,6 +7,7 @@ import GlobalVector from "@/public/social-medias/global-line.svg"
|
||||
import TwitterVector from "@/public/social-medias/twitter-fill.svg"
|
||||
|
||||
import { Markdown } from "@/components/ui/markdown"
|
||||
import { ProjectTags } from "@/components/project/project-detail-tags"
|
||||
|
||||
type PageProps = {
|
||||
params: { id: string }
|
||||
@@ -86,7 +87,7 @@ export default function ProjectDetailPage({ params }: PageProps) {
|
||||
<p className="text-slate-600">{currProject.tldr}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center w-full gap-5 px-6 py-10 bg-anakiwa md:px-0">
|
||||
<div className="flex flex-col items-center justify-center w-full gap-5 px-6 py-10 bg-anakiwa-100 md:px-0">
|
||||
<div className="w-full md:w-[700px]">
|
||||
<div className="relative flex items-center justify-center overflow-hidden rounded-lg">
|
||||
<Image
|
||||
@@ -99,6 +100,9 @@ export default function ProjectDetailPage({ params }: PageProps) {
|
||||
className="object-cover w-full rounded-t-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<ProjectTags project={currProject} />
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-5 py-10 text-base font-normal leading-relaxed">
|
||||
<Markdown>{currProject.description}</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { ProjectLinkIconMap, projects } from "@/data/projects"
|
||||
|
||||
import { ProjectLinkWebsite } from "@/lib/types"
|
||||
import { ProjectLink } from "@/components/project/project-link"
|
||||
import ProjectFiltersBar from "@/components/project/project-filters-bar"
|
||||
import ProjectList from "@/components/project/project-list"
|
||||
import { ProjectResultBar } from "@/components/project/project-result-bar"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Project Library",
|
||||
@@ -13,7 +11,6 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
// TODO: MAKE IT RANDOM - This would prob need to be state and so metadata would get cut
|
||||
// const randomizeProjects = (projects: any[]) => {
|
||||
// // efficient fisher-yates shuffle
|
||||
// const array = [...projects]
|
||||
// let currentIndex = array.length,
|
||||
@@ -32,8 +29,8 @@ export const metadata: Metadata = {
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<section>
|
||||
<div className="bg-second-gradient">
|
||||
<div className="container py-12 mx-auto lg:py-24">
|
||||
<div className="bg-anakiwa-200">
|
||||
<div className="container py-8 mx-auto md:py-12 lg:px-24 lg:py-16">
|
||||
<h1 className="text-4xl font-bold md:text-5xl">
|
||||
Explore the project library
|
||||
</h1>
|
||||
@@ -42,62 +39,15 @@ export default function ProjectsPage() {
|
||||
PSE is home to many projects, from cryptography research to
|
||||
developer tools, protocols and proof-of-concept applications.
|
||||
</p>
|
||||
<ProjectFiltersBar />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-anakiwa">
|
||||
<div className="w-full bg-anakiwa-100">
|
||||
<div className="container">
|
||||
<p className="p-3"></p>
|
||||
<p className="text-base text-slate-900/70 md:text-lg">{`Showing ${projects.length} projects`}</p>
|
||||
<div className="flex flex-wrap justify-center gap-6 py-6">
|
||||
{projects.map((project, index) => {
|
||||
const { id, image, links, name, tldr } = project
|
||||
return (
|
||||
<div key={index}>
|
||||
<Link href={`/projects/${id}`}>
|
||||
<div className="flex h-[419px] w-[310px] cursor-pointer flex-col overflow-hidden rounded-lg border border-slate-900/20 transition duration-150 ease-in hover:scale-105">
|
||||
<Image
|
||||
src={`/project-banners/${
|
||||
image ? image : "fallback.webp"
|
||||
}`}
|
||||
alt={`${name} banner`}
|
||||
width={1200}
|
||||
height={630}
|
||||
className="object-cover w-full rounded-t-lg"
|
||||
/>
|
||||
<div className="flex flex-col justify-between h-full gap-5 p-5 bg-white rounded-b-lg">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<h1 className="text-xl font-bold text-black">
|
||||
{name}
|
||||
</h1>
|
||||
<p className="text-slate-900/80">{tldr}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-2 mr-auto">
|
||||
{Object.entries(links ?? {})?.map(
|
||||
([website, url], index) => {
|
||||
const image =
|
||||
ProjectLinkIconMap?.[
|
||||
website as ProjectLinkWebsite
|
||||
]
|
||||
|
||||
if (!image) return null // no icon mapping for this website
|
||||
return (
|
||||
<ProjectLink
|
||||
key={index}
|
||||
url={url}
|
||||
image={image}
|
||||
website={website as ProjectLinkWebsite}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="px-3 py-8">
|
||||
<ProjectResultBar />
|
||||
</div>
|
||||
<ProjectList />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
41
components/project/project-detail-tags.tsx
Normal file
41
components/project/project-detail-tags.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link"
|
||||
import {
|
||||
FilterLabelMapping,
|
||||
ProjectFilter,
|
||||
} from "@/state/useProjectFiltersState"
|
||||
|
||||
import { ProjectInterface } from "@/lib/types"
|
||||
|
||||
import { CategoryTag } from "../ui/categoryTag"
|
||||
|
||||
export function ProjectTags({ project }: { project: ProjectInterface }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-8">
|
||||
{Object.entries(FilterLabelMapping).map(([key, label]) => {
|
||||
const keyTags = project?.tags?.[key as ProjectFilter]
|
||||
const hasItems = keyTags && keyTags?.length > 0
|
||||
|
||||
return (
|
||||
hasItems && (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<span className="py-2 text-base font-medium ">{label}</span>
|
||||
<div className="flex gap-[6px]">
|
||||
{keyTags?.map((tag) => {
|
||||
return (
|
||||
<Link href={`/projects?${key}=${tag}`}>
|
||||
<CategoryTag key={tag} variant="gray">
|
||||
{tag}
|
||||
</CategoryTag>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
components/project/project-filters-bar.tsx
Normal file
166
components/project/project-filters-bar.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode, useEffect, useState } from "react"
|
||||
import Image from "next/image"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import FiltersIcon from "@/public/icons/filters.svg"
|
||||
import {
|
||||
FilterLabelMapping,
|
||||
ProjectFilter,
|
||||
useProjectFiltersState,
|
||||
} from "@/state/useProjectFiltersState"
|
||||
|
||||
import { cn, queryStringToObject } from "@/lib/utils"
|
||||
|
||||
import Badge from "../ui/badge"
|
||||
import { Button } from "../ui/button"
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
import { Input } from "../ui/input"
|
||||
import { Modal } from "../ui/modal"
|
||||
|
||||
interface FilterWrapperProps {
|
||||
label: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FilterWrapper = ({ label, children }: FilterWrapperProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<span className="text-xl font-bold">{label}</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectFiltersBar() {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const { filters, toggleFilter, queryString, activeFilters } =
|
||||
useProjectFiltersState((state) => state)
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryString) return
|
||||
router.push(`/projects?${queryString}`)
|
||||
}, [queryString, router])
|
||||
|
||||
useEffect(() => {
|
||||
// set active filters from url
|
||||
useProjectFiltersState.setState({
|
||||
activeFilters: queryStringToObject(searchParams),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearAllFilters = () => {
|
||||
useProjectFiltersState.setState({ activeFilters: {}, queryString: "" })
|
||||
router.push("/projects")
|
||||
}
|
||||
|
||||
const hasActiveFilters = queryString && queryString.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Filters"
|
||||
footer={
|
||||
<div className="flex">
|
||||
<Button
|
||||
disabled={!hasActiveFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="black"
|
||||
size="sm"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Show project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={showModal}
|
||||
setOpen={setShowModal}
|
||||
>
|
||||
{Object.entries(filters).map(([key, items]) => {
|
||||
const filterLabel = FilterLabelMapping?.[key as ProjectFilter] ?? ""
|
||||
const hasItems = items.length > 0
|
||||
|
||||
return (
|
||||
hasItems && (
|
||||
<FilterWrapper key={key} label={filterLabel}>
|
||||
<>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
activeFilters?.[key as ProjectFilter]?.includes(item)
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={item}
|
||||
onClick={() => toggleFilter(key as ProjectFilter, item)}
|
||||
name={item}
|
||||
label={item}
|
||||
checked={isActive}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</FilterWrapper>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-6 mt-10">
|
||||
<span className="text-lg font-medium">
|
||||
What do you want to do today?
|
||||
</span>
|
||||
<div className="grid items-center justify-between grid-cols-1 gap-3 md:gap-12 md:grid-cols-5">
|
||||
<div className="grid grid-cols-3 col-span-1 gap-4 md:gap-6 md:col-span-2">
|
||||
<Button variant="white" size="lg">
|
||||
Build
|
||||
</Button>
|
||||
<Button variant="white" size="lg">
|
||||
Play
|
||||
</Button>
|
||||
<Button variant="white" size="lg">
|
||||
Research
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] col-span-1 gap-3 md:col-span-3">
|
||||
<Input placeholder="Search project title or keyword" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge>
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
variant="white"
|
||||
className={cn({
|
||||
"border-2 border-anakiwa-950": hasActiveFilters,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src={FiltersIcon} alt="filter icon" />
|
||||
<span className="hidden md:block">Filters</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Badge>
|
||||
<button
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={clearAllFilters}
|
||||
className="hidden bg-transparent cursor-pointer md:block text-primary opacity-85 hover:opacity-100 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b-2 border-black">
|
||||
<span className="text-sm font-medium">Clear all</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { ProjectLinkIconMap } from "@/data/projects"
|
||||
|
||||
import { ProjectLinkWebsite } from "@/lib/types"
|
||||
|
||||
interface ProjectLinkProps {
|
||||
url: string
|
||||
image: string
|
||||
website: ProjectLinkWebsite
|
||||
}
|
||||
|
||||
export function ProjectLink({ image, website, url }: ProjectLinkProps) {
|
||||
export function ProjectLink({ website, url }: ProjectLinkProps) {
|
||||
const image = ProjectLinkIconMap?.[website as ProjectLinkWebsite]
|
||||
|
||||
// TODO: add support for youtube, discord and other links
|
||||
if (!image) return null
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
|
||||
57
components/project/project-list.tsx
Normal file
57
components/project/project-list.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useProjectFiltersState } from "@/state/useProjectFiltersState"
|
||||
|
||||
import { ProjectLinkWebsite } from "@/lib/types"
|
||||
|
||||
import { ProjectLink } from "./project-link"
|
||||
|
||||
export default function ProjectList() {
|
||||
const { projects } = useProjectFiltersState((state) => state)
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-6 pb-6">
|
||||
{projects.map((project) => {
|
||||
const { id, image, links, name, tldr } = project
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div
|
||||
onClick={() => router.push(`/projects/${id}`)}
|
||||
className="flex h-[419px] w-[310px] cursor-pointer flex-col overflow-hidden rounded-lg border border-slate-900/20 transition duration-150 ease-in hover:scale-105"
|
||||
>
|
||||
<Image
|
||||
src={`/project-banners/${image ? image : "fallback.webp"}`}
|
||||
alt={`${name} banner`}
|
||||
width={1200}
|
||||
height={630}
|
||||
className="object-cover w-full rounded-t-lg"
|
||||
/>
|
||||
<div className="flex flex-col justify-between h-full gap-5 p-5 bg-white rounded-b-lg">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<h1 className="text-xl font-bold text-black">{name}</h1>
|
||||
<p className="text-slate-900/80">{tldr}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-2 mr-auto">
|
||||
{Object.entries(links ?? {})?.map(([website, url], index) => {
|
||||
return (
|
||||
<ProjectLink
|
||||
key={index}
|
||||
url={url}
|
||||
website={website as ProjectLinkWebsite}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
components/project/project-result-bar.tsx
Normal file
54
components/project/project-result-bar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useProjectFiltersState } from "@/state/useProjectFiltersState"
|
||||
|
||||
import { CategoryTag } from "../ui/categoryTag"
|
||||
|
||||
const labelClass = "h-5 text-xs text-base md:h-6 text-slate-900/70 md:text-lg"
|
||||
|
||||
export const ProjectResultBar = () => {
|
||||
const { activeFilters, toggleFilter, projects } = useProjectFiltersState(
|
||||
(state) => state
|
||||
)
|
||||
|
||||
const haveActiveFilters = Object.entries(activeFilters).some(
|
||||
([_key, values]) => values.length > 0
|
||||
)
|
||||
|
||||
if (!haveActiveFilters)
|
||||
return (
|
||||
<span className={labelClass}>
|
||||
{`Showing ${projects.length} projects`}{" "}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={labelClass}>
|
||||
{`Showing ${projects?.length} projects with:`}{" "}
|
||||
</span>
|
||||
<div className="inline-flex flex-wrap gap-4">
|
||||
{Object.entries(activeFilters).map(([key, filters], index) => {
|
||||
return (
|
||||
<>
|
||||
{filters?.map((filter) => {
|
||||
if (filter?.length === 0) return null
|
||||
|
||||
return (
|
||||
<CategoryTag
|
||||
closable
|
||||
variant="gray"
|
||||
onClose={() => toggleFilter(key as any, filter)}
|
||||
key={`${index}-${filter}`}
|
||||
>
|
||||
{filter}
|
||||
</CategoryTag>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||
"inline-flex items-center justify-center duration-100 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -18,6 +19,8 @@ const buttonVariants = cva(
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "underline-offset-4 hover:underline text-primary",
|
||||
black: "bg-tuatara-950 text-white hover:bg-black",
|
||||
white: "bg-zinc-50 text-black hover:bg-zinc-100",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 py-2 px-4",
|
||||
@@ -36,17 +39,25 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
(
|
||||
{ className, variant, size, asChild = false, children, icon, ...props },
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Icon = icon
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{Icon && <Icon size={24} />}
|
||||
<span>{children}</span>
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
39
components/ui/input.tsx
Normal file
39
components/ui/input.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { InputHTMLAttributes, forwardRef } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const inputVariants = cva(
|
||||
"py-2 px-4 text-anakiwa-950 transition-colors duration-100 animate border-[1.5px] rounded-md focus:ring-0 border-tuatara-200 focus-visible:border-2 focus-visible:border-tuatara-950 bg-zinc-50 focus-visible:ring-0 focus-visible:none focus-visible:outline-none placeholder-anakiwa-950/50",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: "text-sm",
|
||||
sm: "text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface InputProps
|
||||
extends VariantProps<typeof inputVariants>,
|
||||
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "id" | "children"> {}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ size, className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(inputVariants({ size, className }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
19
lib/utils.ts
19
lib/utils.ts
@@ -1,6 +1,25 @@
|
||||
import { ReadonlyURLSearchParams } from "next/navigation"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function uniq(arr: any[], removeEmpty = true) {
|
||||
const uniqArray = Array.from(new Set(arr))
|
||||
|
||||
return removeEmpty ? uniqArray.filter(Boolean) : uniqArray
|
||||
}
|
||||
|
||||
export function queryStringToObject(
|
||||
searchParams: ReadonlyURLSearchParams
|
||||
): Record<string, any> {
|
||||
const obj = Object.fromEntries(searchParams.entries())
|
||||
const object: Record<string, any> = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
object[key] = obj[key]?.split(",")
|
||||
})
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"class-variance-authority": "^0.4.0",
|
||||
"clsx": "^1.2.1",
|
||||
"framer-motion": "^10.12.17",
|
||||
"fuse.js": "^6.6.2",
|
||||
"gsap": "^3.12.1",
|
||||
"lucide-react": "0.105.0-alpha.4",
|
||||
"next": "^13.4.10",
|
||||
@@ -36,7 +37,8 @@
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.31.3",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^3.7.2",
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ dependencies:
|
||||
framer-motion:
|
||||
specifier: ^10.12.17
|
||||
version: 10.12.17(react-dom@18.2.0)(react@18.2.0)
|
||||
fuse.js:
|
||||
specifier: ^6.6.2
|
||||
version: 6.6.2
|
||||
gsap:
|
||||
specifier: ^3.12.1
|
||||
version: 3.12.1
|
||||
@@ -56,6 +59,9 @@ dependencies:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(tailwindcss@3.3.2)
|
||||
zustand:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(@types/react@18.2.7)(react@18.2.0)
|
||||
|
||||
devDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
@@ -2003,6 +2009,11 @@ packages:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
dev: true
|
||||
|
||||
/fuse.js@6.6.2:
|
||||
resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4029,6 +4040,14 @@ packages:
|
||||
tslib: 2.5.2
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.2.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -4133,6 +4152,26 @@ packages:
|
||||
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
||||
dev: false
|
||||
|
||||
/zustand@4.4.1(@types/react@18.2.7)(react@18.2.0):
|
||||
resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.7
|
||||
react: 18.2.0
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
||||
3
public/icons/filters.svg
Normal file
3
public/icons/filters.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12.75C13.6065 12.75 12.4418 13.71 12.1065 15H3V16.5H12.1065C12.4418 17.79 13.6065 18.75 15 18.75C16.3935 18.75 17.5583 17.79 17.8935 16.5H21V15H17.8935C17.5583 13.71 16.3935 12.75 15 12.75ZM9 6C7.6065 6 6.44175 6.96 6.1065 8.25H3V9.75H6.1065C6.44175 11.04 7.6065 12 9 12C10.3935 12 11.5583 11.04 11.8935 9.75H21V8.25H11.8935C11.5583 6.96 10.3935 6 9 6Z" fill="#103241"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
139
state/useProjectFiltersState.ts
Normal file
139
state/useProjectFiltersState.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { projects } from "@/data/projects"
|
||||
import Fuse from "fuse.js"
|
||||
import { create } from "zustand"
|
||||
|
||||
import { ProjectInterface } from "@/lib/types"
|
||||
import { uniq } from "@/lib/utils"
|
||||
|
||||
export type ProjectFilter = "keywords" | "builtWith" | "themes"
|
||||
export type FiltersProps = Record<ProjectFilter, string[]>
|
||||
|
||||
export const FilterLabelMapping: Record<ProjectFilter, string> = {
|
||||
keywords: "Keywords",
|
||||
builtWith: "Built with",
|
||||
themes: "Themes",
|
||||
}
|
||||
|
||||
interface ProjectStateProps {
|
||||
projects: ProjectInterface[]
|
||||
filters: FiltersProps
|
||||
activeFilters: Partial<FiltersProps>
|
||||
queryString: string
|
||||
}
|
||||
|
||||
interface SearchMatchByParamsProps {
|
||||
list: any[]
|
||||
searchPattern: string
|
||||
}
|
||||
|
||||
interface ProjectActionsProps {
|
||||
toggleFilter: (projectFilter: ProjectFilter, value: string) => void
|
||||
setFilterFromQueryString: (filters: Partial<FiltersProps>) => void
|
||||
onFilterProject: (searchPattern: string) => void
|
||||
}
|
||||
|
||||
const createURLQueryString = (params: Partial<FiltersProps>) => {
|
||||
const qs = Object.keys(params)
|
||||
.map((key: any) => `${key}=${encodeURIComponent((params as any)[key])}`)
|
||||
.join("&")
|
||||
|
||||
return qs
|
||||
}
|
||||
|
||||
const getProjectFilters = (): FiltersProps => {
|
||||
const filters: FiltersProps = {
|
||||
keywords: [],
|
||||
builtWith: [],
|
||||
themes: [],
|
||||
}
|
||||
|
||||
// get list of all tags from project list
|
||||
projects.forEach((project) => {
|
||||
if (project?.tags?.builtWith) {
|
||||
filters.builtWith.push(...project?.tags?.builtWith)
|
||||
}
|
||||
|
||||
if (project?.tags?.keywords) {
|
||||
filters.keywords.push(...project?.tags?.keywords)
|
||||
}
|
||||
})
|
||||
|
||||
// duplicate-free array for every tags
|
||||
Object.entries(filters).forEach(([key, entries]) => {
|
||||
filters[key as ProjectFilter] = uniq(entries)
|
||||
})
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
const filterProjects = ({ list, searchPattern }: SearchMatchByParamsProps) => {
|
||||
const keys = ["title", "tldr", "tags.keywords", "tags.builtWith"]
|
||||
const fuseOptions = {
|
||||
keys,
|
||||
$or: [
|
||||
{
|
||||
$path: keys,
|
||||
val: searchPattern,
|
||||
},
|
||||
],
|
||||
}
|
||||
const fuse = new Fuse(list, fuseOptions)
|
||||
const result = fuse.search(searchPattern)?.map(({ item }) => item)
|
||||
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
export const useProjectFiltersState = create<
|
||||
ProjectStateProps & ProjectActionsProps
|
||||
>()((set) => ({
|
||||
projects,
|
||||
queryString: "",
|
||||
filters: getProjectFilters(), // list of filters with all possible values from projects
|
||||
activeFilters: {}, // list of filters active in the current view by the user
|
||||
toggleFilter: (filterKey: ProjectFilter, value: string) =>
|
||||
set((state: any) => {
|
||||
if (!filterKey) return
|
||||
const values: string[] = state?.activeFilters?.[filterKey] ?? []
|
||||
const index = values?.indexOf(value)
|
||||
if (index > -1) {
|
||||
values.splice(index, 1)
|
||||
} else {
|
||||
values.push(value)
|
||||
}
|
||||
|
||||
const activeFilters: Partial<FiltersProps> = {
|
||||
...state.activeFilters,
|
||||
[filterKey]: values,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeFilters,
|
||||
queryString: createURLQueryString(activeFilters),
|
||||
}
|
||||
}),
|
||||
onFilterProject: (searchPattern: string) => {
|
||||
set((state: any) => {
|
||||
if (!searchPattern?.length) return state
|
||||
|
||||
const filteredProjects = filterProjects({
|
||||
list: state.projects,
|
||||
searchPattern,
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
projects: filteredProjects,
|
||||
}
|
||||
})
|
||||
},
|
||||
setFilterFromQueryString: (filters: Partial<FiltersProps>) => {
|
||||
set((state: any) => {
|
||||
return {
|
||||
...state,
|
||||
activeFilters: filters,
|
||||
queryString: createURLQueryString(filters),
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -26,13 +26,17 @@ module.exports = {
|
||||
corduroy: "#4A5754",
|
||||
orange: "#E1523A",
|
||||
orangeDark: "#E3533A",
|
||||
anakiwa: "hsl(var(--anakiwa))",
|
||||
"anakiwa-100": "#E4F3FA",
|
||||
"anakiwa-300": "#A3DFF0",
|
||||
"anakiwa-500": "#29ACCE",
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
anakiwa: {
|
||||
100: "hsl(var(--anakiwa))",
|
||||
100: "#E4F3FA",
|
||||
200: "#C2E8F5",
|
||||
300: "#A3DFF0",
|
||||
500: "#29ACCE",
|
||||
950: "#103241",
|
||||
},
|
||||
tuatara: {
|
||||
100: "#E5E6E8",
|
||||
200: "#CDCFD4",
|
||||
|
||||
Reference in New Issue
Block a user