Files
pse.dev/app/providers/ProjectsProvider.tsx
Kalidou Diagne b4b1bb56e6 migrate project data from .ts to Markdown files (#453)
* migrate project data from .ts to Markdown files
2025-06-13 22:05:23 +04:00

452 lines
11 KiB
TypeScript

"use client"
import {
createContext,
useContext,
ReactNode,
useState,
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 =
| "keywords"
| "builtWith"
| "themes"
| "fundingSource"
export type FiltersProps = Record<ProjectFilter, string[]>
export const DEFAULT_PROJECT_SORT_BY: ProjectSortBy = "asc"
interface ProjectInterfaceScore extends ProjectInterface {
score: number
}
export const SortByFnMapping: Record<
ProjectSortBy,
(a: ProjectInterfaceScore, b: ProjectInterfaceScore) => number
> = {
random: () => Math.random() - 0.5,
asc: (a, b) => a.name.localeCompare(b.name),
desc: (a, b) => b.name.localeCompare(a.name),
relevance: (a, b) => b?.score - a?.score,
}
export const FilterLabelMapping: Record<ProjectFilter, string> = {
keywords: LABELS.COMMON.FILTER_LABELS.KEYWORDS,
builtWith: LABELS.COMMON.FILTER_LABELS.BUILT_WITH,
themes: LABELS.COMMON.FILTER_LABELS.THEMES,
fundingSource: LABELS.COMMON.FILTER_LABELS.FUNDING_SOURCE,
}
interface ProjectsContextType {
projects: ProjectInterface[]
researchs: ProjectInterface[]
isLoading: boolean
isError: boolean
error: Error | null
filters: FiltersProps
activeFilters: Partial<FiltersProps>
queryString: string
searchQuery: string
sortBy: ProjectSortBy
getProjectById: (id: string | number) => { project: ProjectInterface }
toggleFilter: (params: {
tag: ProjectFilter
value: string
searchQuery?: string
}) => void
setFilterFromQueryString: (filters: Partial<FiltersProps>) => void
onFilterProject: (searchPattern: string) => void
onSelectTheme: (theme: string, searchPattern?: string) => void
sortProjectBy: (sortBy: ProjectSortBy) => void
refetch: () => Promise<any>
}
const ProjectsContext = createContext<ProjectsContextType | undefined>(
undefined
)
const createURLQueryString = (params: Partial<FiltersProps>): string => {
if (Object.keys(params)?.length === 0) return ""
return Object.keys(params)
.map((key: any) => `${key}=${encodeURIComponent((params as any)[key])}`)
.join("&")
}
const filterProjects = ({
searchPattern = "",
activeFilters = {},
findAnyMatch = false,
projects: projectListItems = [],
}: {
searchPattern?: string
activeFilters?: Partial<FiltersProps>
findAnyMatch?: boolean
projects?: ProjectInterface[]
}) => {
const projectList = projectListItems.map((project: any) => ({
...project,
id: project?.id?.toLowerCase(),
}))
const keys = [
"name",
"tldr",
"tags.themes",
"tags.keywords",
"tags.builtWith",
"projectStatus",
]
const tagsFiltersQuery: Record<string, string>[] = []
Object.entries(activeFilters).forEach(([key, values]) => {
values.forEach((value) => {
if (!value) return
tagsFiltersQuery.push({
[`tags.${key}`]: value,
})
})
})
const noActiveFilters =
tagsFiltersQuery.length === 0 && searchPattern.length === 0
if (noActiveFilters) return projectList
let query: any = {}
if (findAnyMatch) {
query = {
$or: [...tagsFiltersQuery, { name: searchPattern }],
}
} else if (searchPattern?.length === 0) {
query = {
$and: [...tagsFiltersQuery],
}
} else if (tagsFiltersQuery.length === 0) {
query = {
name: searchPattern,
}
} else {
query = {
$and: [
{
$and: [...tagsFiltersQuery],
},
{ name: searchPattern },
],
}
}
const fuse = new Fuse(projectList, {
threshold: 0.3,
useExtendedSearch: true,
includeScore: true,
findAllMatches: true,
distance: 200,
keys,
})
const result = fuse.search(query)?.map(({ item, score }) => ({
...item,
score,
}))
return result ?? []
}
const sortProjectByFn = ({
projects = [],
sortBy = DEFAULT_PROJECT_SORT_BY,
category = null,
ignoreCategories = [ProjectCategory.RESEARCH],
}: {
projects: ProjectInterface[]
sortBy?: ProjectSortBy
category?: ProjectCategory | null
ignoreCategories?: ProjectCategory[]
}) => {
const sortedProjectList: ProjectInterface[] = [
...(projects as ProjectInterfaceScore[]),
]
.sort(SortByFnMapping[sortBy])
.filter(
(project) =>
!ignoreCategories
?.map((category) => category?.toLowerCase())
.includes(project.category?.toLowerCase() as any)
)
if (category) {
return sortedProjectList.filter(
(project) => project.category?.toLowerCase() === category?.toLowerCase()
)
}
return sortedProjectList.map((project: any) => ({
id: project?.id?.toLowerCase(),
...project,
}))
}
const getProjectFilters = (projects: ProjectInterface[]): FiltersProps => {
const filters: FiltersProps = {
themes: ["play", "build", "research"],
keywords: [],
builtWith: [],
fundingSource: [],
}
projects.forEach((project) => {
if (project?.tags?.builtWith) {
filters.builtWith.push(
...project.tags.builtWith.map((tag) => {
if (typeof tag === "string") {
return tag.toLowerCase()
}
return ""
})
)
}
if (project?.tags?.keywords) {
filters.keywords.push(
...project.tags.keywords.map((keyword) =>
typeof keyword === "string" ? keyword.toLowerCase() : ""
)
)
}
})
Object.entries(filters).forEach(([key, entries]) => {
filters[key as ProjectFilter] = uniq(entries)
})
return filters
}
async function fetchProjects(tag?: string) {
try {
const params = new URLSearchParams()
if (tag) params.append("tag", tag)
const response = await fetch(`/api/projects?${params.toString()}`, {
cache: "default",
})
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.status}`)
}
const projects = await response.json()
return projects || []
} catch (error) {
console.error("Error fetching projects:", error)
throw error
}
}
interface ProjectsProviderProps {
children: ReactNode
tag?: string
}
export function ProjectsProvider({ children, tag }: ProjectsProviderProps) {
const [sortBy, setSortBy] = useState<ProjectSortBy>(DEFAULT_PROJECT_SORT_BY)
const [activeFilters, setActiveFilters] = useState<Partial<FiltersProps>>({})
const [queryString, setQueryString] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const {
data: fetchedProjects = [],
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ["projects", tag],
queryFn: () => fetchProjects(tag),
})
const getProjectById = useCallback(
(id: string | number) => {
const project: ProjectInterface =
fetchedProjects.filter(
(project: ProjectInterface) =>
String(project.id?.toLowerCase()) === id.toString().toLowerCase()
)[0] ?? {}
const content = project?.content
return {
project,
content,
}
},
[fetchedProjects]
)
const filters = useMemo(
() => getProjectFilters(fetchedProjects),
[fetchedProjects]
)
const filteredProjects = useMemo(
() =>
filterProjects({
searchPattern: searchQuery,
activeFilters,
projects: fetchedProjects,
}),
[searchQuery, activeFilters, fetchedProjects]
)
const projects = useMemo(
() =>
sortProjectByFn({
projects: filteredProjects,
sortBy,
ignoreCategories: [ProjectCategory.RESEARCH],
}),
[filteredProjects, sortBy]
)
const researchs = useMemo(
() =>
sortProjectByFn({
projects: filteredProjects,
sortBy,
category: ProjectCategory.RESEARCH,
ignoreCategories: [
ProjectCategory.DEVTOOLS,
ProjectCategory.APPLICATION,
],
}),
[filteredProjects, sortBy]
)
const toggleFilter = useCallback(
({
tag: filterKey,
value,
searchQuery: search,
}: {
tag: ProjectFilter
value: string
searchQuery?: string
}) => {
if (!filterKey) return
setActiveFilters((prevFilters) => {
const values: string[] = prevFilters?.[filterKey] ?? []
const index = values?.indexOf(value)
const newValues = [...values]
if (index > -1) {
newValues.splice(index, 1)
} else {
newValues.push(value)
}
const activeFiltersNormalized = newValues.filter(Boolean)
const newActiveFilters = {
...prevFilters,
[filterKey]: activeFiltersNormalized,
}
setQueryString(createURLQueryString(newActiveFilters))
if (search !== undefined) setSearchQuery(search)
return newActiveFilters
})
},
[]
)
const onSelectTheme = useCallback((theme: string, searchPattern = "") => {
setActiveFilters((prevFilters) => {
const themes = prevFilters?.themes?.includes(theme) ? [] : [theme]
const newActiveFilters = {
...prevFilters,
themes,
}
setSearchQuery(searchPattern)
return newActiveFilters
})
}, [])
const onFilterProject = useCallback((searchPattern: string) => {
setSearchQuery(searchPattern)
}, [])
const setFilterFromQueryString = useCallback(
(filters: Partial<FiltersProps>) => {
setActiveFilters(filters)
setQueryString(createURLQueryString(filters))
},
[]
)
const sortProjectBy = useCallback((newSortBy: ProjectSortBy) => {
setSortBy(newSortBy)
}, [])
const contextValue = useMemo(
() => ({
projects,
researchs,
isLoading,
isError,
error: error as Error | null,
filters,
activeFilters,
queryString,
searchQuery,
sortBy,
getProjectById,
toggleFilter,
setFilterFromQueryString,
onFilterProject,
onSelectTheme,
sortProjectBy,
refetch,
}),
[
projects,
researchs,
isLoading,
isError,
error,
filters,
activeFilters,
queryString,
searchQuery,
sortBy,
getProjectById,
toggleFilter,
setFilterFromQueryString,
onFilterProject,
onSelectTheme,
sortProjectBy,
refetch,
]
)
return (
<ProjectsContext.Provider value={contextValue}>
{children}
</ProjectsContext.Provider>
)
}
export function useProjects() {
const context = useContext(ProjectsContext)
if (context === undefined) {
throw new Error("useProjects must be used within a ProjectsProvider")
}
return context
}