diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 2afd99e4..700ae7f6 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -24,6 +24,7 @@ from src.services.courses.courses import ( update_course, delete_course, update_course_thumbnail, + search_courses, ) from src.services.courses.updates import ( create_update, @@ -146,6 +147,24 @@ async def api_get_course_by_orgslug( ) +@router.get("/org_slug/{org_slug}/search") +async def api_search_courses( + request: Request, + org_slug: str, + query: str, + page: int = 1, + limit: int = 10, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> List[CourseRead]: + """ + Search courses by title and description + """ + return await search_courses( + request, current_user, org_slug, query, db_session, page, limit + ) + + @router.put("/{course_uuid}") async def api_update_course( request: Request, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 2233d9c9..42b9b49d 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,6 +1,6 @@ from typing import Literal, List from uuid import uuid4 -from sqlmodel import Session, select, or_, and_ +from sqlmodel import Session, select, or_, and_, text from src.db.usergroup_resources import UserGroupResource from src.db.usergroup_user import UserGroupUser from src.db.organizations import Organization @@ -214,6 +214,80 @@ async def get_courses_orgslug( return course_reads +async def search_courses( + request: Request, + current_user: PublicUser | AnonymousUser, + org_slug: str, + search_query: str, + db_session: Session, + page: int = 1, + limit: int = 10, +) -> List[CourseRead]: + offset = (page - 1) * limit + + # Base query + query = ( + select(Course) + .join(Organization) + .where(Organization.slug == org_slug) + .where( + or_( + text(f"LOWER(course.name) LIKE LOWER('%{search_query}%')"), + text(f"LOWER(course.description) LIKE LOWER('%{search_query}%')"), + text(f"LOWER(course.about) LIKE LOWER('%{search_query}%')"), + text(f"LOWER(course.learnings) LIKE LOWER('%{search_query}%')"), + text(f"LOWER(course.tags) LIKE LOWER('%{search_query}%')") + ) + ) + ) + + if isinstance(current_user, AnonymousUser): + # For anonymous users, only show public courses + query = query.where(Course.public == True) + else: + # For authenticated users, show: + # 1. Public courses + # 2. Courses not in any UserGroup + # 3. Courses in UserGroups where the user is a member + # 4. Courses where the user is a resource author + query = ( + query + .outerjoin(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore + .outerjoin(UserGroupUser, and_( + UserGroupUser.usergroup_id == UserGroupResource.usergroup_id, + UserGroupUser.user_id == current_user.id + )) + .outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore + .where(or_( + Course.public == True, + UserGroupResource.resource_uuid == None, # Courses not in any UserGroup # noqa: E711 + UserGroupUser.user_id == current_user.id, # Courses in UserGroups where user is a member + ResourceAuthor.user_id == current_user.id # Courses where user is a resource author + )) + ) + + # Apply pagination + query = query.offset(offset).limit(limit).distinct() + + courses = db_session.exec(query).all() + + # Fetch authors for each course + course_reads = [] + for course in courses: + authors_query = ( + select(User) + .join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_query).all() + + course_read = CourseRead.model_validate(course) + course_read.authors = [UserRead.model_validate(author) for author in authors] + course_reads.append(course_read) + + return course_reads + + async def create_course( request: Request, org_id: int, 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,