mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #464 from learnhouse/feat/advanced-search
Advanced Search
This commit is contained in:
commit
db45d8ef31
9 changed files with 1222 additions and 774 deletions
|
|
@ -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"]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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
|
# 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)
|
||||||
|
|
||||||
|
|
|
||||||
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,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: [] });
|
||||||
const handleSearchFocus = () => {
|
setIsLoading(false);
|
||||||
if (searchQuery.trim().length > 0) {
|
return;
|
||||||
setShowResults(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
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 (
|
return (
|
||||||
<div ref={searchRef} className={`relative ${className}`}>
|
<div className="py-8 px-4">
|
||||||
<div className="relative">
|
<div className="flex flex-col items-center text-center">
|
||||||
<input
|
<div className="mb-4 p-3 bg-black/5 rounded-full">
|
||||||
type="text"
|
<Sparkles className="w-6 h-6 text-black/70" />
|
||||||
value={searchQuery}
|
</div>
|
||||||
onChange={(e) => {
|
<h3 className="text-sm font-medium text-black/80 mb-1">
|
||||||
setSearchQuery(e.target.value);
|
Discover Your Next Learning Journey
|
||||||
setShowResults(true);
|
</h3>
|
||||||
}}
|
<p className="text-xs text-black/50 max-w-[240px]">
|
||||||
onFocus={handleSearchFocus}
|
Start typing to search through available content
|
||||||
placeholder="Search courses..."
|
</p>
|
||||||
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"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
);
|
||||||
|
}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{showResults && (searchQuery.trim().length > 0 || isLoading) && (
|
{/* Users Section */}
|
||||||
<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]'}`}>
|
{searchResults.users.length > 0 && (
|
||||||
{isLoading ? (
|
<div className="mb-2">
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
|
||||||
<div className="animate-pulse">Searching...</div>
|
<Users size={12} />
|
||||||
|
<span>Users</span>
|
||||||
</div>
|
</div>
|
||||||
) : courses.length > 0 ? (
|
{searchResults.users.map((user) => (
|
||||||
<div className="py-2">
|
|
||||||
{courses.map((course) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={course.course_uuid}
|
key={user.user_uuid}
|
||||||
prefetch
|
href={getUriWithOrg(orgslug, `/user/${user.username}`)}
|
||||||
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"
|
||||||
className="block hover:bg-gray-50 transition-colors"
|
|
||||||
onClick={() => setShowResults(false)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center p-3 space-x-3">
|
<UserAvatar
|
||||||
{course.thumbnail_image && (
|
width={40}
|
||||||
<img
|
avatar_url={user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : ''}
|
||||||
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
|
predefined_avatar={user.avatar_image ? undefined : 'empty'}
|
||||||
alt={course.name}
|
userId={user.id.toString()}
|
||||||
className="w-12 h-12 object-cover rounded-lg"
|
showProfilePopup
|
||||||
|
rounded="rounded-full"
|
||||||
|
backgroundColor="bg-gray-100"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-gray-500 truncate">{course.description}</p>
|
<h3 className="text-sm font-medium text-black/80 truncate">
|
||||||
{course.authors && course.authors[0] && (
|
{user.first_name} {user.last_name}
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
</h3>
|
||||||
by {course.authors[0].first_name} {course.authors[0].last_name}
|
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">User</span>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-black/50 truncate">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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="p-4 text-center text-gray-500">
|
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
|
||||||
No courses found
|
<Book size={20} className="text-black/40" />
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}, [searchResults, orgslug, org?.org_uuid]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setShowResults(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={searchRef} className={`relative ${className}`}>
|
||||||
|
<div className="relative group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onFocus={() => setShowResults(true)}
|
||||||
|
placeholder="Search courses, users, collections..."
|
||||||
|
className="w-full h-9 pl-11 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"
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
className={`absolute z-50 w-full mt-2 bg-white rounded-xl nice-shadow
|
||||||
|
overflow-hidden divide-y divide-black/5
|
||||||
|
transition-all duration-200 ease-in-out transform
|
||||||
|
${showResults ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2 pointer-events-none'}
|
||||||
|
${isMobile ? 'max-w-full' : 'min-w-[400px]'}`}
|
||||||
|
>
|
||||||
|
{(!searchQuery.trim() || isInitialLoad) ? (
|
||||||
|
MemoizedEmptyState
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showSearchSuggestions && MemoizedSearchSuggestions}
|
||||||
|
{isLoading ? (
|
||||||
|
<CourseResultsSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<span>View all results</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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 () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
return ((...args: Parameters<T>) => {
|
// For values, update the debounced value after the delay
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setDebouncedValue(valueOrCallback);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [valueOrCallback, delay]);
|
||||||
|
|
||||||
|
// If it's a function, return a debounced version
|
||||||
|
if (typeof valueOrCallback === 'function') {
|
||||||
|
return ((...args: any[]) => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(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;
|
||||||
}
|
}
|
||||||
|
|
@ -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
1224
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
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