From 3e67205124d1373b58b2e705524d10dae230461c Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 18 Feb 2025 16:08:11 +0100 Subject: [PATCH] feat: implement search on the frontend --- apps/web/components/Objects/Menus/OrgMenu.tsx | 12 ++ .../components/Objects/Search/SearchBar.tsx | 147 ++++++++++++++++++ apps/web/hooks/useDebounce.ts | 17 ++ apps/web/services/courses/courses.ts | 16 ++ 4 files changed, 192 insertions(+) create mode 100644 apps/web/components/Objects/Search/SearchBar.tsx create mode 100644 apps/web/hooks/useDebounce.ts diff --git a/apps/web/components/Objects/Menus/OrgMenu.tsx b/apps/web/components/Objects/Menus/OrgMenu.tsx index 22eef5c2..45ef6ad8 100644 --- a/apps/web/components/Objects/Menus/OrgMenu.tsx +++ b/apps/web/components/Objects/Menus/OrgMenu.tsx @@ -1,12 +1,14 @@ 'use client' import React from 'react' import Link from 'next/link' +import { Search } from 'lucide-react' import { getUriWithOrg } from '@services/config/config' import { HeaderProfileBox } from '@components/Security/HeaderProfileBox' import MenuLinks from './OrgMenuLinks' import { getOrgLogoMediaDirectory } from '@services/media/media' import { useLHSession } from '@components/Contexts/LHSessionContext' import { useOrg } from '@components/Contexts/OrgContext' +import { SearchBar } from '@components/Objects/Search/SearchBar' export const OrgMenu = (props: any) => { const orgslug = props.orgslug @@ -50,6 +52,12 @@ export const OrgMenu = (props: any) => { + + {/* Search Section */} +
+ +
+
@@ -77,6 +85,10 @@ export const OrgMenu = (props: any) => { }`} >
+ {/* Mobile Search */} +
+ +
diff --git a/apps/web/components/Objects/Search/SearchBar.tsx b/apps/web/components/Objects/Search/SearchBar.tsx new file mode 100644 index 00000000..a76c0c1e --- /dev/null +++ b/apps/web/components/Objects/Search/SearchBar.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Search } from 'lucide-react'; +import { searchOrgCourses } from '@services/courses/courses'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import Link from 'next/link'; +import { getCourseThumbnailMediaDirectory } from '@services/media/media'; +import { useDebounce } from '@/hooks/useDebounce'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { getUriWithOrg } from '@services/config/config'; +import { removeCoursePrefix } from '../Thumbnails/CourseThumbnail'; + +interface Course { + name: string; + description: string; + thumbnail_image: string; + course_uuid: string; + authors: Array<{ + first_name: string; + last_name: string; + avatar_image: string; + }>; +} + +interface SearchBarProps { + orgslug: string; + className?: string; + isMobile?: boolean; +} + +export const SearchBar: React.FC = ({ orgslug, className = '', isMobile = false }) => { + const org = useOrg() as any; + const [searchQuery, setSearchQuery] = useState(''); + const [courses, setCourses] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [showResults, setShowResults] = useState(false); + const searchRef = useRef(null); + const session = useLHSession() as any; + const debouncedSearch = useDebounce(searchQuery, 300); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setShowResults(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const fetchCourses = async () => { + if (debouncedSearch.trim().length === 0) { + setCourses([]); + return; + } + + setIsLoading(true); + try { + const results = await searchOrgCourses( + orgslug, + debouncedSearch, + 1, + 5, + null, + session?.data?.tokens?.access_token + ); + setCourses(results); + } catch (error) { + console.error('Error searching courses:', error); + setCourses([]); + } + setIsLoading(false); + }; + fetchCourses(); + }, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]); + + const handleSearchFocus = () => { + if (searchQuery.trim().length > 0) { + setShowResults(true); + } + }; + + return ( +
+
+ { + setSearchQuery(e.target.value); + setShowResults(true); + }} + onFocus={handleSearchFocus} + placeholder="Search courses..." + className="w-full h-9 pl-10 pr-4 rounded-lg border border-gray-200 bg-white/50 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-transparent text-sm placeholder:text-gray-400" + /> + +
+ + {showResults && (searchQuery.trim().length > 0 || isLoading) && ( +
+ {isLoading ? ( +
+
Searching...
+
+ ) : courses.length > 0 ? ( +
+ {courses.map((course) => ( + setShowResults(false)} + > +
+ {course.thumbnail_image && ( + {course.name} + )} +
+

{course.name}

+

{course.description}

+ {course.authors && course.authors[0] && ( +

+ by {course.authors[0].first_name} {course.authors[0].last_name} +

+ )} +
+
+ + ))} +
+ ) : ( +
+ No courses found +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/web/hooks/useDebounce.ts b/apps/web/hooks/useDebounce.ts new file mode 100644 index 00000000..76324598 --- /dev/null +++ b/apps/web/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/apps/web/services/courses/courses.ts b/apps/web/services/courses/courses.ts index ba6a1e90..e9796148 100644 --- a/apps/web/services/courses/courses.ts +++ b/apps/web/services/courses/courses.ts @@ -24,6 +24,22 @@ export async function getOrgCourses( return res } +export async function searchOrgCourses( + org_slug: string, + query: string, + page: number = 1, + limit: number = 10, + next: any, + access_token?: any +) { + const result: any = await fetch( + `${getAPIUrl()}courses/org_slug/${org_slug}/search?query=${encodeURIComponent(query)}&page=${page}&limit=${limit}`, + RequestBodyWithAuthHeader('GET', null, next, access_token) + ) + const res = await errorHandling(result) + return res +} + export async function getCourseMetadata( course_uuid: any, next: any,