Skip to content

Commit b898ab1

Browse files
committed
feat: implement user search page with user cards and pagination
1 parent 44b3259 commit b898ab1

File tree

5 files changed

+128
-21
lines changed

5 files changed

+128
-21
lines changed
File renamed without changes.

web/src/app/(content)/user/[username]/page.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ const UserPage = async ({ params }: { params: { username: string } }) => {
2323
console.error('Failed to get song data:', e);
2424
}
2525

26-
return !userData ? (
27-
<ErrorBox message='Failed to get user data' />
28-
) : (
29-
<UserProfile userData={userData} songData={songData} />
30-
);
26+
if (userData) {
27+
// set the page title to the user's name
28+
document.title = `${userData?.publicName} - User Profile`;
29+
return <UserProfile userData={userData} songData={songData} />;
30+
} else {
31+
return <ErrorBox message='Failed to get user data' />;
32+
}
3133
};
3234

3335
export default UserPage;

web/src/modules/search/components/SearchPageComponent.tsx

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,93 @@
22

33
import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
44
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5+
import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto';
6+
import Image from 'next/image';
57
import { useSearchParams } from 'next/navigation';
6-
import { useEffect } from 'react';
8+
import { useEffect, useState } from 'react';
79

810
import { useSearch } from './client/context/useSearch';
911

12+
type UserCardProps = {
13+
user: UserSearchViewDto;
14+
};
15+
16+
export const UserCard = ({ user }: UserCardProps) => {
17+
const { id, profileImage, songCount, username } = user;
18+
19+
return (
20+
<div className='max-w-sm p-6 bg-zinc-800 rounded-lg shadow-md hover:bg-zinc-750 transition-colors cursor-pointer'>
21+
{/* Profile Image */}
22+
<div className='flex justify-center'>
23+
<Image
24+
src={profileImage}
25+
alt={`Profile picture of ${username}`}
26+
className='w-24 h-24 rounded-full'
27+
width={96}
28+
height={96}
29+
/>
30+
</div>
31+
32+
{/* Username */}
33+
<h2 className='mt-4 text-xl font-bold text-center text-zinc-100'>
34+
{username}
35+
</h2>
36+
37+
{/* Song Count */}
38+
<p className='mt-2 text-sm text-center text-zinc-400'>
39+
{songCount} {songCount === 1 ? 'song' : 'songs'}
40+
</p>
41+
42+
{/* User ID (Optional) */}
43+
<p className='mt-2 text-xs text-center text-zinc-500'>ID: {id}</p>
44+
</div>
45+
);
46+
};
47+
48+
export const UserCardSkeleton = () => {
49+
return (
50+
<div className='max-w-sm p-6 bg-zinc-800 rounded-lg shadow-md animate-pulse'>
51+
{/* Profile Image Skeleton */}
52+
<div className='flex justify-center'>
53+
<div className='w-24 h-24 bg-zinc-700 rounded-full'></div>
54+
</div>
55+
56+
{/* Username Skeleton */}
57+
<div className='mt-4 h-6 bg-zinc-700 rounded mx-auto w-3/4'></div>
58+
59+
{/* Song Count Skeleton */}
60+
<div className='mt-2 h-4 bg-zinc-700 rounded mx-auto w-1/2'></div>
61+
62+
{/* User ID Skeleton */}
63+
<div className='mt-2 h-3 bg-zinc-700 rounded mx-auto w-1/3'></div>
64+
</div>
65+
);
66+
};
67+
1068
export const SearchPageComponent = () => {
1169
const searchParams = useSearchParams();
12-
const { results, query, fetchSearchResults } = useSearch();
70+
const [currentPage, setCurrentPage] = useState(1);
71+
72+
const { data, query, isLoading, limit, page, fetchSearchResults } =
73+
useSearch();
1374

1475
useEffect(() => {
1576
const query = searchParams.get('query') || '';
1677
const page = searchParams.get('page') || '1';
1778
const limit = searchParams.get('limit') || '20';
1879

1980
fetchSearchResults(query, parseInt(page), parseInt(limit));
20-
}, []);
81+
setCurrentPage(parseInt(page));
82+
// eslint-disable-next-line react-hooks/exhaustive-deps
83+
}, [searchParams]);
84+
85+
const handlePageChange = (newPage: number) => {
86+
const query = searchParams.get('query') || '';
87+
const limit = searchParams.get('limit') || '20';
88+
89+
fetchSearchResults(query, newPage, parseInt(limit));
90+
setCurrentPage(newPage);
91+
};
2192

2293
return (
2394
<>
@@ -38,14 +109,44 @@ export const SearchPageComponent = () => {
38109
</h2>
39110
)}
40111

41-
{results.length === 0 ? (
112+
{/* Loading State */}
113+
{isLoading ? (
114+
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
115+
{Array.from({ length: 6 }).map((_, i) => (
116+
<UserCardSkeleton key={i} />
117+
))}
118+
</div>
119+
) : data.length === 0 ? (
42120
<div className='text-center text-xl font-light'>
43121
No results found. Try searching for something else.
44122
</div>
45123
) : (
46-
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
47-
{JSON.stringify(results, null, 2)}
48-
</div>
124+
<>
125+
{/* User Cards */}
126+
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
127+
{data.map((user) => (
128+
<UserCard key={user.id} user={user} />
129+
))}
130+
</div>
131+
132+
{/* Pagination Controls */}
133+
<div className='flex justify-center gap-4 mt-8'>
134+
<button
135+
onClick={() => handlePageChange(currentPage - 1)}
136+
disabled={currentPage === 1}
137+
className='px-4 py-2 bg-zinc-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
138+
>
139+
Previous
140+
</button>
141+
<button
142+
onClick={() => handlePageChange(currentPage + 1)}
143+
disabled={data.length < limit}
144+
className='px-4 py-2 bg-zinc-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
145+
>
146+
Next
147+
</button>
148+
</div>
149+
</>
49150
)}
50151
</>
51152
);
Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client';
22

3+
import { PageResultDTO } from '@shared/validation/common/dto/PageResult.dto';
4+
import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto';
35
import { create } from 'zustand';
46

57
import axios from '@web/src/lib/axios';
@@ -13,31 +15,33 @@ type SearchState = {
1315
query: string;
1416
page: number;
1517
limit: number;
16-
results: any[];
18+
data: UserSearchViewDto[];
1719
isLoading: boolean;
1820
};
1921

20-
export const useSearch = create<SearchState>((set, get) => {
22+
export const useSearch = create<SearchState>((set) => {
2123
const fetchSearchResults = async (
2224
query: string,
2325
page: number,
2426
limit: number,
2527
) => {
2628
set({ isLoading: true });
2729

28-
const result = await axios.get('/user', {
30+
const result = await axios.get<PageResultDTO<UserSearchViewDto>>('/user', {
2931
params: {
3032
query: query,
3133
page: page,
3234
limit: limit,
3335
},
3436
});
3537

38+
const { data } = result;
39+
3640
set({
37-
query,
38-
page,
39-
limit,
40-
results: result.data,
41+
query: query,
42+
page: data.page,
43+
limit: data.limit,
44+
data: data.data,
4145
isLoading: false,
4246
});
4347
};
@@ -47,7 +51,7 @@ export const useSearch = create<SearchState>((set, get) => {
4751
query: '',
4852
page: 1,
4953
limit: 20,
50-
results: [],
54+
data: [],
5155
isLoading: false,
5256
};
5357
});

web/src/modules/shared/components/layout/BlockSearchProps.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const BlockSearch = () => {
4141
query,
4242
});
4343

44-
router.push(`/search?${queryParam.toString()}`);
44+
router.push(`/search-user?${queryParam.toString()}`);
4545
}}
4646
>
4747
<FontAwesomeIcon icon={faMagnifyingGlass} />

0 commit comments

Comments
 (0)