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:
co-authored by
Claude
parent
35a5761a7c
commit
c83e54fae8
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user