import { projects } from '@/data/projects' import Fuse from 'fuse.js' import i18next from 'i18next' import { create } from 'zustand' import { ProjectCategory, ProjectInterface } from '@/lib/types' import { uniq } from '@/lib/utils' import { LocaleTypes, fallbackLng } from '@/app/i18n/settings' 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 = ( lang?: LocaleTypes ): Record => { const t = i18next.getFixedT(lang ?? fallbackLng, 'common') return { keywords: t('filterLabels.keywords'), builtWith: t('filterLabels.builtWith'), themes: t('filterLabels.themes'), fundingSource: t('filterLabels.fundingSource'), } } export const FilterTypeMapping: Partial< Record > = { keywords: 'checkbox', builtWith: 'checkbox', themes: 'button', fundingSource: 'checkbox', } interface ProjectStateProps { sortBy: ProjectSortBy projects: ProjectInterface[] filters: FiltersProps activeFilters: Partial queryString: string searchQuery: string currentCategory: ProjectCategory | null } 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 setCurrentCategory: (section: ProjectCategory | null) => 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: projectList = projects, }: SearchMatchByParamsProps) => { // 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: ProjectInterface[], sortBy: ProjectSortBy ) => { const sortedProjectList: ProjectInterface[] = [ ...(projects as ProjectInterfaceScore[]), ].sort(SortByFnMapping[sortBy]) return sortedProjectList } export const useProjectFiltersState = create< ProjectStateProps & ProjectActionsProps >()((set) => ({ currentCategory: null, sortBy: DEFAULT_PROJECT_SORT_BY, projects: sortProjectByFn(projects, DEFAULT_PROJECT_SORT_BY), 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(filteredProjects, 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(filteredProjects, state.sortBy), searchQuery, } }) }, onFilterProject: (searchPattern: string) => { set((state: any) => { const filteredProjects = filterProjects({ searchPattern, activeFilters: state.activeFilters, }) return { ...state, projects: sortProjectByFn(filteredProjects, state.sortBy), } }) }, setFilterFromQueryString: (filters: Partial) => { set((state: any) => { return { ...state, activeFilters: filters, queryString: createURLQueryString(filters), } }) }, sortProjectBy(sortBy: ProjectSortBy) { set((state: any) => { return { ...state, sortBy, projects: sortProjectByFn(state.projects, sortBy), } }) }, setCurrentCategory(category: ProjectCategory | null) { set((state: any) => { return { ...state, projects: projects.filter((project) => { if (category == null) return true // return all projects return project?.category === category }), currentCategory: category, } }) }, }))