Merge pull request #464 from learnhouse/feat/advanced-search

Advanced Search
This commit is contained in:
Badr B. 2025-04-06 13:49:38 +02:00 committed by GitHub
commit db45d8ef31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1222 additions and 774 deletions

View file

@ -2,7 +2,7 @@ import os
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.routers import health from src.routers import health
from src.routers import usergroups 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.ai import ai
from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses import chapters, collections, courses, assignments
from src.routers.courses.activities import activities, blocks 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(roles.router, prefix="/roles", tags=["roles"])
v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"]) v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"])
v1_router.include_router(courses.router, prefix="/courses", tags=["courses"]) v1_router.include_router(courses.router, prefix="/courses", tags=["courses"])
v1_router.include_router(search.router, prefix="/search", tags=["search"])
v1_router.include_router( v1_router.include_router(
assignments.router, prefix="/assignments", tags=["assignments"] assignments.router, prefix="/assignments", tags=["assignments"]
) )

View 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
)

View file

@ -288,10 +288,22 @@ async def get_courses_orgslug(
# Create CourseRead objects with authors # Create CourseRead objects with authors
course_reads = [] course_reads = []
for course in courses: for course in courses:
course_read = CourseRead( course_read = CourseRead.model_validate({
**course.model_dump(), "id": course.id or 0, # Ensure id is never None
authors=course_authors.get(course.course_uuid, []) "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) course_reads.append(course_read)
return course_reads return course_reads
@ -380,8 +392,22 @@ async def search_courses(
for resource_author, user in author_results for resource_author, user in author_results
] ]
course_read = CourseRead.model_validate(course) course_read = CourseRead.model_validate({
course_read.authors = authors "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) course_reads.append(course_read)
return course_reads return course_reads
@ -700,22 +726,22 @@ async def get_user_courses(
) )
# Create CourseRead object # Create CourseRead object
course_read = CourseRead( course_read = CourseRead.model_validate({
id=course.id, "id": course.id or 0, # Ensure id is never None
org_id=course.org_id, "org_id": course.org_id,
name=course.name, "name": course.name,
description=course.description, "description": course.description or "",
about=course.about, "about": course.about or "",
learnings=course.learnings, "learnings": course.learnings or "",
tags=course.tags, "tags": course.tags or "",
thumbnail_image=course.thumbnail_image, "thumbnail_image": course.thumbnail_image or "",
public=course.public, "public": course.public,
open_to_contributors=course.open_to_contributors, "open_to_contributors": course.open_to_contributors,
course_uuid=course.course_uuid, "course_uuid": course.course_uuid,
creation_date=course.creation_date, "creation_date": course.creation_date,
update_date=course.update_date, "update_date": course.update_date,
authors=authors_with_role, "authors": authors_with_role
) })
result.append(course_read) result.append(course_read)

View 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
)

View file

@ -1,64 +1,116 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Search } from 'lucide-react'; import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch, Users } from 'lucide-react';
import { searchOrgCourses } from '@services/courses/courses'; import { searchOrgContent } from '@services/search/search';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import Link from 'next/link'; import Link from 'next/link';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media';
import { useDebounce } from '@/hooks/useDebounce'; import { useDebounce } from '@/hooks/useDebounce';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { removeCoursePrefix } from '../Thumbnails/CourseThumbnail'; 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 { interface Course {
name: string; name: string;
description: string; description: string;
about: string;
learnings: string;
tags: string;
thumbnail_image: string; thumbnail_image: string;
public: boolean;
open_to_contributors: boolean;
id: number;
org_id: number;
authors: Author[];
course_uuid: string; course_uuid: string;
authors: Array<{ creation_date: string;
first_name: string; update_date: string;
last_name: string; }
avatar_image: 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 { interface SearchBarProps {
orgslug: string; orgslug: string;
className?: string; className?: string;
isMobile?: boolean; isMobile?: boolean;
showSearchSuggestions?: boolean;
} }
export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', isMobile = false }) => { const CourseResultsSkeleton = () => (
<div className="p-2 ">
<div className="flex items-center gap-2 px-2 py-2">
<div className="w-4 h-4 bg-black/5 rounded animate-pulse" />
<div className="w-20 h-4 bg-black/5 rounded animate-pulse" />
</div>
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 p-2">
<div className="w-10 h-10 bg-black/5 rounded-lg animate-pulse" />
<div className="flex-1">
<div className="w-48 h-4 bg-black/5 rounded animate-pulse mb-2" />
<div className="w-32 h-3 bg-black/5 rounded animate-pulse" />
</div>
</div>
))}
</div>
);
export const SearchBar: React.FC<SearchBarProps> = ({
orgslug,
className = '',
isMobile = false,
showSearchSuggestions = false,
}) => {
const org = useOrg() as any; const org = useOrg() as any;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [courses, setCourses] = useState<Course[]>([]); const [searchResults, setSearchResults] = useState<SearchResults>({
courses: [],
collections: [],
users: []
});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null); const searchRef = useRef<HTMLDivElement>(null);
const session = useLHSession() as any; const session = useLHSession() as any;
const [isInitialLoad, setIsInitialLoad] = useState(true);
const debouncedSearchFunction = useDebounce(async (query: string) => { // Debounce the search query value
if (query.trim().length === 0) { const debouncedSearch = useDebounce(searchQuery, 300);
setCourses([]);
return;
}
setIsLoading(true);
try {
const results = await searchOrgCourses(
orgslug,
query,
1,
5,
null,
session?.data?.tokens?.access_token
);
setCourses(results);
} catch (error) {
console.error('Error searching courses:', error);
setCourses([]);
}
setIsLoading(false);
}, 300);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -72,76 +124,285 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
}, []); }, []);
useEffect(() => { useEffect(() => {
debouncedSearchFunction(searchQuery); const fetchResults = async () => {
}, [searchQuery, debouncedSearchFunction]); if (debouncedSearch.trim().length === 0) {
setSearchResults({ courses: [], collections: [], users: [] });
setIsLoading(false);
return;
}
const handleSearchFocus = () => { setIsLoading(true);
if (searchQuery.trim().length > 0) { try {
setShowResults(true); const response = await searchOrgContent(
orgslug,
debouncedSearch,
1,
3,
null,
session?.data?.tokens?.access_token
);
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 content:', error);
setSearchResults({ courses: [], collections: [], users: [] });
}
setIsLoading(false);
setIsInitialLoad(false);
};
fetchResults();
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
const MemoizedEmptyState = useMemo(() => {
if (!searchQuery.trim()) {
return (
<div className="py-8 px-4">
<div className="flex flex-col items-center text-center">
<div className="mb-4 p-3 bg-black/5 rounded-full">
<Sparkles className="w-6 h-6 text-black/70" />
</div>
<h3 className="text-sm font-medium text-black/80 mb-1">
Discover Your Next Learning Journey
</h3>
<p className="text-xs text-black/50 max-w-[240px]">
Start typing to search through available content
</p>
</div>
</div>
);
} }
}; return null;
}, [searchQuery]);
const searchTerms = useMemo(() => [
{ term: searchQuery, type: 'exact', icon: <Search size={14} className="text-black/40" /> },
{ term: `${searchQuery} courses`, type: 'courses', icon: <GraduationCap size={14} className="text-black/40" /> },
{ term: `${searchQuery} collections`, type: 'collections', icon: <Book size={14} className="text-black/40" /> },
], [searchQuery]);
const MemoizedSearchSuggestions = useMemo(() => {
if (searchQuery.trim()) {
return (
<div className="p-2">
<div className="flex items-center gap-2 px-2 py-2 text-sm text-black/50">
<ScanSearch size={16} />
<span className="font-medium">Search suggestions</span>
</div>
<div className="space-y-1">
{searchTerms.map(({ term, type, icon }) => (
<Link
key={`${term}-${type}`}
href={getUriWithOrg(orgslug, `/search?q=${encodeURIComponent(term)}`)}
className="flex items-center px-3 py-2 hover:bg-black/[0.02] rounded-lg transition-colors group"
>
<div className="flex items-center gap-2 flex-1">
{icon}
<span className="text-sm text-black/70">{term}</span>
</div>
<ArrowUpRight size={14} className="text-black/30 group-hover:text-black/50 transition-colors" />
</Link>
))}
</div>
</div>
);
}
return null;
}, [searchQuery, searchTerms, orgslug]);
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">
<div className="flex items-center gap-2 px-2 py-2 text-sm text-black/50">
<TextSearch size={16} />
<span className="font-medium">Quick Results</span>
</div>
{/* 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)}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="relative">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="w-10 h-10 object-cover rounded-lg"
/>
) : (
<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="absolute -bottom-1 -right-1 bg-white shadow-sm p-1 rounded-full">
<GraduationCap size={11} className="text-black/60" />
</div>
</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">{course.name}</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Course</span>
</div>
<p className="text-xs text-black/50 truncate">{course.description}</p>
</div>
</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>
);
}, [searchResults, orgslug, org?.org_uuid]);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowResults(true);
}, []);
return ( return (
<div ref={searchRef} className={`relative ${className}`}> <div ref={searchRef} className={`relative ${className}`}>
<div className="relative"> <div className="relative group">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={handleSearchChange}
setSearchQuery(e.target.value); onFocus={() => setShowResults(true)}
setShowResults(true); placeholder="Search courses, users, collections..."
}} className="w-full h-9 pl-11 pr-4 rounded-xl nice-shadow bg-white
onFocus={handleSearchFocus} focus:outline-none focus:ring-1 focus:ring-black/5 focus:border-black/20
placeholder="Search courses..." text-sm placeholder:text-black/40 transition-all"
className="w-full h-9 pl-10 pr-4 rounded-lg border border-gray-200 bg-white/50 focus:outline-hidden 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 className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Search className="text-black/40 group-focus-within:text-black/60 transition-colors" size={18} />
</div>
</div> </div>
{showResults && (searchQuery.trim().length > 0 || isLoading) && ( <div
<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]'}`}> className={`absolute z-50 w-full mt-2 bg-white rounded-xl nice-shadow
{isLoading ? ( overflow-hidden divide-y divide-black/5
<div className="p-4 text-center text-gray-500"> transition-all duration-200 ease-in-out transform
<div className="animate-pulse">Searching...</div> ${showResults ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2 pointer-events-none'}
</div> ${isMobile ? 'max-w-full' : 'min-w-[400px]'}`}
) : courses.length > 0 ? ( >
<div className="py-2"> {(!searchQuery.trim() || isInitialLoad) ? (
{courses.map((course) => ( MemoizedEmptyState
<Link ) : (
key={course.course_uuid} <>
prefetch {showSearchSuggestions && MemoizedSearchSuggestions}
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)} {isLoading ? (
className="block hover:bg-gray-50 transition-colors" <CourseResultsSkeleton />
onClick={() => setShowResults(false)} ) : (
> <>
<div className="flex items-center p-3 space-x-3"> {MemoizedQuickResults}
{course.thumbnail_image && ( {((searchResults.courses.length > 0 ||
<img searchResults.collections.length > 0 ||
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)} searchResults.users.length > 0) ||
alt={course.name} searchQuery.trim()) && (
className="w-12 h-12 object-cover rounded-lg" <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"
<div className="flex-1 min-w-0"> >
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3> <span>View all results</span>
<p className="text-xs text-gray-500 truncate">{course.description}</p> <ArrowRight size={14} />
{course.authors && course.authors[0] && ( </Link>
<p className="text-xs text-gray-400 mt-1"> )}
by {course.authors[0].first_name} {course.authors[0].last_name} </>
</p> )}
)} </>
</div> )}
</div> </div>
</Link>
))}
</div>
) : (
<div className="p-4 text-center text-gray-500">
No courses found
</div>
)}
</div>
)}
</div> </div>
); );
}; };

View file

@ -1,26 +1,54 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
// Function debouncing
export function useDebounce<T extends (...args: any[]) => any>( export function useDebounce<T extends (...args: any[]) => any>(
callback: T, callback: T,
delay: number delay: number
): T { ): T;
// Value debouncing
export function useDebounce<T>(value: T, delay: number): T;
// Implementation
export function useDebounce<T>(valueOrCallback: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(valueOrCallback);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined); const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => { useEffect(() => {
// If it's a function, return a debounced version of it
if (typeof valueOrCallback === 'function') {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}
// For values, update the debounced value after the delay
timeoutRef.current = setTimeout(() => {
setDebouncedValue(valueOrCallback);
}, delay);
return () => { return () => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
}; };
}, []); }, [valueOrCallback, delay]);
return ((...args: Parameters<T>) => { // If it's a function, return a debounced version
if (timeoutRef.current) { if (typeof valueOrCallback === 'function') {
clearTimeout(timeoutRef.current); return ((...args: any[]) => {
} if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
callback(...args); (valueOrCallback as Function)(...args);
}, delay); }, delay);
}) as T; }) as T;
}
// For values, return the debounced value
return debouncedValue;
} }

View file

@ -32,17 +32,17 @@
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.6", "@tiptap/core": "^2.11.7",
"@tiptap/extension-code-block-lowlight": "^2.11.6", "@tiptap/extension-code-block-lowlight": "^2.11.7",
"@tiptap/extension-table": "^2.11.6", "@tiptap/extension-table": "^2.11.7",
"@tiptap/extension-table-cell": "^2.11.6", "@tiptap/extension-table-cell": "^2.11.7",
"@tiptap/extension-table-header": "^2.11.6", "@tiptap/extension-table-header": "^2.11.7",
"@tiptap/extension-table-row": "^2.11.6", "@tiptap/extension-table-row": "^2.11.7",
"@tiptap/extension-youtube": "^2.11.6", "@tiptap/extension-youtube": "^2.11.7",
"@tiptap/html": "^2.11.6", "@tiptap/html": "^2.11.7",
"@tiptap/pm": "^2.11.6", "@tiptap/pm": "^2.11.7",
"@tiptap/react": "^2.11.6", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.6", "@tiptap/starter-kit": "^2.11.7",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
@ -50,10 +50,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"currency-codes": "^2.2.0", "currency-codes": "^2.2.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.4", "dompurify": "^3.2.5",
"emblor": "^1.4.8", "emblor": "^1.4.8",
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^12.6.2", "framer-motion": "^12.6.3",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.21", "katex": "^0.16.21",
@ -74,7 +74,7 @@
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"styled-components": "^6.1.16", "styled-components": "^6.1.17",
"swr": "^2.3.3", "swr": "^2.3.3",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -84,7 +84,7 @@
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.17", "@tailwindcss/postcss": "^4.1.3",
"@types/node": "20.12.2", "@types/node": "20.12.2",
"@types/react": "19.0.10", "@types/react": "19.0.10",
"@types/react-dom": "19.0.4", "@types/react-dom": "19.0.4",
@ -92,11 +92,11 @@
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"eslint": "^8.57.1", "eslint": "^9.24.0",
"eslint-config-next": "15.2.1", "eslint-config-next": "15.2.1",
"eslint-plugin-unused-imports": "^3.2.0", "eslint-plugin-unused-imports": "^3.2.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.0.17", "tailwindcss": "^4.1.3",
"typescript": "5.4.4" "typescript": "5.4.4"
}, },
"pnpm": { "pnpm": {

1224
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View 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
}