feat(teams): add skeleton loading and per_page selector

- Show skeleton cards during pagination loading
- Add per_page dropdown (6, 12, 24, 48 options)
- Reset to page 1 when changing per_page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Maulana Sodiqin
2025-11-29 17:11:56 +07:00
co-authored by Claude
parent 35a5761a7c
commit c83e54fae8
+107 -55
View File
@@ -14,7 +14,33 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select'; import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
const TEAMS_PER_PAGE = 12; const DEFAULT_PER_PAGE = 12;
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
// Skeleton card component for loading state
const TeamCardSkeleton: FC = () => (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1">
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
<div className="space-y-2 mb-4 flex-1">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<div className="space-y-3 mt-auto">
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
</div>
);
const BrowseTeamsPage: FC = (): ReactElement => { const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -24,6 +50,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false); const [showJoinModal, setShowJoinModal] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
// Debounce search term and reset page // Debounce search term and reset page
useEffect(() => { useEffect(() => {
@@ -45,7 +72,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
isFetching, isFetching,
} = useTeams({ } = useTeams({
page: currentPage, page: currentPage,
limit: TEAMS_PER_PAGE, limit: perPage,
search: debouncedSearch, search: debouncedSearch,
city: selectedCity || undefined, city: selectedCity || undefined,
visibility: ETeamVisibility.PUBLIC, visibility: ETeamVisibility.PUBLIC,
@@ -177,16 +204,17 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Teams Count */} {/* Teams Count */}
{!isLoading && total > 0 && ( {!isLoading && total > 0 && (
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400"> <div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing {(currentPage - 1) * TEAMS_PER_PAGE + 1}- Showing {(currentPage - 1) * perPage + 1}-
{Math.min(currentPage * TEAMS_PER_PAGE, total)} of {total} teams {Math.min(currentPage * perPage, total)} of {total} teams
</div> </div>
)} )}
{/* Teams List */} {/* Teams List */}
{isLoading ? ( {isLoading || isFetching ? (
<div className="text-center py-12"> <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<Icon icon="mdi:loading" className="animate-spin text-4xl text-gray-400 mx-auto mb-2" /> {Array.from({ length: perPage }).map((_, index) => (
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p> <TeamCardSkeleton key={index} />
))}
</div> </div>
) : teams.length === 0 ? ( ) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
@@ -199,7 +227,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<> <>
<div className={`grid gap-6 md:grid-cols-2 xl:grid-cols-3 ${isFetching ? 'opacity-50' : ''}`}> <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team: any) => ( {teams.map((team: any) => (
<div <div
key={team.id} key={team.id}
@@ -295,57 +323,81 @@ const BrowseTeamsPage: FC = (): ReactElement => {
</div> </div>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {(totalPages > 1 || total > 6) && (
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<div className="flex items-center gap-2"> {totalPages > 1 && (
<Button <div className="flex items-center gap-2">
variant="secondary" <Button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} variant="secondary"
disabled={currentPage === 1 || isFetching} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
className="px-3" disabled={currentPage === 1 || isFetching}
> className="px-3"
<Icon icon="mdi:chevron-left" className="text-xl" /> >
</Button> <Icon icon="mdi:chevron-left" className="text-xl" />
</Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{getPageNumbers().map((page, index) => {getPageNumbers().map((page, index) =>
typeof page === 'string' ? ( typeof page === 'string' ? (
<span <span
key={`ellipsis-${index}`} key={`ellipsis-${index}`}
className="px-2 text-gray-400 dark:text-gray-500" className="px-2 text-gray-400 dark:text-gray-500"
> >
... ...
</span> </span>
) : ( ) : (
<button <button
key={page} key={page}
onClick={() => setCurrentPage(page)} onClick={() => setCurrentPage(page)}
disabled={isFetching} disabled={isFetching}
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${ className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
currentPage === page currentPage === page
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
}`} }`}
> >
{page} {page}
</button> </button>
) )
)} )}
</div>
<Button
variant="secondary"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages || isFetching}
className="px-3"
>
<Icon icon="mdi:chevron-right" className="text-xl" />
</Button>
</div> </div>
)}
<Button <div className="flex items-center gap-3">
variant="secondary" {totalPages > 1 && (
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} <span className="text-sm text-gray-500 dark:text-gray-400">
disabled={currentPage === totalPages || isFetching} Page {currentPage} of {totalPages}
className="px-3" </span>
> )}
<Icon icon="mdi:chevron-right" className="text-xl" />
</Button> <div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
<select
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div> </div>
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {currentPage} of {totalPages}
</span>
</div> </div>
)} )}
</> </>