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 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"]
)

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

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 { Search } from 'lucide-react';
import { searchOrgCourses } from '@services/courses/courses';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
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;
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;
}
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 [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);
const session = useLHSession() as any;
const [isInitialLoad, setIsInitialLoad] = useState(true);
const debouncedSearchFunction = useDebounce(async (query: string) => {
if (query.trim().length === 0) {
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);
// Debounce the search query value
const debouncedSearch = useDebounce(searchQuery, 300);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -72,76 +124,285 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
}, []);
useEffect(() => {
debouncedSearchFunction(searchQuery);
}, [searchQuery, debouncedSearchFunction]);
const fetchResults = async () => {
if (debouncedSearch.trim().length === 0) {
setSearchResults({ courses: [], collections: [], users: [] });
setIsLoading(false);
return;
}
const handleSearchFocus = () => {
if (searchQuery.trim().length > 0) {
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 (
<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 (
<div ref={searchRef} className={`relative ${className}`}>
<div className="relative">
<div className="relative group">
<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-hidden focus:ring-2 focus:ring-gray-200 focus:border-transparent text-sm placeholder:text-gray-400"
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"
/>
<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>
{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
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>
);
};

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>(
callback: T,
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);
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 () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
}, [valueOrCallback, delay]);
return ((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// If it's a function, return a debounced version
if (typeof valueOrCallback === 'function') {
return ((...args: any[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}) as T;
timeoutRef.current = setTimeout(() => {
(valueOrCallback as Function)(...args);
}, delay);
}) as T;
}
// For values, return the debounced value
return debouncedValue;
}

View file

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