mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: introduce search page + improvements to the search experience
This commit is contained in:
parent
3bc6703f33
commit
12e1d79504
4 changed files with 479 additions and 36 deletions
|
|
@ -8,6 +8,7 @@ from src.db.courses.courses import Course, CourseRead
|
||||||
from src.db.collections import Collection, CollectionRead
|
from src.db.collections import Collection, CollectionRead
|
||||||
from src.db.collections_courses import CollectionCourse
|
from src.db.collections_courses import CollectionCourse
|
||||||
from src.db.organizations import Organization
|
from src.db.organizations import Organization
|
||||||
|
from src.db.user_organizations import UserOrganization
|
||||||
from src.services.courses.courses import search_courses
|
from src.services.courses.courses import search_courses
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
@ -60,6 +61,10 @@ async def search_across_org(
|
||||||
# Search users
|
# Search users
|
||||||
users_query = (
|
users_query = (
|
||||||
select(User)
|
select(User)
|
||||||
|
.join(UserOrganization, and_(
|
||||||
|
UserOrganization.user_id == User.id,
|
||||||
|
UserOrganization.org_id == org.id
|
||||||
|
))
|
||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
text('LOWER("user".username) LIKE LOWER(:pattern) OR ' +
|
text('LOWER("user".username) LIKE LOWER(:pattern) OR ' +
|
||||||
|
|
|
||||||
434
apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx
Normal file
434
apps/web/app/orgs/[orgslug]/(withmenu)/search/page.tsx
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { searchOrgContent } from '@services/search/search';
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext';
|
||||||
|
import { Book, GraduationCap, Users, Search, Filter, X } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media';
|
||||||
|
import { getUriWithOrg } from '@services/config/config';
|
||||||
|
import { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail';
|
||||||
|
import UserAvatar from '@components/Objects/UserAvatar';
|
||||||
|
|
||||||
|
// Types from SearchBar component
|
||||||
|
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;
|
||||||
|
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[];
|
||||||
|
total_courses: number;
|
||||||
|
total_collections: number;
|
||||||
|
total_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentType = 'all' | 'courses' | 'collections' | 'users';
|
||||||
|
|
||||||
|
function SearchPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const session = useLHSession() as any;
|
||||||
|
const org = useOrg() as any;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||||
|
courses: [],
|
||||||
|
collections: [],
|
||||||
|
users: [],
|
||||||
|
total_courses: 0,
|
||||||
|
total_collections: 0,
|
||||||
|
total_users: 0
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
|
||||||
|
|
||||||
|
// URL parameters
|
||||||
|
const query = searchParams.get('q') || '';
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const type = (searchParams.get('type') as ContentType) || 'all';
|
||||||
|
const perPage = 9;
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [selectedType, setSelectedType] = useState<ContentType>(type);
|
||||||
|
|
||||||
|
const updateSearchParams = (updates: Record<string, string>) => {
|
||||||
|
const current = new URLSearchParams(Array.from(searchParams.entries()));
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
if (value) {
|
||||||
|
current.set(key, value);
|
||||||
|
} else {
|
||||||
|
current.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.push(`?${current.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
updateSearchParams({ q: searchQuery, page: '1' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchResults = async () => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults({
|
||||||
|
courses: [],
|
||||||
|
collections: [],
|
||||||
|
users: [],
|
||||||
|
total_courses: 0,
|
||||||
|
total_collections: 0,
|
||||||
|
total_users: 0
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchOrgContent(
|
||||||
|
org?.slug,
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
selectedType === 'all' ? null : selectedType,
|
||||||
|
session?.data?.tokens?.access_token
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log the response to see what we're getting
|
||||||
|
console.log('Search API Response:', response);
|
||||||
|
|
||||||
|
// The response data is directly what we need
|
||||||
|
const results = response.data;
|
||||||
|
|
||||||
|
setSearchResults({
|
||||||
|
courses: results.courses || [],
|
||||||
|
collections: results.collections || [],
|
||||||
|
users: results.users || [],
|
||||||
|
total_courses: results.courses?.length || 0,
|
||||||
|
total_collections: results.collections?.length || 0,
|
||||||
|
total_users: results.users?.length || 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching content:', error);
|
||||||
|
setSearchResults({
|
||||||
|
courses: [],
|
||||||
|
collections: [],
|
||||||
|
users: [],
|
||||||
|
total_courses: 0,
|
||||||
|
total_collections: 0,
|
||||||
|
total_users: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResults();
|
||||||
|
}, [query, page, selectedType, org?.slug, session?.data?.tokens?.access_token]);
|
||||||
|
|
||||||
|
const totalResults = searchResults.total_courses + searchResults.total_collections + searchResults.total_users;
|
||||||
|
const totalPages = Math.ceil(totalResults / perPage);
|
||||||
|
|
||||||
|
const FilterButton = ({ type, count, icon: Icon }: { type: ContentType; count: number; icon: any }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedType(type);
|
||||||
|
updateSearchParams({ type: type === 'all' ? '' : type, page: '1' });
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
selectedType === type
|
||||||
|
? 'bg-black/10 text-black/80 font-medium'
|
||||||
|
: 'hover:bg-black/5 text-black/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
<span>{type.charAt(0).toUpperCase() + type.slice(1)}</span>
|
||||||
|
<span className="text-black/40">({count})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Pagination = () => {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center gap-2 mt-8">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => updateSearchParams({ page: pageNum.toString() })}
|
||||||
|
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
|
||||||
|
page === pageNum
|
||||||
|
? 'bg-black/10 text-black/80 font-medium'
|
||||||
|
: 'hover:bg-black/5 text-black/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingState = () => (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl nice-shadow p-4 animate-pulse">
|
||||||
|
<div className="w-full h-32 bg-black/5 rounded-lg mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="w-3/4 h-4 bg-black/5 rounded" />
|
||||||
|
<div className="w-1/2 h-3 bg-black/5 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="mb-4 p-4 bg-black/5 rounded-full">
|
||||||
|
<Search className="w-8 h-8 text-black/40" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-black/80 mb-2">No results found</h3>
|
||||||
|
<p className="text-sm text-black/50 max-w-md">
|
||||||
|
We couldn't find any matches for "{query}". Try adjusting your search terms or browse our featured content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Search Header */}
|
||||||
|
<div className="bg-white border-b border-black/5">
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-semibold text-black/80 mb-6">Search</h1>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<form onSubmit={handleSearch} className="relative group mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search courses, users, collections..."
|
||||||
|
className="w-full h-12 pl-12 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-4 flex items-center pointer-events-none">
|
||||||
|
<Search className="text-black/40 group-focus-within:text-black/60 transition-colors" size={20} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="absolute inset-y-0 right-0 px-4 flex items-center text-sm text-black/60 hover:text-black/80"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||||
|
<FilterButton type="all" count={totalResults} icon={Search} />
|
||||||
|
<FilterButton type="courses" count={searchResults.total_courses} icon={GraduationCap} />
|
||||||
|
<FilterButton type="collections" count={searchResults.total_collections} icon={Book} />
|
||||||
|
<FilterButton type="users" count={searchResults.total_users} icon={Users} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{query && (
|
||||||
|
<div className="text-sm text-black/60 mb-6">
|
||||||
|
Found {totalResults} results for "{query}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : totalResults === 0 && query ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{/* Courses Grid */}
|
||||||
|
{(selectedType === 'all' || selectedType === 'courses') && searchResults.courses.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-medium text-black/80 mb-4 flex items-center gap-2">
|
||||||
|
<GraduationCap size={20} className="text-black/60" />
|
||||||
|
Courses ({searchResults.courses.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{searchResults.courses.map((course) => (
|
||||||
|
<Link
|
||||||
|
key={course.course_uuid}
|
||||||
|
href={getUriWithOrg(org?.slug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
||||||
|
className="bg-white rounded-xl nice-shadow hover:shadow-md transition-all overflow-hidden group"
|
||||||
|
>
|
||||||
|
<div className="relative h-48">
|
||||||
|
{course.thumbnail_image ? (
|
||||||
|
<img
|
||||||
|
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
|
||||||
|
alt={course.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-black/5 flex items-center justify-center">
|
||||||
|
<GraduationCap size={32} className="text-black/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-sm font-medium text-black/80 mb-1">{course.name}</h3>
|
||||||
|
<p className="text-xs text-black/50 line-clamp-2">{course.description}</p>
|
||||||
|
{course.authors && course.authors.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<UserAvatar
|
||||||
|
width={20}
|
||||||
|
avatar_url={course.authors[0].user.avatar_image ? getUserAvatarMediaDirectory(course.authors[0].user.user_uuid, course.authors[0].user.avatar_image) : ''}
|
||||||
|
predefined_avatar={course.authors[0].user.avatar_image ? undefined : 'empty'}
|
||||||
|
userId={course.authors[0].user.id.toString()}
|
||||||
|
showProfilePopup={false}
|
||||||
|
rounded="rounded-full"
|
||||||
|
backgroundColor="bg-gray-100"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-black/40">
|
||||||
|
{course.authors[0].user.first_name} {course.authors[0].user.last_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections Grid */}
|
||||||
|
{(selectedType === 'all' || selectedType === 'collections') && searchResults.collections.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-medium text-black/80 mb-4 flex items-center gap-2">
|
||||||
|
<Book size={20} className="text-black/60" />
|
||||||
|
Collections ({searchResults.collections.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{searchResults.collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection.collection_uuid}
|
||||||
|
href={getUriWithOrg(org?.slug, `/collection/${collection.collection_uuid.replace('collection_', '')}`)}
|
||||||
|
className="flex items-start gap-4 p-4 bg-white rounded-xl nice-shadow hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-black/5 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Book size={24} className="text-black/40" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-black/80 mb-1">{collection.name}</h3>
|
||||||
|
<p className="text-xs text-black/50 line-clamp-2">{collection.description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users Grid */}
|
||||||
|
{(selectedType === 'all' || selectedType === 'users') && searchResults.users.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-medium text-black/80 mb-4 flex items-center gap-2">
|
||||||
|
<Users size={20} className="text-black/60" />
|
||||||
|
Users ({searchResults.users.length})
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{searchResults.users.map((user) => (
|
||||||
|
<Link
|
||||||
|
key={user.user_uuid}
|
||||||
|
href={getUriWithOrg(org?.slug, `/user/${user.username}`)}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-xl nice-shadow hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
width={48}
|
||||||
|
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>
|
||||||
|
<h3 className="text-sm font-medium text-black/80">
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-black/50">@{user.username}</p>
|
||||||
|
{user.details?.title?.text && (
|
||||||
|
<p className="text-xs text-black/40 mt-1">{user.details.title.text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPage;
|
||||||
|
|
@ -238,42 +238,6 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||||
<span className="font-medium">Quick Results</span>
|
<span className="font-medium">Quick Results</span>
|
||||||
</div>
|
</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 */}
|
{/* Courses Section */}
|
||||||
{searchResults.courses.length > 0 && (
|
{searchResults.courses.length > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|
@ -342,6 +306,42 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [searchResults, orgslug, org?.org_uuid]);
|
}, [searchResults, orgslug, org?.org_uuid]);
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,10 @@ layer(base);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue