From 12e1d79504efad9103ce408e8f3e054684a8dbb5 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 6 Apr 2025 22:13:26 +0200 Subject: [PATCH] feat: introduce search page + improvements to the search experience --- apps/api/src/services/search/search.py | 5 + .../orgs/[orgslug]/(withmenu)/search/page.tsx | 434 ++++++++++++++++++ .../components/Objects/Search/SearchBar.tsx | 72 +-- apps/web/styles/globals.css | 4 + 4 files changed, 479 insertions(+), 36 deletions(-) create mode 100644 apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx diff --git a/apps/api/src/services/search/search.py b/apps/api/src/services/search/search.py index e9077549..e3fda087 100644 --- a/apps/api/src/services/search/search.py +++ b/apps/api/src/services/search/search.py @@ -8,6 +8,7 @@ from src.db.courses.courses import Course, CourseRead from src.db.collections import Collection, CollectionRead from src.db.collections_courses import CollectionCourse from src.db.organizations import Organization +from src.db.user_organizations import UserOrganization from src.services.courses.courses import search_courses T = TypeVar('T') @@ -60,6 +61,10 @@ async def search_across_org( # Search users users_query = ( select(User) + .join(UserOrganization, and_( + UserOrganization.user_id == User.id, + UserOrganization.org_id == org.id + )) .where( or_( text('LOWER("user".username) LIKE LOWER(:pattern) OR ' + diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx new file mode 100644 index 00000000..29dc8ba0 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx @@ -0,0 +1,434 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { searchOrgContent } from '@services/search/search'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { Book, GraduationCap, Users, Search, Filter, X } from 'lucide-react'; +import Link from 'next/link'; +import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'; +import { getUriWithOrg } from '@services/config/config'; +import { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail'; +import UserAvatar from '@components/Objects/UserAvatar'; + +// Types from SearchBar component +interface User { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + bio: string; + details: Record; + profile: Record; + id: number; + user_uuid: string; +} + +interface Author { + user: User; + authorship: string; + authorship_status: string; + creation_date: string; + update_date: string; +} + +interface Course { + name: string; + description: string; + about: string; + learnings: string; + tags: string; + thumbnail_image: string; + public: boolean; + open_to_contributors: boolean; + id: number; + org_id: number; + authors: Author[]; + course_uuid: string; + creation_date: string; + update_date: string; +} + +interface Collection { + name: string; + public: boolean; + description: string; + id: number; + courses: string[]; + collection_uuid: string; + creation_date: string; + update_date: string; +} + +interface SearchResults { + courses: Course[]; + collections: Collection[]; + users: User[]; + total_courses: number; + total_collections: number; + total_users: number; +} + +type ContentType = 'all' | 'courses' | 'collections' | 'users'; + +function SearchPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const session = useLHSession() as any; + const org = useOrg() as any; + + // Search state + const [searchResults, setSearchResults] = useState({ + courses: [], + collections: [], + users: [], + total_courses: 0, + total_collections: 0, + total_users: 0 + }); + const [isLoading, setIsLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || ''); + + // URL parameters + const query = searchParams.get('q') || ''; + const page = parseInt(searchParams.get('page') || '1'); + const type = (searchParams.get('type') as ContentType) || 'all'; + const perPage = 9; + + // Filter state + const [selectedType, setSelectedType] = useState(type); + + const updateSearchParams = (updates: Record) => { + const current = new URLSearchParams(Array.from(searchParams.entries())); + Object.entries(updates).forEach(([key, value]) => { + if (value) { + current.set(key, value); + } else { + current.delete(key); + } + }); + router.push(`?${current.toString()}`); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + updateSearchParams({ q: searchQuery, page: '1' }); + } + }; + + useEffect(() => { + setSearchQuery(query); + }, [query]); + + useEffect(() => { + const fetchResults = async () => { + if (!query.trim()) { + setSearchResults({ + courses: [], + collections: [], + users: [], + total_courses: 0, + total_collections: 0, + total_users: 0 + }); + return; + } + + setIsLoading(true); + try { + const response = await searchOrgContent( + org?.slug, + query, + page, + perPage, + selectedType === 'all' ? null : selectedType, + session?.data?.tokens?.access_token + ); + + // Log the response to see what we're getting + console.log('Search API Response:', response); + + // The response data is directly what we need + const results = response.data; + + setSearchResults({ + courses: results.courses || [], + collections: results.collections || [], + users: results.users || [], + total_courses: results.courses?.length || 0, + total_collections: results.collections?.length || 0, + total_users: results.users?.length || 0 + }); + } catch (error) { + console.error('Error searching content:', error); + setSearchResults({ + courses: [], + collections: [], + users: [], + total_courses: 0, + total_collections: 0, + total_users: 0 + }); + } + setIsLoading(false); + }; + + fetchResults(); + }, [query, page, selectedType, org?.slug, session?.data?.tokens?.access_token]); + + const totalResults = searchResults.total_courses + searchResults.total_collections + searchResults.total_users; + const totalPages = Math.ceil(totalResults / perPage); + + const FilterButton = ({ type, count, icon: Icon }: { type: ContentType; count: number; icon: any }) => ( + + ); + + const Pagination = () => { + if (totalPages <= 1) return null; + + return ( +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => ( + + ))} +
+ ); + }; + + const LoadingState = () => ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + + const EmptyState = () => ( +
+
+ +
+

No results found

+

+ We couldn't find any matches for "{query}". Try adjusting your search terms or browse our featured content. +

+
+ ); + + return ( +
+ {/* Search Header */} +
+
+
+

Search

+ + {/* Search Input */} +
+ setSearchQuery(e.target.value)} + placeholder="Search courses, users, collections..." + className="w-full h-12 pl-12 pr-4 rounded-xl nice-shadow bg-white + focus:outline-none focus:ring-1 focus:ring-black/5 focus:border-black/20 + text-sm placeholder:text-black/40 transition-all" + /> +
+ +
+ +
+ + {/* Filters */} +
+ + + + +
+
+
+
+ + {/* Search Results */} +
+
+ {query && ( +
+ Found {totalResults} results for "{query}" +
+ )} + + {isLoading ? ( + + ) : totalResults === 0 && query ? ( + + ) : ( +
+ {/* Courses Grid */} + {(selectedType === 'all' || selectedType === 'courses') && searchResults.courses.length > 0 && ( +
+

+ + Courses ({searchResults.courses.length}) +

+
+ {searchResults.courses.map((course) => ( + +
+ {course.thumbnail_image ? ( + {course.name} + ) : ( +
+ +
+ )} +
+
+

{course.name}

+

{course.description}

+ {course.authors && course.authors.length > 0 && ( +
+ + + {course.authors[0].user.first_name} {course.authors[0].user.last_name} + +
+ )} +
+ + ))} +
+
+ )} + + {/* Collections Grid */} + {(selectedType === 'all' || selectedType === 'collections') && searchResults.collections.length > 0 && ( +
+

+ + Collections ({searchResults.collections.length}) +

+
+ {searchResults.collections.map((collection) => ( + +
+ +
+
+

{collection.name}

+

{collection.description}

+
+ + ))} +
+
+ )} + + {/* Users Grid */} + {(selectedType === 'all' || selectedType === 'users') && searchResults.users.length > 0 && ( +
+

+ + Users ({searchResults.users.length}) +

+
+ {searchResults.users.map((user) => ( + + +
+

+ {user.first_name} {user.last_name} +

+

@{user.username}

+ {user.details?.title?.text && ( +

{user.details.title.text}

+ )} +
+ + ))} +
+
+ )} +
+ )} + + +
+
+
+ ); +} + +export default SearchPage; \ No newline at end of file diff --git a/apps/web/components/Objects/Search/SearchBar.tsx b/apps/web/components/Objects/Search/SearchBar.tsx index e84c8cad..e0a3171b 100644 --- a/apps/web/components/Objects/Search/SearchBar.tsx +++ b/apps/web/components/Objects/Search/SearchBar.tsx @@ -238,42 +238,6 @@ export const SearchBar: React.FC = ({ Quick Results
- {/* Users Section */} - {searchResults.users.length > 0 && ( -
-
- - Users -
- {searchResults.users.map((user) => ( - - -
-
-

- {user.first_name} {user.last_name} -

- User -
-

@{user.username}

-
- - ))} -
- )} - {/* Courses Section */} {searchResults.courses.length > 0 && (
@@ -342,6 +306,42 @@ export const SearchBar: React.FC = ({ ))}
)} + + {/* Users Section */} + {searchResults.users.length > 0 && ( +
+
+ + Users +
+ {searchResults.users.map((user) => ( + + +
+
+

+ {user.first_name} {user.last_name} +

+ User +
+

@{user.username}

+
+ + ))} +
+ )}
); }, [searchResults, orgslug, org?.org_uuid]); diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index e48ae9b2..966db14b 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -113,6 +113,10 @@ layer(base); text-decoration: none; } + button { + @apply cursor-pointer; + } + * { box-sizing: border-box; }