mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: revamp search bar
This commit is contained in:
parent
32a59f0ffc
commit
43a1f4e8e0
2 changed files with 240 additions and 96 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
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 } from 'lucide-react';
|
||||||
import { searchOrgCourses } from '@services/courses/courses';
|
import { searchOrgCourses } from '@services/courses/courses';
|
||||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
@ -14,6 +14,7 @@ interface Course {
|
||||||
description: string;
|
description: string;
|
||||||
thumbnail_image: string;
|
thumbnail_image: string;
|
||||||
course_uuid: string;
|
course_uuid: string;
|
||||||
|
tags?: string[];
|
||||||
authors: Array<{
|
authors: Array<{
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
|
|
@ -27,7 +28,29 @@ interface SearchBarProps {
|
||||||
isMobile?: boolean;
|
isMobile?: 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,
|
||||||
|
}) => {
|
||||||
const org = useOrg() as any;
|
const org = useOrg() as any;
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
|
@ -35,30 +58,10 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
|
||||||
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 +75,189 @@ export const SearchBar: React.FC<SearchBarProps> = ({ orgslug, className = '', i
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedSearchFunction(searchQuery);
|
const fetchCourses = async () => {
|
||||||
}, [searchQuery, debouncedSearchFunction]);
|
if (debouncedSearch.trim().length === 0) {
|
||||||
|
setCourses([]);
|
||||||
const handleSearchFocus = () => {
|
setIsLoading(false);
|
||||||
if (searchQuery.trim().length > 0) {
|
return;
|
||||||
setShowResults(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await searchOrgCourses(
|
||||||
|
orgslug,
|
||||||
|
debouncedSearch,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
null,
|
||||||
|
session?.data?.tokens?.access_token
|
||||||
|
);
|
||||||
|
setCourses(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching courses:', error);
|
||||||
|
setCourses([]);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsInitialLoad(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
fetchCourses();
|
||||||
<div ref={searchRef} className={`relative ${className}`}>
|
}, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]);
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setShowResults(true);
|
|
||||||
}}
|
|
||||||
onFocus={handleSearchFocus}
|
|
||||||
placeholder="Search courses..."
|
|
||||||
className="w-full h-9 pl-10 pr-4 rounded-lg border border-gray-200 bg-white/50 focus:outline-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>
|
|
||||||
|
|
||||||
{showResults && (searchQuery.trim().length > 0 || isLoading) && (
|
const MemoizedEmptyState = useMemo(() => {
|
||||||
<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]'}`}>
|
if (!searchQuery.trim()) {
|
||||||
{isLoading ? (
|
return (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<div className="py-8 px-4">
|
||||||
<div className="animate-pulse">Searching...</div>
|
<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 MemoizedCourseResults = useMemo(() => {
|
||||||
|
if (!courses.length) 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>
|
||||||
) : courses.length > 0 ? (
|
|
||||||
<div className="py-2">
|
|
||||||
{courses.map((course) => (
|
{courses.map((course) => (
|
||||||
<Link
|
<Link
|
||||||
key={course.course_uuid}
|
key={course.course_uuid}
|
||||||
prefetch
|
|
||||||
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
||||||
className="block hover:bg-gray-50 transition-colors"
|
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
|
||||||
onClick={() => setShowResults(false)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center p-3 space-x-3">
|
<div className="relative">
|
||||||
{course.thumbnail_image && (
|
{course.thumbnail_image ? (
|
||||||
<img
|
<img
|
||||||
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
|
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
|
||||||
alt={course.name}
|
alt={course.name}
|
||||||
className="w-12 h-12 object-cover rounded-lg"
|
className="w-10 h-10 object-cover rounded-lg"
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
|
||||||
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3>
|
<Book size={20} className="text-black/40" />
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}, [courses, 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
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 text-center text-gray-500">
|
<>
|
||||||
No courses found
|
{MemoizedSearchSuggestions}
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<CourseResultsSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{MemoizedCourseResults}
|
||||||
|
{(courses.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>
|
||||||
)}
|
|
||||||
</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;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue