mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement multi search on search bar
This commit is contained in:
parent
ed1ae628cb
commit
0167fecbe8
6 changed files with 413 additions and 73 deletions
|
|
@ -2,7 +2,7 @@ import os
|
|||
from fastapi import APIRouter, Depends
|
||||
from src.routers import health
|
||||
from src.routers import usergroups
|
||||
from src.routers import dev, trail, users, auth, orgs, roles
|
||||
from src.routers import dev, trail, users, auth, orgs, roles, search
|
||||
from src.routers.ai import ai
|
||||
from src.routers.courses import chapters, collections, courses, assignments
|
||||
from src.routers.courses.activities import activities, blocks
|
||||
|
|
@ -23,6 +23,7 @@ v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"])
|
|||
v1_router.include_router(roles.router, prefix="/roles", tags=["roles"])
|
||||
v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"])
|
||||
v1_router.include_router(courses.router, prefix="/courses", tags=["courses"])
|
||||
v1_router.include_router(search.router, prefix="/search", tags=["search"])
|
||||
v1_router.include_router(
|
||||
assignments.router, prefix="/assignments", tags=["assignments"]
|
||||
)
|
||||
|
|
|
|||
31
apps/api/src/routers/search.py
Normal file
31
apps/api/src/routers/search.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session
|
||||
from src.core.events.database import get_db_session
|
||||
from src.db.users import PublicUser
|
||||
from src.security.auth import get_current_user
|
||||
from src.services.search.search import search_across_org, SearchResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/org_slug/{org_slug}", response_model=SearchResult)
|
||||
async def api_search_across_org(
|
||||
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),
|
||||
) -> SearchResult:
|
||||
"""
|
||||
Search across courses, collections and users within an organization
|
||||
"""
|
||||
return await search_across_org(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
org_slug=org_slug,
|
||||
search_query=query,
|
||||
db_session=db_session,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
|
|
@ -288,10 +288,22 @@ async def get_courses_orgslug(
|
|||
# Create CourseRead objects with authors
|
||||
course_reads = []
|
||||
for course in courses:
|
||||
course_read = CourseRead(
|
||||
**course.model_dump(),
|
||||
authors=course_authors.get(course.course_uuid, [])
|
||||
)
|
||||
course_read = CourseRead.model_validate({
|
||||
"id": course.id or 0, # Ensure id is never None
|
||||
"org_id": course.org_id,
|
||||
"name": course.name,
|
||||
"description": course.description or "",
|
||||
"about": course.about or "",
|
||||
"learnings": course.learnings or "",
|
||||
"tags": course.tags or "",
|
||||
"thumbnail_image": course.thumbnail_image or "",
|
||||
"public": course.public,
|
||||
"open_to_contributors": course.open_to_contributors,
|
||||
"course_uuid": course.course_uuid,
|
||||
"creation_date": course.creation_date,
|
||||
"update_date": course.update_date,
|
||||
"authors": course_authors.get(course.course_uuid, [])
|
||||
})
|
||||
course_reads.append(course_read)
|
||||
|
||||
return course_reads
|
||||
|
|
@ -380,8 +392,22 @@ async def search_courses(
|
|||
for resource_author, user in author_results
|
||||
]
|
||||
|
||||
course_read = CourseRead.model_validate(course)
|
||||
course_read.authors = authors
|
||||
course_read = CourseRead.model_validate({
|
||||
"id": course.id or 0, # Ensure id is never None
|
||||
"org_id": course.org_id,
|
||||
"name": course.name,
|
||||
"description": course.description or "",
|
||||
"about": course.about or "",
|
||||
"learnings": course.learnings or "",
|
||||
"tags": course.tags or "",
|
||||
"thumbnail_image": course.thumbnail_image or "",
|
||||
"public": course.public,
|
||||
"open_to_contributors": course.open_to_contributors,
|
||||
"course_uuid": course.course_uuid,
|
||||
"creation_date": course.creation_date,
|
||||
"update_date": course.update_date,
|
||||
"authors": authors
|
||||
})
|
||||
course_reads.append(course_read)
|
||||
|
||||
return course_reads
|
||||
|
|
@ -700,22 +726,22 @@ async def get_user_courses(
|
|||
)
|
||||
|
||||
# Create CourseRead object
|
||||
course_read = CourseRead(
|
||||
id=course.id,
|
||||
org_id=course.org_id,
|
||||
name=course.name,
|
||||
description=course.description,
|
||||
about=course.about,
|
||||
learnings=course.learnings,
|
||||
tags=course.tags,
|
||||
thumbnail_image=course.thumbnail_image,
|
||||
public=course.public,
|
||||
open_to_contributors=course.open_to_contributors,
|
||||
course_uuid=course.course_uuid,
|
||||
creation_date=course.creation_date,
|
||||
update_date=course.update_date,
|
||||
authors=authors_with_role,
|
||||
)
|
||||
course_read = CourseRead.model_validate({
|
||||
"id": course.id or 0, # Ensure id is never None
|
||||
"org_id": course.org_id,
|
||||
"name": course.name,
|
||||
"description": course.description or "",
|
||||
"about": course.about or "",
|
||||
"learnings": course.learnings or "",
|
||||
"tags": course.tags or "",
|
||||
"thumbnail_image": course.thumbnail_image or "",
|
||||
"public": course.public,
|
||||
"open_to_contributors": course.open_to_contributors,
|
||||
"course_uuid": course.course_uuid,
|
||||
"creation_date": course.creation_date,
|
||||
"update_date": course.update_date,
|
||||
"authors": authors_with_role
|
||||
})
|
||||
|
||||
result.append(course_read)
|
||||
|
||||
|
|
|
|||
118
apps/api/src/services/search/search.py
Normal file
118
apps/api/src/services/search/search.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from typing import List, TypeVar
|
||||
from fastapi import Request
|
||||
from sqlmodel import Session, select, or_, text, and_
|
||||
from sqlalchemy import true as sa_true
|
||||
from pydantic import BaseModel
|
||||
from src.db.users import PublicUser, AnonymousUser, UserRead, User
|
||||
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.services.courses.courses import search_courses
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
courses: List[CourseRead]
|
||||
collections: List[CollectionRead]
|
||||
users: List[UserRead]
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
async def search_across_org(
|
||||
request: Request,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
org_slug: str,
|
||||
search_query: str,
|
||||
db_session: Session,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> SearchResult:
|
||||
"""
|
||||
Search across courses, collections and users within an organization
|
||||
"""
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Get organization
|
||||
org_statement = select(Organization).where(Organization.slug == org_slug)
|
||||
org = db_session.exec(org_statement).first()
|
||||
|
||||
if not org:
|
||||
return SearchResult(courses=[], collections=[], users=[])
|
||||
|
||||
# Search courses using existing search_courses function
|
||||
courses = await search_courses(request, current_user, org_slug, search_query, db_session, page, limit)
|
||||
|
||||
# Search collections
|
||||
collections_query = (
|
||||
select(Collection)
|
||||
.where(Collection.org_id == org.id)
|
||||
.where(
|
||||
or_(
|
||||
text('LOWER("collection".name) LIKE LOWER(:pattern)'),
|
||||
text('LOWER("collection".description) LIKE LOWER(:pattern)')
|
||||
)
|
||||
)
|
||||
.params(pattern=f"%{search_query}%")
|
||||
)
|
||||
|
||||
# Search users
|
||||
users_query = (
|
||||
select(User)
|
||||
.where(
|
||||
or_(
|
||||
text('LOWER("user".username) LIKE LOWER(:pattern) OR ' +
|
||||
'LOWER("user".first_name) LIKE LOWER(:pattern) OR ' +
|
||||
'LOWER("user".last_name) LIKE LOWER(:pattern) OR ' +
|
||||
'LOWER("user".bio) LIKE LOWER(:pattern)')
|
||||
)
|
||||
)
|
||||
.params(pattern=f"%{search_query}%")
|
||||
)
|
||||
|
||||
if isinstance(current_user, AnonymousUser):
|
||||
# For anonymous users, only show public collections
|
||||
collections_query = collections_query.where(Collection.public == sa_true())
|
||||
else:
|
||||
# For authenticated users, show public collections and those in their org
|
||||
collections_query = (
|
||||
collections_query
|
||||
.where(
|
||||
or_(
|
||||
Collection.public == sa_true(),
|
||||
Collection.org_id == org.id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Apply pagination to queries
|
||||
collections = db_session.exec(collections_query.offset(offset).limit(limit)).all()
|
||||
users = db_session.exec(users_query.offset(offset).limit(limit)).all()
|
||||
|
||||
# Convert collections to CollectionRead objects with courses
|
||||
collection_reads = []
|
||||
for collection in collections:
|
||||
# Get courses in collection
|
||||
statement = (
|
||||
select(Course)
|
||||
.select_from(Course)
|
||||
.join(CollectionCourse, and_(
|
||||
CollectionCourse.course_id == Course.id,
|
||||
CollectionCourse.collection_id == collection.id,
|
||||
CollectionCourse.org_id == collection.org_id
|
||||
))
|
||||
.distinct()
|
||||
)
|
||||
collection_courses = list(db_session.exec(statement).all())
|
||||
collection_read = CollectionRead(**collection.model_dump(), courses=collection_courses)
|
||||
collection_reads.append(collection_read)
|
||||
|
||||
# Convert users to UserRead objects
|
||||
user_reads = [UserRead.model_validate(user) for user in users]
|
||||
|
||||
return SearchResult(
|
||||
courses=courses,
|
||||
collections=collection_reads,
|
||||
users=user_reads
|
||||
)
|
||||
|
|
@ -1,31 +1,75 @@
|
|||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch } from 'lucide-react';
|
||||
import { searchOrgCourses } from '@services/courses/courses';
|
||||
import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch, Users } from 'lucide-react';
|
||||
import { searchOrgContent } from '@services/search/search';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import Link from 'next/link';
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } 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';
|
||||
import UserAvatar from '../UserAvatar';
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
avatar_image: string;
|
||||
bio: string;
|
||||
details: Record<string, any>;
|
||||
profile: Record<string, any>;
|
||||
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;
|
||||
tags?: string[];
|
||||
authors: Array<{
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar_image: 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[];
|
||||
}
|
||||
|
||||
interface SearchBarProps {
|
||||
orgslug: string;
|
||||
className?: string;
|
||||
isMobile?: boolean;
|
||||
showSearchSuggestions?: boolean;
|
||||
}
|
||||
|
||||
const CourseResultsSkeleton = () => (
|
||||
|
|
@ -50,10 +94,15 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
orgslug,
|
||||
className = '',
|
||||
isMobile = false,
|
||||
showSearchSuggestions = false,
|
||||
}) => {
|
||||
const org = useOrg() as any;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||
courses: [],
|
||||
collections: [],
|
||||
users: []
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -75,16 +124,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
const fetchResults = async () => {
|
||||
if (debouncedSearch.trim().length === 0) {
|
||||
setCourses([]);
|
||||
setSearchResults({ courses: [], collections: [], users: [] });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await searchOrgCourses(
|
||||
const response = await searchOrgContent(
|
||||
orgslug,
|
||||
debouncedSearch,
|
||||
1,
|
||||
|
|
@ -92,16 +141,31 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
null,
|
||||
session?.data?.tokens?.access_token
|
||||
);
|
||||
setCourses(results);
|
||||
|
||||
console.log('Search API Response:', response); // Debug log
|
||||
|
||||
// Type assertion and safe access
|
||||
const typedResponse = response.data as any;
|
||||
|
||||
// Ensure we have the correct structure and handle potential undefined values
|
||||
const processedResults: SearchResults = {
|
||||
courses: Array.isArray(typedResponse?.courses) ? typedResponse.courses : [],
|
||||
collections: Array.isArray(typedResponse?.collections) ? typedResponse.collections : [],
|
||||
users: Array.isArray(typedResponse?.users) ? typedResponse.users : []
|
||||
};
|
||||
|
||||
console.log('Processed Results:', processedResults); // Debug log
|
||||
|
||||
setSearchResults(processedResults);
|
||||
} catch (error) {
|
||||
console.error('Error searching courses:', error);
|
||||
setCourses([]);
|
||||
console.error('Error searching content:', error);
|
||||
setSearchResults({ courses: [], collections: [], users: [] });
|
||||
}
|
||||
setIsLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
};
|
||||
|
||||
fetchCourses();
|
||||
fetchResults();
|
||||
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
|
||||
|
||||
const MemoizedEmptyState = useMemo(() => {
|
||||
|
|
@ -160,8 +224,12 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
return null;
|
||||
}, [searchQuery, searchTerms, orgslug]);
|
||||
|
||||
const MemoizedCourseResults = useMemo(() => {
|
||||
if (!courses.length) return null;
|
||||
const MemoizedQuickResults = useMemo(() => {
|
||||
const hasResults = searchResults.courses.length > 0 ||
|
||||
searchResults.collections.length > 0 ||
|
||||
searchResults.users.length > 0;
|
||||
|
||||
if (!hasResults) return null;
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
|
|
@ -169,7 +237,51 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
<TextSearch size={16} />
|
||||
<span className="font-medium">Quick Results</span>
|
||||
</div>
|
||||
{courses.map((course) => (
|
||||
|
||||
{/* Users Section */}
|
||||
{searchResults.users.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
|
||||
<Users size={12} />
|
||||
<span>Users</span>
|
||||
</div>
|
||||
{searchResults.users.map((user) => (
|
||||
<Link
|
||||
key={user.user_uuid}
|
||||
href={getUriWithOrg(orgslug, `/user/${user.username}`)}
|
||||
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
|
||||
>
|
||||
<UserAvatar
|
||||
width={40}
|
||||
avatar_url={user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : ''}
|
||||
predefined_avatar={user.avatar_image ? undefined : 'empty'}
|
||||
userId={user.id.toString()}
|
||||
showProfilePopup
|
||||
rounded="rounded-full"
|
||||
backgroundColor="bg-gray-100"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-black/80 truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">User</span>
|
||||
</div>
|
||||
<p className="text-xs text-black/50 truncate">@{user.username}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Courses Section */}
|
||||
{searchResults.courses.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
|
||||
<GraduationCap size={12} />
|
||||
<span>Courses</span>
|
||||
</div>
|
||||
{searchResults.courses.map((course) => (
|
||||
<Link
|
||||
key={course.course_uuid}
|
||||
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
||||
|
|
@ -201,8 +313,38 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections Section */}
|
||||
{searchResults.collections.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
|
||||
<Book size={12} />
|
||||
<span>Collections</span>
|
||||
</div>
|
||||
{searchResults.collections.map((collection) => (
|
||||
<Link
|
||||
key={collection.collection_uuid}
|
||||
href={getUriWithOrg(orgslug, `/collection/${collection.collection_uuid}`)}
|
||||
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
|
||||
<Book size={20} className="text-black/40" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-black/80 truncate">{collection.name}</h3>
|
||||
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Collection</span>
|
||||
</div>
|
||||
<p className="text-xs text-black/50 truncate">{collection.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [courses, orgslug, org?.org_uuid]);
|
||||
}, [searchResults, orgslug, org?.org_uuid]);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
|
@ -238,13 +380,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||
MemoizedEmptyState
|
||||
) : (
|
||||
<>
|
||||
{MemoizedSearchSuggestions}
|
||||
{showSearchSuggestions && MemoizedSearchSuggestions}
|
||||
{isLoading ? (
|
||||
<CourseResultsSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{MemoizedCourseResults}
|
||||
{(courses.length > 0 || searchQuery.trim()) && (
|
||||
{MemoizedQuickResults}
|
||||
{((searchResults.courses.length > 0 ||
|
||||
searchResults.collections.length > 0 ||
|
||||
searchResults.users.length > 0) ||
|
||||
searchQuery.trim()) && (
|
||||
<Link
|
||||
href={getUriWithOrg(orgslug, `/search?q=${encodeURIComponent(searchQuery)}`)}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-xs text-black/50 hover:text-black/70 hover:bg-black/[0.02] transition-colors"
|
||||
|
|
|
|||
19
apps/web/services/search/search.ts
Normal file
19
apps/web/services/search/search.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { RequestBodyWithAuthHeader } from "@services/utils/ts/requests"
|
||||
import { getAPIUrl } from "@services/config/config"
|
||||
import { errorHandling, getResponseMetadata } from "@services/utils/ts/requests"
|
||||
|
||||
export async function searchOrgContent(
|
||||
org_slug: string,
|
||||
query: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
next: any,
|
||||
access_token?: any
|
||||
) {
|
||||
const result: any = await fetch(
|
||||
`${getAPIUrl()}search/org_slug/${org_slug}?query=${encodeURIComponent(query)}&page=${page}&limit=${limit}`,
|
||||
RequestBodyWithAuthHeader('GET', null, next, access_token)
|
||||
)
|
||||
const res = await getResponseMetadata(result)
|
||||
return res
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue