Skip to content

Commit 169ddac

Browse files
Merge pull request #234 from Povindu/feat/povindu/search-filters
feat: advanced search page and search filtering
2 parents 38bb78d + e8a6712 commit 169ddac

File tree

11 files changed

+381
-13
lines changed

11 files changed

+381
-13
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@tailwindcss/typography": "^0.5.14",
5858
"@types/node": "^22.14.1",
5959
"cross-env": "^7.0.3",
60+
"pagefind": "^1.3.0",
6061
"remark-code-import": "^1.2.0"
6162
}
6263
}

src/components/CheckBox.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react";
2+
3+
interface ColorCheckboxProps {
4+
checked: boolean;
5+
onChange: (checked: boolean) => void;
6+
label?: string;
7+
}
8+
9+
export const ColorCheckbox: React.FC<ColorCheckboxProps> = ({
10+
checked,
11+
onChange,
12+
label,
13+
}) => {
14+
return (
15+
<label className="inline-flex items-center space-x-2 cursor-pointer">
16+
<input
17+
type="checkbox"
18+
checked={checked}
19+
onChange={(e) => onChange(e.target.checked)}
20+
className={`h-5 w-5 rounded border-2 accent-superOfficeGreen`}
21+
/>
22+
{label && <span className="text-sm text-gray-700">{label}</span>}
23+
</label>
24+
);
25+
};

src/components/Header.astro

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Icon } from "astro-icon/components";
33
import Hamburger from "./Hamburger.astro";
44
import logo from "@assets/logo.svg";
55
import { trimSlash } from "@scripts/utils";
6-
import Search from "@components/Search.astro";
76
87
const base = import.meta.env.BASE_URL;
98
@@ -102,12 +101,35 @@ const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
102101
))
103102
}
104103
</ul>
105-
<div class="md:pl-3 md:pt-0.5">
106-
<Search
107-
id="search"
108-
className="pagefind-ui"
109-
uiOptions={{ showImages: false, excerptLength: 15 }}
110-
/>
104+
<div class="md:pl-3 flex justify-center items-center">
105+
<form
106+
class="flex items-center w-full max-w-md border border-gray-300 rounded-md"
107+
action=`${base}/search`
108+
method="GET"
109+
>
110+
<input
111+
type="text"
112+
name="query"
113+
placeholder="Search"
114+
required
115+
class="px-4 py-[2px] focus:outline-none"
116+
/>
117+
<button
118+
type="submit"
119+
class="bg-superOfficeGreen hover:bg-[#0b4642] py-1 px-2 rounded-r-md"
120+
>
121+
<svg
122+
xmlns="http://www.w3.org/2000/svg"
123+
width="20"
124+
height="24"
125+
fill="#FFFFFF"
126+
viewBox="0 0 256 256"
127+
><path
128+
d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"
129+
></path></svg
130+
>
131+
</button>
132+
</form>
111133
</div>
112134
</div>
113135
</nav>

src/components/SearchPage.tsx

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { useEffect, useState } from "react";
2+
import { ColorCheckbox } from "./CheckBox";
3+
import { trimFileExtension } from "@utils/slugUtils"
4+
import {
5+
CaretDown, CaretUp
6+
} from "@phosphor-icons/react";
7+
8+
const baseUrl = import.meta.env.BASE_URL ?? "" // Currently baseUrl is /docs-next/
9+
10+
type filterType = {
11+
[filterGroupName: string]: {
12+
[filterItemName: string]: number
13+
};
14+
}
15+
16+
type filterListType = {
17+
[filterGroupName: string]: {
18+
"any": string[]
19+
};
20+
}
21+
22+
type currentFiltersCollection = {
23+
filterGroup: string;
24+
filterName: string;
25+
active: boolean;
26+
}
27+
28+
export default function PagefindSearch() {
29+
const [query, setQuery] = useState("");
30+
const [filters, setFilters] = useState<filterType>({});
31+
const [results, setResults] = useState<any[]>([]);
32+
const [filterState, setFilterState] = useState<currentFiltersCollection[]>([]);
33+
const [isFiltersChanged, setIsFiltersChanged] = useState<boolean>(false);
34+
const [loading, setLoading] = useState<boolean>(false);
35+
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(true);
36+
37+
38+
const capitalizeFirstLetter = (str: string) => {
39+
if (typeof str !== 'string' || str.length === 0) {
40+
return str;
41+
}
42+
return str.charAt(0).toUpperCase() + str.slice(1);
43+
}
44+
45+
46+
const checkboxOnChange = (filterGroup: string, filterName: string): void => {
47+
setIsFiltersChanged(true)
48+
setFilterState(filterState.map(
49+
(item) => (item.filterGroup == filterGroup && item.filterName == filterName) ? { ...item, active: !item.active } : item))
50+
}
51+
52+
const getFilterState = (filterGroup: string, filterName: string): boolean => {
53+
return filterState.find(item => item.filterGroup == filterGroup && item.filterName == filterName)?.active ?? false
54+
}
55+
56+
const getActiveFilters = () => {
57+
let tempFilterList: filterListType = {}
58+
filterState.filter((item) => item.active).map((item) => {
59+
if (tempFilterList[item.filterGroup] === undefined) {
60+
tempFilterList[item.filterGroup] = {
61+
"any": []
62+
}
63+
}
64+
tempFilterList[item.filterGroup].any.push(item.filterName)
65+
})
66+
return tempFilterList
67+
}
68+
69+
const setPreferredLanguage = () => {
70+
71+
let langArray: string[] = [];
72+
73+
filterState.filter(item => item.active && item.filterGroup == "language").map(
74+
item => {
75+
langArray.push(item.filterName)
76+
}
77+
)
78+
localStorage.setItem("SuperOfficeDocs-lang", JSON.stringify(langArray))
79+
}
80+
81+
const applyFilerChanges = () => {
82+
// Pass filterOnly param value based on whether query is empty
83+
doSearch(query == "")
84+
//set user's preferred language
85+
setPreferredLanguage()
86+
}
87+
88+
let _pagefind: any;
89+
90+
async function getPagefind(): Promise<any> {
91+
if (_pagefind) {
92+
return Promise.resolve(_pagefind)
93+
}
94+
try {
95+
_pagefind = await import(/* @vite-ignore */ `${baseUrl}/pagefind/pagefind.js`);
96+
return _pagefind
97+
}
98+
catch (error) {
99+
console.error("Pagefind search failed:", error);
100+
throw error
101+
}
102+
}
103+
104+
async function setInitialFilters() {
105+
106+
107+
108+
const selectedLanguage = JSON.parse(localStorage.getItem("SuperOfficeDocs-lang") ?? "")
109+
console.log("se", selectedLanguage)
110+
let pagefind = await getPagefind();
111+
const currentFilters: filterType = await pagefind.filters();
112+
setFilters(currentFilters)
113+
114+
let filterStateTemp: currentFiltersCollection[] = [];
115+
116+
Object.entries(currentFilters).map(([filterGroupName, filterGroup]) => {
117+
Object.entries(filterGroup).map(([filterItemName]) => {
118+
filterStateTemp.push({
119+
filterGroup: filterGroupName,
120+
filterName: filterItemName,
121+
active: (filterGroupName == "language" && selectedLanguage.includes(filterItemName)) ? true : false
122+
})
123+
})
124+
})
125+
// console.log(filterStateTemp)
126+
setFilterState(filterStateTemp)
127+
}
128+
129+
async function doSearch(filterOnly: boolean) {
130+
try {
131+
setLoading(true)
132+
let pagefind = await getPagefind();
133+
const currentFilterArray = getActiveFilters();
134+
console.log(currentFilterArray)
135+
const searchResponse = await pagefind.search(
136+
filterOnly ? null : query,
137+
{
138+
filters: currentFilterArray
139+
});
140+
const detailedResults = await Promise.all(
141+
(searchResponse.results || []).map((r: any) => r.data())
142+
);
143+
144+
setResults(detailedResults);
145+
setFilters(searchResponse.filters)
146+
setIsFiltersChanged(false);
147+
} catch (error) {
148+
console.error("Pagefind search failed:", error);
149+
setResults([]);
150+
}
151+
finally {
152+
setLoading(false)
153+
}
154+
}
155+
156+
useEffect(() => {
157+
if (!query) {
158+
setResults([]);
159+
setInitialFilters();
160+
return;
161+
}
162+
doSearch(false);
163+
164+
}, [query]);
165+
166+
useEffect(() => {
167+
const params = new URLSearchParams(window.location.search);
168+
setQuery(params.get("query") || "");
169+
setInitialFilters();
170+
}, [])
171+
172+
173+
174+
return (
175+
<div className="w-full">
176+
{/* Search Hero */}
177+
<div className="w-full px-6 py-3 md:px-28 lg:px-72 lg:py-6 bg-gradient-to-r from-[#31b494] to-[#0a5e58] text-white lg:h-20 flex justify-center items-center">
178+
<input
179+
placeholder="Search"
180+
className="w-full h-10 rounded-full px-5 text-black"
181+
type="search"
182+
value={query}
183+
onChange={(e) => setQuery(e.target.value)} />
184+
</div>
185+
186+
{/* Search Body*/}
187+
188+
{loading ?
189+
<div className="w-full h-96 flex justify-center items-center">
190+
{/* Loading animation */}
191+
<div
192+
className="inline-block h-8 w-8 animate-spin text-superOfficeGreen rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
193+
role="status">
194+
</div>
195+
</div>
196+
:
197+
<div className="flex flex-col p-2 md:flex-row md:mt-2 md:pl-6 md:pt-4">
198+
199+
{/* Filters Menu */}
200+
<div className={`bg-lightTealGray py-2 px-4 md:py-4 w-full md:w-[25%] h-fit rounded-lg mb-3`}>
201+
202+
<div className={`flex flex-row justify-between ${filtersExpanded && "border-b-[1px] pb-3"} border-black`}>
203+
<p className="font-bold">Filters</p>
204+
<button onClick={() => setFiltersExpanded(!filtersExpanded)}>{filtersExpanded ? <CaretUp /> : <CaretDown />}</button>
205+
</div>
206+
207+
{filtersExpanded && <>
208+
209+
{/* filter list */}
210+
<ul className={`flex flex-col overflow-y-auto h-fit max-h-80`}>
211+
{Object.entries(filters).map(([filterGroupName, filterGroup], index) => (
212+
<li className="mt-5" key={index + filterGroupName}>
213+
<p className="font-semibold mb-2">By {capitalizeFirstLetter(filterGroupName)}</p>
214+
<ul>
215+
{Object.entries(filterGroup).map(([filterItemName, count], index) => (
216+
<li className="ml-2 flex items-center" key={index + filterItemName}>
217+
<ColorCheckbox
218+
checked={getFilterState(filterGroupName, filterItemName)}
219+
onChange={() => checkboxOnChange(filterGroupName, filterItemName)}
220+
/>
221+
<label className="ml-2" htmlFor={filterItemName}>
222+
{filterItemName != "" ? capitalizeFirstLetter(filterItemName) : "No filter"} ({count})
223+
</label>
224+
</li>
225+
))}
226+
</ul>
227+
</li>
228+
))}
229+
</ul>
230+
231+
{/* Filter buttons */}
232+
<div className="flex justify-end gap-2">
233+
<button onClick={() => setInitialFilters()} className={` mt-5 rounded-lg px-3 py-1 w-fit text-superOfficeGreen hover:text-red-400`}>Reset</button>
234+
<button onClick={applyFilerChanges} className={` mt-5 rounded-lg px-3 py-1 w-fit ${(isFiltersChanged) ? "bg-superOfficeGreen text-white hover:shadow-md" : "bg-gray-200 text-slate-500"}`} disabled={!(isFiltersChanged)}>Set Filters</button>
235+
</div>
236+
</>
237+
}
238+
</div>
239+
240+
{/* Results Section*/}
241+
<div className="w-full px-2 md:pl-8">
242+
243+
{/* Search Results text */}
244+
<div className="h-10">
245+
{((results.length > 0 || query) && !loading) ? <p>
246+
Found {results.length} results
247+
{query && (
248+
<>
249+
{' for '}
250+
<strong>{query}</strong>
251+
</>
252+
)}
253+
</p> : <p className="w-full md:text-lg md:p-3">Type in a term to search</p>}
254+
</div>
255+
256+
{/* Results list */}
257+
<ul className="h-[400px] 2xl:h-[600px] overflow-y-auto overflow-x-hidden">
258+
{/* {results.length === 0 && (query && <li>No results found</li>)} */}
259+
{results.map((result: any, index: number) => (
260+
<li className="flex flex-col mb-5" key={index + result.meta.title}>
261+
<a className="font-semibold text-lg text-superOfficeGreen hover:text-black hover:underline" href={trimFileExtension(result.url)}>{result.meta.title}</a>
262+
<p className="search-snippet" dangerouslySetInnerHTML={{ __html: result.excerpt }}></p>
263+
</li>
264+
))}
265+
</ul>
266+
</div>
267+
</div>}
268+
</div>
269+
);
270+
}

src/layouts/Base.astro

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ export interface Props {
1111
title: string;
1212
};
1313
lang: string;
14+
scroll?: boolean;
1415
}
1516
16-
const { metadata, lang = "en" } = Astro.props;
17+
const { metadata, lang = "en", scroll = true } = Astro.props;
1718
---
1819

19-
<html lang={lang}>
20+
<html>
2021
<head>
2122
<meta charset="utf-8" />
2223
<link rel="icon" type="image/x-icon" href={favIcon} />
@@ -28,7 +29,10 @@ const { metadata, lang = "en" } = Astro.props;
2829
</head>
2930
<body class="h-screen flex flex-col overflow-hidden">
3031
<div class="z-50"><Header {...headerData} /></div>
31-
<div class="min-h-screen overflow-y-auto" data-scroll-container>
32+
<div
33+
class=`min-h-screen ${scroll && "overflow-y-auto"}`
34+
data-scroll-container
35+
>
3236
<main class="flex-grow mb-[140px] md:mb-[120px]">
3337
<slot />
3438
</main>

0 commit comments

Comments
 (0)