mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
commit
421cc82637
6 changed files with 286 additions and 1 deletions
|
|
@ -24,6 +24,7 @@ from src.services.courses.courses import (
|
||||||
update_course,
|
update_course,
|
||||||
delete_course,
|
delete_course,
|
||||||
update_course_thumbnail,
|
update_course_thumbnail,
|
||||||
|
search_courses,
|
||||||
)
|
)
|
||||||
from src.services.courses.updates import (
|
from src.services.courses.updates import (
|
||||||
create_update,
|
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}")
|
@router.put("/{course_uuid}")
|
||||||
async def api_update_course(
|
async def api_update_course(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Literal, List
|
from typing import Literal, List
|
||||||
from uuid import uuid4
|
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_resources import UserGroupResource
|
||||||
from src.db.usergroup_user import UserGroupUser
|
from src.db.usergroup_user import UserGroupUser
|
||||||
from src.db.organizations import Organization
|
from src.db.organizations import Organization
|
||||||
|
|
@ -214,6 +214,80 @@ async def get_courses_orgslug(
|
||||||
return course_reads
|
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(
|
async def create_course(
|
||||||
request: Request,
|
request: Request,
|
||||||
org_id: int,
|
org_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import { getUriWithOrg } from '@services/config/config'
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
import { HeaderProfileBox } from '@components/Security/HeaderProfileBox'
|
import { HeaderProfileBox } from '@components/Security/HeaderProfileBox'
|
||||||
import MenuLinks from './OrgMenuLinks'
|
import MenuLinks from './OrgMenuLinks'
|
||||||
import { getOrgLogoMediaDirectory } from '@services/media/media'
|
import { getOrgLogoMediaDirectory } from '@services/media/media'
|
||||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
import { useOrg } from '@components/Contexts/OrgContext'
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import { SearchBar } from '@components/Objects/Search/SearchBar'
|
||||||
|
|
||||||
export const OrgMenu = (props: any) => {
|
export const OrgMenu = (props: any) => {
|
||||||
const orgslug = props.orgslug
|
const orgslug = props.orgslug
|
||||||
|
|
@ -50,6 +52,12 @@ export const OrgMenu = (props: any) => {
|
||||||
<MenuLinks orgslug={orgslug} />
|
<MenuLinks orgslug={orgslug} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search Section */}
|
||||||
|
<div className="hidden md:flex flex-1 justify-center max-w-lg px-4">
|
||||||
|
<SearchBar orgslug={orgslug} className="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="hidden md:flex">
|
<div className="hidden md:flex">
|
||||||
<HeaderProfileBox />
|
<HeaderProfileBox />
|
||||||
|
|
@ -77,6 +85,10 @@ export const OrgMenu = (props: any) => {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col px-4 py-3 space-y-4 justify-center items-center">
|
<div className="flex flex-col px-4 py-3 space-y-4 justify-center items-center">
|
||||||
|
{/* Mobile Search */}
|
||||||
|
<div className="w-full px-2">
|
||||||
|
<SearchBar orgslug={orgslug} isMobile={true} />
|
||||||
|
</div>
|
||||||
<div className='py-4'>
|
<div className='py-4'>
|
||||||
<MenuLinks orgslug={orgslug} />
|
<MenuLinks orgslug={orgslug} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
147
apps/web/components/Objects/Search/SearchBar.tsx
Normal file
147
apps/web/components/Objects/Search/SearchBar.tsx
Normal file
|
|
@ -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<SearchBarProps> = ({ orgslug, className = '', isMobile = false }) => {
|
||||||
|
const org = useOrg() as any;
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const searchRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div ref={searchRef} className={`relative ${className}`}>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResults && (searchQuery.trim().length > 0 || isLoading) && (
|
||||||
|
<div className={`absolute z-50 w-full mt-2 bg-white rounded-lg shadow-lg border border-gray-100 max-h-[400px] overflow-y-auto ${isMobile ? 'max-w-full' : 'min-w-[400px]'}`}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 text-center text-gray-500">
|
||||||
|
<div className="animate-pulse">Searching...</div>
|
||||||
|
</div>
|
||||||
|
) : courses.length > 0 ? (
|
||||||
|
<div className="py-2">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<Link
|
||||||
|
key={course.course_uuid}
|
||||||
|
prefetch
|
||||||
|
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
||||||
|
className="block hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => setShowResults(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-3 space-x-3">
|
||||||
|
{course.thumbnail_image && (
|
||||||
|
<img
|
||||||
|
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
|
||||||
|
alt={course.name}
|
||||||
|
className="w-12 h-12 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{course.description}</p>
|
||||||
|
{course.authors && course.authors[0] && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
by {course.authors[0].first_name} {course.authors[0].last_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-gray-500">
|
||||||
|
No courses found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/hooks/useDebounce.ts
Normal file
17
apps/web/hooks/useDebounce.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,22 @@ export async function getOrgCourses(
|
||||||
return res
|
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(
|
export async function getCourseMetadata(
|
||||||
course_uuid: any,
|
course_uuid: any,
|
||||||
next: any,
|
next: any,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue