diff --git a/app/(pages)/blog/tags/[tag]/page.tsx b/app/(pages)/blog/tags/[tag]/page.tsx index 194e9a6..4375ae4 100644 --- a/app/(pages)/blog/tags/[tag]/page.tsx +++ b/app/(pages)/blog/tags/[tag]/page.tsx @@ -2,7 +2,7 @@ import { LABELS } from "@/app/labels" import { ArticleListCard } from "@/components/blog/article-list-card" import { AppContent } from "@/components/ui/app-content" import { Label } from "@/components/ui/label" -import { getArticles, Article, getArticleTags } from "@/lib/content" +import { getArticles, Article, getArticleTags, ArticleTag } from "@/lib/content" import { interpolate } from "@/lib/utils" import { HydrationBoundary, @@ -23,22 +23,43 @@ export async function generateMetadata({ params, }: BlogTagPageProps): Promise { const { tag } = params + const tags = await getArticleTags() + const tagInfo = tags.find((t) => t.id === tag) return { - title: interpolate(LABELS.BLOG_TAGS_PAGE.TAG_TITLE, { tag }), + title: interpolate(LABELS.BLOG_TAGS_PAGE.TAG_TITLE, { + tag: tagInfo?.name ?? tag, + }), description: LABELS.BLOG_TAGS_PAGE.SUBTITLE, } } export const generateStaticParams = async () => { const tags = await getArticleTags() - return tags.map((tag) => ({ tag })) + return tags.map((tag) => ({ tag: tag.id })) } const BlogTagPage = async ({ params }: BlogTagPageProps) => { const { tag } = params const queryClient = new QueryClient() + // First get all tags to find the display name + await queryClient.prefetchQuery({ + queryKey: ["get-articles-tags"], + queryFn: async () => { + try { + const tags = getArticleTags() + return tags + } catch (error) { + console.error("Error fetching article tags:", error) + return [] + } + }, + }) + + const tags = queryClient.getQueryData(["get-articles-tags"]) as ArticleTag[] + const tagInfo = tags.find((t) => t.id === tag) + await queryClient.prefetchQuery({ queryKey: ["get-articles-by-tag", tag], queryFn: async () => { @@ -72,7 +93,7 @@ const BlogTagPage = async ({ params }: BlogTagPageProps) => { @@ -87,7 +108,7 @@ const BlogTagPage = async ({ params }: BlogTagPageProps) => { ))} {articles?.length === 0 && (

- No articles found for tag "{tag}" + No articles found for tag "{tagInfo?.name ?? tag}"

)} diff --git a/app/(pages)/blog/tags/page.tsx b/app/(pages)/blog/tags/page.tsx index 837a8ce..c8d9148 100644 --- a/app/(pages)/blog/tags/page.tsx +++ b/app/(pages)/blog/tags/page.tsx @@ -2,7 +2,7 @@ import { LABELS } from "@/app/labels" import { Icons } from "@/components/icons" import { AppContent } from "@/components/ui/app-content" import { Label } from "@/components/ui/label" -import { getArticleTags } from "@/lib/content" +import { getArticleTags, ArticleTag } from "@/lib/content" import { HydrationBoundary, QueryClient } from "@tanstack/react-query" import Link from "next/link" import { Suspense } from "react" @@ -20,16 +20,16 @@ const BlogTagsPage = async () => { queryKey: ["get-articles-tags"], queryFn: async () => { try { - const articles = getArticleTags() - return articles + const tags = getArticleTags() + return tags } catch (error) { - console.error("Error fetching articles:", error) + console.error("Error fetching article tags:", error) return [] } }, }) - const tags = queryClient.getQueryData(["get-articles-tags"]) as string[] + const tags = queryClient.getQueryData(["get-articles-tags"]) as ArticleTag[] return (
@@ -53,11 +53,11 @@ const BlogTagsPage = async () => { {tags?.map((tag) => ( - {tag} + {tag.name} ))} diff --git a/app/providers/GlobalProvider.tsx b/app/providers/GlobalProvider.tsx index f37da16..53b5d1b 100644 --- a/app/providers/GlobalProvider.tsx +++ b/app/providers/GlobalProvider.tsx @@ -16,7 +16,6 @@ interface GlobalContextType { setIsDarkMode: (value: boolean) => void } -const DARK_MODE_KEY = "pse-dark-mode" const GlobalContext = createContext(undefined) const queryClient = new QueryClient({ @@ -29,41 +28,23 @@ const queryClient = new QueryClient({ }) export function GlobalProvider({ children }: { children: ReactNode }) { - // Initialize dark mode from local storage or system preference - const [isDarkMode, setIsDarkMode] = useState(() => { - // Check local storage first - const storedPreference = localStorage.getItem(DARK_MODE_KEY) - if (storedPreference !== null) { - return storedPreference === "true" - } - // Fall back to system preference - return window.matchMedia("(prefers-color-scheme: dark)").matches - }) + const [isDarkMode, setIsDarkMode] = useState(false) - // Listen for system preference changes + // Initialize dark mode from system preference useEffect(() => { const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + setIsDarkMode(mediaQuery.matches) + const handleChange = (e: MediaQueryListEvent) => { - // Only update if user hasn't explicitly set a preference - if (localStorage.getItem("pse,") === null) { - setIsDarkMode(e.matches) - } + setIsDarkMode(e.matches) } - // Add event listener mediaQuery.addEventListener("change", handleChange) - - // Cleanup return () => mediaQuery.removeEventListener("change", handleChange) }, []) - // Save preference to localStorage when it changes - useEffect(() => { - localStorage.setItem(DARK_MODE_KEY, isDarkMode.toString()) - }, [isDarkMode]) - const value = { - isDarkMode: true, + isDarkMode, setIsDarkMode, } diff --git a/app/providers/ProjectsProvider.tsx b/app/providers/ProjectsProvider.tsx index c5c7183..1d292de 100644 --- a/app/providers/ProjectsProvider.tsx +++ b/app/providers/ProjectsProvider.tsx @@ -1,26 +1,69 @@ "use client" -import { createContext, useContext, ReactNode } from "react" +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" -// Define the Project type based on your needs -export interface Project { - id: string - title: string - description: string - image?: string - tags?: string[] - url?: string - github?: string - date?: string - status?: "completed" | "in-progress" | "planned" +export type ProjectSortBy = "random" | "asc" | "desc" | "relevance" +export type ProjectFilter = + | "keywords" + | "builtWith" + | "themes" + | "fundingSource" +export type FiltersProps = Record +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 = { + 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: any[] + projects: ProjectInterface[] + researchs: ProjectInterface[] isLoading: boolean isError: boolean error: Error | null + filters: FiltersProps + activeFilters: Partial + queryString: string + searchQuery: string + sortBy: ProjectSortBy + toggleFilter: (params: { + tag: ProjectFilter + value: string + searchQuery?: string + }) => void + setFilterFromQueryString: (filters: Partial) => void + onFilterProject: (searchPattern: string) => void + onSelectTheme: (theme: string, searchPattern?: string) => void + sortProjectBy: (sortBy: ProjectSortBy) => void refetch: () => Promise } @@ -28,6 +71,157 @@ const ProjectsContext = createContext( undefined ) +const createURLQueryString = (params: Partial): 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 + 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[] = [] + + 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.includes(project.category as any)) + + if (category) { + return sortedProjectList.filter((project) => project.category === category) + } + + 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() @@ -45,7 +239,7 @@ async function fetchProjects(tag?: string) { return projects || [] } catch (error) { console.error("Error fetching projects:", error) - throw error // Let React Query handle the error + throw error } } @@ -55,31 +249,166 @@ interface ProjectsProviderProps { } export function ProjectsProvider({ children, tag }: ProjectsProviderProps) { + const [sortBy, setSortBy] = useState(DEFAULT_PROJECT_SORT_BY) + const [activeFilters, setActiveFilters] = useState>({}) + const [queryString, setQueryString] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const { - data: projects = [], + data: fetchedProjects = [], isLoading, isError, error, refetch, } = useQuery({ queryKey: ["projects", tag], - queryFn: async () => { - const projects = await fetchProjects() - console.log("projects for provider", projects) - return projects - }, + queryFn: () => fetchProjects(tag), }) + 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) => { + 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, + toggleFilter, + setFilterFromQueryString, + onFilterProject, + onSelectTheme, + sortProjectBy, + refetch, + }), + [ + projects, + researchs, + isLoading, + isError, + error, + filters, + activeFilters, + queryString, + searchQuery, + sortBy, + toggleFilter, + setFilterFromQueryString, + onFilterProject, + onSelectTheme, + sortProjectBy, + refetch, + ] + ) + return ( - + {children} ) diff --git a/components/blog/blog-article-related-projects.tsx b/components/blog/blog-article-related-projects.tsx index f5d7ecc..5413874 100644 --- a/components/blog/blog-article-related-projects.tsx +++ b/components/blog/blog-article-related-projects.tsx @@ -1,6 +1,7 @@ "use client" -import { useProjectFiltersState } from "@/state/useProjectFiltersState" +import { useMemo } from "react" +import { useProjects } from "@/app/providers/ProjectsProvider" import ProjectCard from "../project/project-card" interface BlogArticleRelatedProjectsProps { @@ -10,12 +11,13 @@ interface BlogArticleRelatedProjectsProps { export const BlogArticleRelatedProjects = ({ projectsIds, }: BlogArticleRelatedProjectsProps) => { - const { projects: allProjects, researchs: allResearchs } = - useProjectFiltersState((state) => state) + const { projects: allProjects, researchs: allResearchs } = useProjects() - const projects = [...allProjects, ...allResearchs].filter((project) => - projectsIds.includes(project.id) - ) + const projects = useMemo(() => { + return [...allProjects, ...allResearchs].filter((project) => + projectsIds.includes(project.id) + ) + }, [allProjects, allResearchs, projectsIds]) if (projects.length === 0) return null diff --git a/components/project/discover-more-projects.tsx b/components/project/discover-more-projects.tsx index 4f63c01..8eccfdd 100644 --- a/components/project/discover-more-projects.tsx +++ b/components/project/discover-more-projects.tsx @@ -1,8 +1,8 @@ "use client" +import { useMemo } from "react" import Link from "next/link" -import { projects } from "@/data/projects" -import { filterProjects } from "@/state/useProjectFiltersState" +import { useProjects } from "@/app/providers/ProjectsProvider" import { ProjectInterface } from "@/lib/types" import { shuffleArray } from "@/lib/utils" @@ -13,25 +13,28 @@ import ProjectCard from "./project-card" import { ProjectProps } from "@/app/(pages)/projects/[id]/page" export default function DiscoverMoreProjects({ project }: ProjectProps) { - const getSuggestedProjects = () => { - const projectList = projects.filter((p) => p.id !== project.id) + const { projects: allProjects, onFilterProject } = useProjects() - const suggestedProject = filterProjects({ - searchPattern: "", - activeFilters: project?.tags, - findAnyMatch: true, - projects: projectList, + const suggestedProject = useMemo(() => { + const projectList = allProjects.filter( + (p: ProjectInterface) => p.id !== project.id + ) + + // Filter projects by tags + onFilterProject("") + const suggestedProjects = projectList.filter((p) => { + const projectThemes = project.tags?.themes ?? [] + const pThemes = p.tags?.themes ?? [] + return projectThemes.some((tag) => pThemes.includes(tag)) }) // No match return random projects - if (suggestedProject?.length < 2) { + if (suggestedProjects?.length < 2) { return shuffleArray(projectList).slice(0, 2) } - return suggestedProject.slice(0, 2) - } - - const suggestedProject = getSuggestedProjects() + return suggestedProjects.slice(0, 2) + }, [allProjects, project.id, project.tags?.themes, onFilterProject]) return (
@@ -40,8 +43,8 @@ export default function DiscoverMoreProjects({ project }: ProjectProps) { {LABELS.COMMON.DISCOVER_MORE}
- {suggestedProject?.map((project: ProjectInterface, index: number) => ( - + {suggestedProject?.map((project: ProjectInterface) => ( + ))}
{ label: string } +interface TagGroup { + key: string + label: string + tags: string[] +} + const TagsWrapper = ({ label, children }: TagsProps) => { return (
@@ -30,42 +36,53 @@ type IProjectTags = { } export function ProjectTags({ project }: IProjectTags) { - const FilterKeyMapping: Record = { - 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, - } + const FilterKeyMapping = useMemo( + () => ({ + 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, + }), + [] + ) + + const filteredTags = useMemo( + () => + Object.entries(FilterLabelMapping) + .filter(([key]) => !["themes", "builtWith"].includes(key)) + .map(([key]) => { + const keyTags = project?.tags?.[key as ProjectFilter] + const hasItems = keyTags && keyTags?.length > 0 + + if (!hasItems) return null + + return { + key, + label: FilterKeyMapping[key as ProjectFilter] || key, + tags: keyTags, + } + }) + .filter((item): item is TagGroup => item !== null), + [project?.tags, FilterKeyMapping] + ) return (
- {Object.entries(FilterLabelMapping).map(([key]) => { - const keyTags = project?.tags?.[key as ProjectFilter] - const hasItems = keyTags && keyTags?.length > 0 - - if (["themes", "builtWith"].includes(key)) return null // keys to ignore - return ( - hasItems && ( -
- -
- {keyTags?.map((tag, index) => { - return ( - - - {tag} - - - ) - })} -
-
+ {filteredTags.map((tagGroup) => ( +
+ +
+ {tagGroup.tags?.map((tag, index) => ( + + + {tag} + + + ))}
- ) - ) - })} +
+
+ ))}
) } diff --git a/components/project/project-filters-bar.tsx b/components/project/project-filters-bar.tsx index ab51b67..847307f 100644 --- a/components/project/project-filters-bar.tsx +++ b/components/project/project-filters-bar.tsx @@ -3,16 +3,8 @@ import React, { ChangeEvent, ReactNode, useEffect, useState } from "react" import Image from "next/image" import { useRouter, useSearchParams } from "next/navigation" -import { projects } from "@/data/projects" import FiltersIcon from "@/public/icons/filters.svg" -import { - FilterLabelMapping, - FilterTypeMapping, - ProjectFilter, - useProjectFiltersState, -} from "@/state/useProjectFiltersState" import { useDebounce } from "react-use" - import { IThemeStatus, IThemesButton } from "@/types/common" import { ProjectCategories, @@ -24,7 +16,11 @@ import { } from "@/lib/types" import { cn, queryStringToObject } from "@/lib/utils" import { LABELS } from "@/app/labels" - +import { + useProjects, + ProjectFilter, + FilterLabelMapping, +} from "@/app/providers/ProjectsProvider" import { Icons } from "../icons" import Badge from "../ui/badge" import { Button } from "../ui/button" @@ -33,6 +29,14 @@ import { Checkbox } from "../ui/checkbox" import { Input } from "../ui/input" import { Modal } from "../ui/modal" +// Define the mapping for filter types +const FilterTypeMapping: Record = { + keywords: "checkbox", + builtWith: "checkbox", + themes: "button", + fundingSource: "checkbox", +} + interface FilterWrapperProps { label: string children?: ReactNode @@ -42,7 +46,9 @@ interface FilterWrapperProps { const FilterWrapper = ({ label, children, className }: FilterWrapperProps) => { return (
- {label} + + {label} + {children}
) @@ -81,8 +87,14 @@ export default function ProjectFiltersBar() { const [searchQuery, setSearchQuery] = useState("") const [filterCount, setFilterCount] = useState(0) - const { filters, toggleFilter, queryString, activeFilters, onFilterProject } = - useProjectFiltersState((state) => state) + const { + filters, + toggleFilter, + queryString, + activeFilters, + onFilterProject, + setFilterFromQueryString, + } = useProjects() useEffect(() => { if (!queryString) return @@ -91,10 +103,8 @@ export default function ProjectFiltersBar() { useEffect(() => { // set active filters from url - useProjectFiltersState.setState({ - activeFilters: queryStringToObject(searchParams), - }) - }, [searchParams]) + setFilterFromQueryString(queryStringToObject(searchParams)) + }, [searchParams, setFilterFromQueryString]) useEffect(() => { const count = Object.values(activeFilters).reduce((acc, curr) => { @@ -104,11 +114,7 @@ export default function ProjectFiltersBar() { }, [activeFilters]) const clearAllFilters = () => { - useProjectFiltersState.setState({ - activeFilters: {}, - queryString: "", - projects, - }) + setFilterFromQueryString({}) setSearchQuery("") // clear input router.push("/projects") } @@ -255,9 +261,7 @@ export default function ProjectFiltersBar() { ) => { setSearchQuery(e?.target?.value) - useProjectFiltersState.setState({ - searchQuery: e?.target?.value, - }) + onFilterProject(e?.target?.value) }} value={searchQuery} placeholder={LABELS.COMMON.SEARCH_PROJECT_PLACEHOLDER} diff --git a/components/project/project-list.tsx b/components/project/project-list.tsx index b6e7227..17fe471 100644 --- a/components/project/project-list.tsx +++ b/components/project/project-list.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react" import Image from "next/image" import NoResultIcon from "@/public/icons/no-result.svg" -import { useProjectFiltersState } from "@/state/useProjectFiltersState" import { cva } from "class-variance-authority" import { diff --git a/components/project/project-result-bar.tsx b/components/project/project-result-bar.tsx index c13b1ad..c006ac4 100644 --- a/components/project/project-result-bar.tsx +++ b/components/project/project-result-bar.tsx @@ -1,11 +1,10 @@ "use client" import { - DEFAULT_PROJECT_SORT_BY, ProjectFilter, ProjectSortBy, - useProjectFiltersState, -} from "@/state/useProjectFiltersState" + useProjects, +} from "@/app/providers/ProjectsProvider" import { LABELS } from "@/app/labels" import { interpolate } from "@/lib/utils" @@ -17,7 +16,7 @@ const labelClass = "h-5 text-xs text-base md:h-6 text-slate-900/70 md:text-sm" export const ProjectResultBar = () => { const { activeFilters, toggleFilter, projects, sortProjectBy, sortBy } = - useProjectFiltersState((state) => state) + useProjects() const haveActiveFilters = Object.entries(activeFilters).some( ([, values]) => values?.length > 0 @@ -61,7 +60,7 @@ export const ProjectResultBar = () => { {resultLabel} sortProjectBy(sortBy as ProjectSortBy)} disabled={!projects?.length} diff --git a/components/research/research-list.tsx b/components/research/research-list.tsx index f0a3412..3653f96 100644 --- a/components/research/research-list.tsx +++ b/components/research/research-list.tsx @@ -1,17 +1,18 @@ "use client" -import React, { useEffect, useState } from "react" +import React, { useEffect, useState, useMemo } from "react" import Image from "next/image" import NoResultIcon from "@/public/icons/no-result.svg" -import { useProjectFiltersState } from "@/state/useProjectFiltersState" import { cva } from "class-variance-authority" -import { ProjectStatus } from "@/lib/types" +import { ProjectInterface, ProjectStatus } from "@/lib/types" import { cn } from "@/lib/utils" import { LABELS } from "@/app/labels" +import { useProjects } from "@/app/providers/ProjectsProvider" import ResearchCard from "./research-card" import Link from "next/link" + const sectionTitleClass = cva( "relative font-sans text-base font-bold uppercase tracking-[3.36px] text-anakiwa-950 after:ml-8 after:absolute after:top-1/2 after:h-[1px] after:w-full after:translate-y-1/2 after:bg-anakiwa-300 after:content-['']" ) @@ -37,17 +38,28 @@ const ProjectStatusOrderList = ["active", "maintained", "inactive"] export const ResearchList = () => { const [isMounted, setIsMounted] = useState(false) - const { researchs, searchQuery, queryString } = useProjectFiltersState( - (state) => state - ) + const { researchs, searchQuery, queryString } = useProjects() const noItems = researchs?.length === 0 + const hasActiveFilters = searchQuery !== "" || queryString !== "" useEffect(() => { setIsMounted(true) }, []) - const hasActiveFilters = searchQuery !== "" || queryString !== "" + const { activeResearchs, pastResearchs } = useMemo(() => { + const active = researchs.filter( + (research: ProjectInterface) => + research.projectStatus === ProjectStatus.ACTIVE + ) + + const past = researchs.filter( + (research: ProjectInterface) => + research.projectStatus !== ProjectStatus.ACTIVE + ) + + return { activeResearchs: active, pastResearchs: past } + }, [researchs]) if (!isMounted) { return ( @@ -74,14 +86,6 @@ export const ResearchList = () => { if (noItems) return - const activeResearchs = researchs.filter( - (research) => research.projectStatus === ProjectStatus.ACTIVE - ) - - const pastResearchs = researchs.filter( - (research) => research.projectStatus !== ProjectStatus.ACTIVE - ) - return (
{
)}
- {activeResearchs.map((project) => { - return ( - - ) - })} + {activeResearchs.map((project: ProjectInterface) => ( + + ))}
@@ -120,17 +122,15 @@ export const ResearchList = () => {
- {pastResearchs.map((project) => { - return ( - - {project.name} - - ) - })} + {pastResearchs.map((project: ProjectInterface) => ( + + {project.name} + + ))}
diff --git a/lib/content.ts b/lib/content.ts index ffb7b09..6a27f9f 100644 --- a/lib/content.ts +++ b/lib/content.ts @@ -10,6 +10,11 @@ export interface Project { [key: string]: any } +export interface ArticleTag { + id: string + name: string +} + export interface Article { id: string title: string @@ -22,7 +27,7 @@ export interface Article { publicKey?: string hash?: string canonical?: string - tags?: { id: string; name: string }[] + tags: ArticleTag[] projects?: string[] } @@ -107,22 +112,32 @@ export function getArticles(options?: { directory: articlesDirectory, excludeFiles: ["readme", "_readme", "_article-template"], processContent: (data, content, id) => { - // Ensure tags are always an array, combining 'tags' and 'tag' - const tags = [ + // Normalize tags from both 'tags' and 'tag' fields + const rawTags = [ ...(Array.isArray(data?.tags) ? data.tags : []), ...(data?.tag ? [data.tag] : []), ] + // Process and normalize tags + const normalizedTags = rawTags + .map((tag) => { + if (typeof tag !== "string") return null + const name = tag.trim() + if (!name) return null + return { + id: name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""), + name, + } + }) + .filter((tag): tag is ArticleTag => tag !== null) + return { id, ...data, - tags: tags.map((tag) => ({ - id: tag - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-]/g, ""), - name: tag, - })), + tags: normalizedTags, content, } }, @@ -132,8 +147,9 @@ export function getArticles(options?: { // Filter by tag if provided if (tag) { + const tagId = tag.toLowerCase() filteredArticles = filteredArticles.filter((article) => - article.tags?.some((t) => t.id === tag) + article.tags?.some((t) => t.id === tagId) ) } @@ -189,13 +205,17 @@ export function getProjects(options?: { export const getArticleTags = () => { const articles = getArticles() - const allTags = - articles - .map((article) => article.tags?.map((t) => t.name)) - .flat() - .filter(Boolean) ?? [] + const allTags = articles.reduce((acc, article) => { + article.tags?.forEach((tag) => { + if (!acc.some((t) => t.id === tag.id)) { + acc.push(tag) + } + }) + return acc + }, []) - return Array.from(new Set(allTags)) as string[] + // Sort tags alphabetically by name + return allTags.sort((a, b) => a.name.localeCompare(b.name)) } export const getArticleTagsWithIds = () => { diff --git a/state/useProjectFiltersState.ts b/state/useProjectFiltersState.ts deleted file mode 100644 index 76d392a..0000000 --- a/state/useProjectFiltersState.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { projects } from "@/data/projects" -import Fuse from "fuse.js" -import { create } from "zustand" - -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 -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, // sort from most relevant to least relevant -} - -export const FilterLabelMapping: Record = { - 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, -} - -export const FilterTypeMapping: Partial< - Record -> = { - keywords: "checkbox", - builtWith: "checkbox", - themes: "button", - fundingSource: "checkbox", -} -interface ProjectStateProps { - sortBy: ProjectSortBy - projects: ProjectInterface[] - researchs: ProjectInterface[] - filters: FiltersProps - activeFilters: Partial - queryString: string - searchQuery: string -} - -interface SearchMatchByParamsProps { - searchPattern: string - activeFilters?: Partial - findAnyMatch?: boolean - projects?: ProjectInterface[] -} - -interface toggleFilterProps { - tag: ProjectFilter - value: string - searchQuery?: string -} -interface ProjectActionsProps { - toggleFilter: ({ tag, value, searchQuery }: toggleFilterProps) => void - setFilterFromQueryString: (filters: Partial) => void - onFilterProject: (searchPattern: string) => void - onSelectTheme: (theme: string, searchPattern?: string) => void - sortProjectBy: (sortBy: ProjectSortBy) => void -} - -const createURLQueryString = (params: Partial): string => { - if (Object.keys(params)?.length === 0) return "" // no params, return empty string - const qs = Object.keys(params) - .map((key: any) => `${key}=${encodeURIComponent((params as any)[key])}`) - .join("&") - - return qs -} - -const getProjectFilters = (): FiltersProps => { - const filters: FiltersProps = { - themes: ["play", "build", "research"], - keywords: [], - builtWith: [], - fundingSource: [], - } - - // get list of all tags from project list - 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() : "" - ) - ) - } - }) - - // duplicate-free array for every tags - Object.entries(filters).forEach(([key, entries]) => { - filters[key as ProjectFilter] = uniq(entries) - }) - - return filters -} - -export const filterProjects = ({ - searchPattern = "", - activeFilters = {}, - findAnyMatch = false, - 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", - "tldr", - "tags.themes", - "tags.keywords", - "tags.builtWith", - "projectStatus", - ] - - const tagsFiltersQuery: Record[] = [] - - Object.entries(activeFilters).forEach(([key, values]) => { - values.forEach((value) => { - if (!value) return // skip empty values - tagsFiltersQuery.push({ - [`tags.${key}`]: value, - }) - }) - }) - - const noActiveFilters = - tagsFiltersQuery.length === 0 && searchPattern.length === 0 - - if (noActiveFilters) return projectList - - let query: any = {} - - if (findAnyMatch) { - // find any match of filters - 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 }) => { - return { - ...item, - score, // 0 indicates a perfect match, while a score of 1 indicates a complete mismatch. - } - }) - 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.includes(project.category as any)) - - if (category) { - return sortedProjectList.filter((project) => project.category === category) - } - - return sortedProjectList.map((project: any) => { - return { - id: project?.id?.toLowerCase(), // force lowercase id - ...project, - } - }) -} - -export const useProjectFiltersState = create< - ProjectStateProps & ProjectActionsProps ->()((set) => ({ - sortBy: DEFAULT_PROJECT_SORT_BY, - projects: sortProjectByFn({ - projects, - ignoreCategories: [ProjectCategory.RESEARCH], - }), - researchs: sortProjectByFn({ - projects, - sortBy: DEFAULT_PROJECT_SORT_BY, - category: ProjectCategory.RESEARCH, - ignoreCategories: [ProjectCategory.DEVTOOLS, ProjectCategory.APPLICATION], - }), - queryString: "", - searchQuery: "", - filters: getProjectFilters(), // list of filters with all possible values from projects - activeFilters: {}, // list of filters active in the current view by the user - toggleFilter: ({ tag: filterKey, value, searchQuery }: toggleFilterProps) => - 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 activeFiltersNormalized = values.filter(Boolean) - - const activeFilters: Partial = { - ...state.activeFilters, - [filterKey]: activeFiltersNormalized, - } - const queryString = createURLQueryString(activeFilters) - const filteredProjects = filterProjects({ - searchPattern: searchQuery ?? "", - activeFilters, - }) - - return { - ...state, - activeFilters, - queryString, - projects: sortProjectByFn({ - projects: filteredProjects, - sortBy: state.sortBy, - }), - } - }), - onSelectTheme: (theme: string, searchQuery = "") => { - set((state: any) => { - // toggle theme when it's already selected - const themes = state?.activeFilters?.themes?.includes(theme) - ? [] - : [theme] - - const activeFilters = { - ...state.activeFilters, - themes, - } - - const filteredProjects = filterProjects({ - searchPattern: searchQuery ?? "", - activeFilters, - }) - - return { - ...state, - activeFilters, - projects: sortProjectByFn({ - projects: filteredProjects, - sortBy: state.sortBy, - }), - searchQuery, - } - }) - }, - onFilterProject: (searchPattern: string) => { - set((state: any) => { - const filteredProjects = filterProjects({ - searchPattern, - activeFilters: state.activeFilters, - }) - - return { - ...state, - projects: sortProjectByFn({ - projects: filteredProjects, - sortBy: state.sortBy, - ignoreCategories: [], // when filtering, show all projects regardless of category - }), - } - }) - }, - setFilterFromQueryString: (filters: Partial) => { - set((state: any) => { - return { - ...state, - activeFilters: filters, - queryString: createURLQueryString(filters), - } - }) - }, - sortProjectBy(sortBy: ProjectSortBy) { - set((state: any) => { - return { - ...state, - sortBy, - projects: sortProjectByFn({ - projects: state.projects, - sortBy, - }), - } - }) - }, -}))