From af9809942923e13b1bc5c5d3b479199bba578e98 Mon Sep 17 00:00:00 2001
From: "oussama.aftys" <oussama.aftys@marketingconfort.com>
Date: Tue, 3 Jun 2025 11:14:49 +0100
Subject: [PATCH] added live pagination in client side
---
src/components/live-filters/index.ts | 1 +
src/components/live-filters/live-filters.tsx | 144 +++++++++++++++++
src/components/pagination/index.ts | 1 +
src/components/pagination/pagination.tsx | 69 ++++++++
src/hooks/use-live-pagination.ts | 127 +++++++++++++++
.../live/page-view/all-lives-advanced.tsx | 153 ++++++++++++++++++
src/sections/live/page-view/all-lives.tsx | 108 ++++++++++---
src/sections/live/page-view/index.ts | 1 +
src/shared/api/live.ts | 24 +++
src/shared/api/server.ts | 1 +
10 files changed, 607 insertions(+), 22 deletions(-)
create mode 100644 src/components/live-filters/index.ts
create mode 100644 src/components/live-filters/live-filters.tsx
create mode 100644 src/components/pagination/index.ts
create mode 100644 src/components/pagination/pagination.tsx
create mode 100644 src/hooks/use-live-pagination.ts
create mode 100644 src/sections/live/page-view/all-lives-advanced.tsx
diff --git a/src/components/live-filters/index.ts b/src/components/live-filters/index.ts
new file mode 100644
index 00000000..982bc7f1
--- /dev/null
+++ b/src/components/live-filters/index.ts
@@ -0,0 +1 @@
+export { default } from './live-filters';
\ No newline at end of file
diff --git a/src/components/live-filters/live-filters.tsx b/src/components/live-filters/live-filters.tsx
new file mode 100644
index 00000000..6084af04
--- /dev/null
+++ b/src/components/live-filters/live-filters.tsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import {
+ Box,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ TextField,
+ Chip,
+ InputAdornment,
+ SelectChangeEvent
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import ClearIcon from '@mui/icons-material/Clear';
+import { LiveStatus } from '@/shared/types/live';
+
+interface LiveFilters {
+ status?: LiveStatus;
+ search?: string;
+}
+
+interface LiveFiltersProps {
+ filters: LiveFilters;
+ onFilterChange: (filters: Partial<LiveFilters>) => void;
+ onClearFilters: () => void;
+ isFiltered: boolean;
+}
+
+const statusLabels: Record<LiveStatus, string> = {
+ [LiveStatus.ONGOING]: 'En cours',
+ [LiveStatus.COMING]: 'À venir',
+ [LiveStatus.REVIEW]: 'En révision',
+ [LiveStatus.REPLAY]: 'Replay'
+};
+
+const LiveFiltersComponent: React.FC<LiveFiltersProps> = ({
+ filters,
+ onFilterChange,
+ onClearFilters,
+ isFiltered
+}) => {
+ const handleStatusChange = (event: SelectChangeEvent<string>) => {
+ const value = event.target.value;
+ onFilterChange({
+ status: value ? (value as LiveStatus) : undefined
+ });
+ };
+
+ const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const value = event.target.value;
+ onFilterChange({
+ search: value || undefined
+ });
+ };
+
+ return (
+ <Box sx={{ mb: 3 }}>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: { xs: 'column', sm: 'row' },
+ gap: 2,
+ alignItems: { xs: 'stretch', sm: 'center' },
+ mb: 2
+ }}
+ >
+ {/* Search Field */}
+ <TextField
+ placeholder="Rechercher un live..."
+ value={filters.search || ''}
+ onChange={handleSearchChange}
+ size="small"
+ sx={{ minWidth: { xs: '100%', sm: 250 } }}
+ InputProps={{
+ startAdornment: (
+ <InputAdornment position="start">
+ <SearchIcon color="action" />
+ </InputAdornment>
+ ),
+ }}
+ />
+
+ {/* Status Filter */}
+ <FormControl size="small" sx={{ minWidth: { xs: '100%', sm: 150 } }}>
+ <InputLabel>Statut</InputLabel>
+ <Select
+ value={filters.status || ''}
+ onChange={handleStatusChange}
+ label="Statut"
+ >
+ <MenuItem value="">
+ <em>Tous les statuts</em>
+ </MenuItem>
+ {Object.entries(statusLabels).map(([status, label]) => (
+ <MenuItem key={status} value={status}>
+ {label}
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ </Box>
+
+ {/* Active Filters Display */}
+ {isFiltered && (
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
+ <Box component="span" sx={{ fontSize: '0.875rem', color: 'text.secondary' }}>
+ Filtres actifs:
+ </Box>
+
+ {filters.status && (
+ <Chip
+ label={`Statut: ${statusLabels[filters.status]}`}
+ size="small"
+ onDelete={() => onFilterChange({ status: undefined })}
+ color="primary"
+ variant="outlined"
+ />
+ )}
+
+ {filters.search && (
+ <Chip
+ label={`Recherche: "${filters.search}"`}
+ size="small"
+ onDelete={() => onFilterChange({ search: undefined })}
+ color="primary"
+ variant="outlined"
+ />
+ )}
+
+ <Chip
+ label="Effacer tout"
+ size="small"
+ onClick={onClearFilters}
+ color="default"
+ variant="outlined"
+ icon={<ClearIcon />}
+ />
+ </Box>
+ )}
+ </Box>
+ );
+};
+
+export default LiveFiltersComponent;
\ No newline at end of file
diff --git a/src/components/pagination/index.ts b/src/components/pagination/index.ts
new file mode 100644
index 00000000..7433829d
--- /dev/null
+++ b/src/components/pagination/index.ts
@@ -0,0 +1 @@
+export { default } from './pagination';
\ No newline at end of file
diff --git a/src/components/pagination/pagination.tsx b/src/components/pagination/pagination.tsx
new file mode 100644
index 00000000..1742a646
--- /dev/null
+++ b/src/components/pagination/pagination.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { Box, Pagination as MuiPagination, Typography } from '@mui/material';
+
+interface PaginationProps {
+ currentPage: number;
+ totalPages: number;
+ totalElements: number;
+ pageSize: number;
+ onPageChange: (page: number) => void;
+ loading?: boolean;
+}
+
+const Pagination: React.FC<PaginationProps> = ({
+ currentPage,
+ totalPages,
+ totalElements,
+ pageSize,
+ onPageChange,
+ loading = false
+}) => {
+ const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
+ if (!loading) {
+ onPageChange(page - 1); // MUI Pagination est 1-based, mais notre API est 0-based
+ }
+ };
+
+ const startItem = currentPage * pageSize + 1;
+ const endItem = Math.min((currentPage + 1) * pageSize, totalElements);
+
+ if (totalPages <= 1) {
+ return null;
+ }
+
+ return (
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: { xs: 'column', sm: 'row' },
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 2,
+ mt: 4,
+ mb: 2
+ }}
+ >
+ <Typography variant="body2" color="text.secondary">
+ Affichage {startItem}-{endItem} sur {totalElements} lives
+ </Typography>
+
+ <MuiPagination
+ count={totalPages}
+ page={currentPage + 1} // MUI Pagination est 1-based
+ onChange={handlePageChange}
+ color="primary"
+ size="medium"
+ disabled={loading}
+ showFirstButton
+ showLastButton
+ sx={{
+ '& .MuiPaginationItem-root': {
+ fontSize: '0.875rem',
+ }
+ }}
+ />
+ </Box>
+ );
+};
+
+export default Pagination;
\ No newline at end of file
diff --git a/src/hooks/use-live-pagination.ts b/src/hooks/use-live-pagination.ts
new file mode 100644
index 00000000..20e3c808
--- /dev/null
+++ b/src/hooks/use-live-pagination.ts
@@ -0,0 +1,127 @@
+import { useState, useCallback, useMemo } from 'react';
+import { useGetLivesPaginated } from '@/shared/api/live';
+import { LiveStatus } from '@/shared/types/live';
+
+interface UseLivePaginationOptions {
+ initialPageSize?: number;
+ initialPage?: number;
+}
+
+interface LiveFilters {
+ status?: LiveStatus;
+ search?: string;
+}
+
+export const useLivePagination = (options: UseLivePaginationOptions = {}) => {
+ const { initialPageSize = 8, initialPage = 0 } = options;
+
+ const [currentPage, setCurrentPage] = useState(initialPage);
+ const [pageSize, setPageSize] = useState(initialPageSize);
+ const [filters, setFilters] = useState<LiveFilters>({});
+
+ const {
+ livesData,
+ livesLoading,
+ livesError,
+ totalElements,
+ totalPages,
+ hasNext,
+ hasPrevious,
+ livesValidating
+ } = useGetLivesPaginated(currentPage, pageSize);
+
+ // Filtrer les données côté client (à améliorer côté serveur plus tard)
+ const filteredLivesData = useMemo(() => {
+ if (!livesData) return [];
+
+ let filtered = [...livesData];
+
+ if (filters.status) {
+ filtered = filtered.filter(live => live.status === filters.status);
+ }
+
+ if (filters.search) {
+ const searchLower = filters.search.toLowerCase();
+ filtered = filtered.filter(live =>
+ live.title.toLowerCase().includes(searchLower) ||
+ live.description.toLowerCase().includes(searchLower)
+ );
+ }
+
+ return filtered;
+ }, [livesData, filters]);
+
+ const handlePageChange = useCallback((page: number) => {
+ setCurrentPage(page);
+ }, []);
+
+ const handlePageSizeChange = useCallback((newPageSize: number) => {
+ setPageSize(newPageSize);
+ setCurrentPage(0); // Reset to first page
+ }, []);
+
+ const handleFilterChange = useCallback((newFilters: Partial<LiveFilters>) => {
+ setFilters(prev => ({ ...prev, ...newFilters }));
+ setCurrentPage(0); // Reset to first page when filters change
+ }, []);
+
+ const clearFilters = useCallback(() => {
+ setFilters({});
+ setCurrentPage(0);
+ }, []);
+
+ const goToPage = useCallback((page: number) => {
+ if (page >= 0 && page < totalPages) {
+ setCurrentPage(page);
+ }
+ }, [totalPages]);
+
+ const goToNextPage = useCallback(() => {
+ if (hasNext) {
+ setCurrentPage(prev => prev + 1);
+ }
+ }, [hasNext]);
+
+ const goToPreviousPage = useCallback(() => {
+ if (hasPrevious) {
+ setCurrentPage(prev => prev - 1);
+ }
+ }, [hasPrevious]);
+
+ const scrollToTop = useCallback(() => {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, []);
+
+ return {
+ // Data
+ livesData: filteredLivesData,
+ totalElements,
+ totalPages,
+ currentPage,
+ pageSize,
+ hasNext,
+ hasPrevious,
+
+ // Loading states
+ livesLoading,
+ livesError,
+ livesValidating,
+
+ // Filters
+ filters,
+
+ // Actions
+ handlePageChange,
+ handlePageSizeChange,
+ handleFilterChange,
+ clearFilters,
+ goToPage,
+ goToNextPage,
+ goToPreviousPage,
+ scrollToTop,
+
+ // Computed
+ isEmpty: filteredLivesData.length === 0,
+ isFiltered: Object.keys(filters).some(key => filters[key as keyof LiveFilters]),
+ };
+};
\ No newline at end of file
diff --git a/src/sections/live/page-view/all-lives-advanced.tsx b/src/sections/live/page-view/all-lives-advanced.tsx
new file mode 100644
index 00000000..46b12e69
--- /dev/null
+++ b/src/sections/live/page-view/all-lives-advanced.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import React, { useEffect } from 'react';
+import { Grid, Box, Typography, Fade } from '@mui/material';
+import LiveCard from '../all-lives/live-card';
+import CircularProgress from "@mui/material/CircularProgress";
+import FlexRowCenter from "@/components/flex-box/flex-row-center";
+import { useSearchParams } from 'next/navigation';
+import Pagination from '@/components/pagination';
+import LiveFilters from '@/components/live-filters';
+import { useLivePagination } from '@/hooks/use-live-pagination';
+
+const LIVES_PER_PAGE = 8;
+
+const AllLivesAdvanced = () => {
+ const params = useSearchParams();
+ const liveId = params.get('liveId');
+
+ const {
+ // Data
+ livesData,
+ totalElements,
+ totalPages,
+ currentPage,
+ pageSize,
+
+ // Loading states
+ livesLoading,
+ livesError,
+
+ // Filters
+ filters,
+
+ // Actions
+ handlePageChange,
+ handleFilterChange,
+ clearFilters,
+ scrollToTop,
+
+ // Computed
+ isEmpty,
+ isFiltered,
+ } = useLivePagination({
+ initialPageSize: LIVES_PER_PAGE,
+ initialPage: 0
+ });
+
+ const handlePageChangeWithScroll = (page: number) => {
+ handlePageChange(page);
+ scrollToTop();
+ };
+
+ if (livesError) {
+ return (
+ <Box sx={{ paddingX: { xs: 3, md: 10, lg: 20 }, paddingY: 4 }}>
+ <Box sx={{ textAlign: 'center', py: 8 }}>
+ <Typography variant="h6" color="error">
+ Erreur lors du chargement des lives
+ </Typography>
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
+ Veuillez rafraîchir la page ou réessayer plus tard
+ </Typography>
+ </Box>
+ </Box>
+ );
+ }
+
+ return (
+ <Box sx={{ paddingX: { xs: 3, md: 10, lg: 20 }, paddingY: 4 }}>
+ {/* Header section */}
+ <Box sx={{ mb: 4, textAlign: 'center' }}>
+ <Typography variant="h4" component="h1" sx={{ mb: 2, fontWeight: 'bold' }}>
+ Nos Lives
+ </Typography>
+ <Typography variant="subtitle1" color="text.secondary">
+ Découvrez nos livestreams et interagissez en temps réel
+ </Typography>
+ </Box>
+
+ {/* Filters */}
+ <LiveFilters
+ filters={filters}
+ onFilterChange={handleFilterChange}
+ onClearFilters={clearFilters}
+ isFiltered={isFiltered}
+ />
+
+ {/* Loading State */}
+ {livesLoading && (
+ <FlexRowCenter minHeight="400px">
+ <CircularProgress color="primary" />
+ </FlexRowCenter>
+ )}
+
+ {/* Content */}
+ {!livesLoading && (
+ <Fade in={!livesLoading}>
+ <div>
+ {/* Lives Grid */}
+ <Grid container spacing={3}>
+ {isEmpty ? (
+ <Grid item xs={12}>
+ <Box sx={{ textAlign: 'center', py: 8 }}>
+ <Typography variant="h6" color="text.secondary">
+ {isFiltered
+ ? 'Aucun live ne correspond à vos critères'
+ : 'Aucun live disponible pour le moment'
+ }
+ </Typography>
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
+ {isFiltered
+ ? 'Essayez de modifier vos filtres ou effacez-les pour voir tous les lives'
+ : 'Revenez plus tard pour découvrir nos nouveaux lives !'
+ }
+ </Typography>
+ </Box>
+ </Grid>
+ ) : (
+ livesData.map((live) => (
+ <Grid item xs={12} sm={6} lg={6} key={live.id}>
+ <LiveCard
+ open={liveId === live.id}
+ title={live.title}
+ description={live.description}
+ status={live.status}
+ id={live.id}
+ date={new Date(live.startDate)}
+ imageUrl={live.image}
+ />
+ </Grid>
+ ))
+ )}
+ </Grid>
+
+ {/* Pagination */}
+ {totalElements > 0 && !isEmpty && (
+ <Pagination
+ currentPage={currentPage}
+ totalPages={totalPages}
+ totalElements={totalElements}
+ pageSize={pageSize}
+ onPageChange={handlePageChangeWithScroll}
+ loading={livesLoading}
+ />
+ )}
+ </div>
+ </Fade>
+ )}
+ </Box>
+ );
+};
+
+export default AllLivesAdvanced;
\ No newline at end of file
diff --git a/src/sections/live/page-view/all-lives.tsx b/src/sections/live/page-view/all-lives.tsx
index 5a1eee11..c0cacfd1 100644
--- a/src/sections/live/page-view/all-lives.tsx
+++ b/src/sections/live/page-view/all-lives.tsx
@@ -1,43 +1,107 @@
"use client";
-import React, { useEffect } from 'react';
-import { Grid, Card, CardMedia, CardContent, Typography, Button } from '@mui/material';
+import React, { useEffect, useState } from 'react';
+import { Grid, Card, CardMedia, CardContent, Typography, Button, Box } from '@mui/material';
import LiveCard from '../all-lives/live-card';
-import { useGetLives } from '@/shared/api/live';
+import { useGetLivesPaginated } from '@/shared/api/live';
import CircularProgress from "@mui/material/CircularProgress";
import FlexRowCenter from "@/components/flex-box/flex-row-center";
import { useSearchParams } from 'next/navigation';
+import Pagination from '@/components/pagination';
// import LiveNowComponent from '../LiveNowComponent';
-
+const LIVES_PER_PAGE = 8;
const LivePage = () => {
- const { livesData, livesLoading } = useGetLives();
- const params = useSearchParams()
+ const [currentPage, setCurrentPage] = useState(0);
+ const {
+ livesData,
+ livesLoading,
+ totalElements,
+ totalPages,
+ pageSize,
+ hasNext,
+ hasPrevious
+ } = useGetLivesPaginated(currentPage, LIVES_PER_PAGE);
+
+ const params = useSearchParams();
const liveId = params.get('liveId');
- useEffect(() => {
- }, [livesData]);
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ // Scroll to top when changing page
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ };
- return (
- <>{livesLoading ? (<FlexRowCenter minHeight="100vh">
- <CircularProgress color="primary" />
- </FlexRowCenter>) : (
- <Grid container spacing={2} sx={{ paddingX: { xs: 3, md: 10, lg: 20 }, paddingY: 20 }}>
+ useEffect(() => {
+ // Reset to first page when component mounts
+ setCurrentPage(0);
+ }, []);
- {/* <LiveNowComponent /> */}
+ return (
+ <>
+ {livesLoading ? (
+ <FlexRowCenter minHeight="100vh">
+ <CircularProgress color="primary" />
+ </FlexRowCenter>
+ ) : (
+ <Box sx={{ paddingX: { xs: 3, md: 10, lg: 20 }, paddingY: 4 }}>
+ {/* Header section */}
+ <Box sx={{ mb: 4, textAlign: 'center' }}>
+ <Typography variant="h4" component="h1" sx={{ mb: 2, fontWeight: 'bold' }}>
+ Nos Lives
+ </Typography>
+ <Typography variant="subtitle1" color="text.secondary">
+ Découvrez nos livestreams et interagissez en temps réel
+ </Typography>
+ </Box>
- {livesData.map((live, index) => (
- <Grid item xs={12} md={6} key={index}>
- <LiveCard open={liveId == live.id} title={live.title} description={live.description} status={live.status} id={live.id} date={new Date(live.startDate)} imageUrl={live.image} />
+ {/* Lives Grid */}
+ <Grid container spacing={3}>
+ {/* <LiveNowComponent /> */}
+
+ {livesData.length === 0 ? (
+ <Grid item xs={12}>
+ <Box sx={{ textAlign: 'center', py: 8 }}>
+ <Typography variant="h6" color="text.secondary">
+ Aucun live disponible pour le moment
+ </Typography>
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
+ Revenez plus tard pour découvrir nos nouveaux lives !
+ </Typography>
+ </Box>
+ </Grid>
+ ) : (
+ livesData.map((live, index) => (
+ <Grid item xs={12} sm={6} lg={6} key={live.id}>
+ <LiveCard
+ open={liveId === live.id}
+ title={live.title}
+ description={live.description}
+ status={live.status}
+ id={live.id}
+ date={new Date(live.startDate)}
+ imageUrl={live.image}
+ />
+ </Grid>
+ ))
+ )}
</Grid>
- ))}
-
- </Grid>
- )}
+ {/* Pagination */}
+ {totalElements > 0 && (
+ <Pagination
+ currentPage={currentPage}
+ totalPages={totalPages}
+ totalElements={totalElements}
+ pageSize={pageSize}
+ onPageChange={handlePageChange}
+ loading={livesLoading}
+ />
+ )}
+ </Box>
+ )}
</>
-
);
};
diff --git a/src/sections/live/page-view/index.ts b/src/sections/live/page-view/index.ts
index a3f7b160..6655cf96 100644
--- a/src/sections/live/page-view/index.ts
+++ b/src/sections/live/page-view/index.ts
@@ -1,2 +1,3 @@
export { default as AllLivesView } from "./all-lives";
+export { default as AllLivesAdvancedView } from "./all-lives-advanced";
export { default as LiveView } from "./live-popup";
diff --git a/src/shared/api/live.ts b/src/shared/api/live.ts
index 8f973783..3dbe72bb 100644
--- a/src/shared/api/live.ts
+++ b/src/shared/api/live.ts
@@ -41,6 +41,30 @@ export function useGetLives() {
return memoizedValue;
}
+export function useGetLivesPaginated(page: number = 0, size: number = 10) {
+ const URL = [endpoints.live.clientWithPagination(page, size)];
+
+ const { data, isLoading, error, isValidating } = useSWR(URL, fetcher, options);
+
+ const memoizedValue = useMemo(
+ () => ({
+ livesData: sortLivesByStatus((data?.content as ILiveItem[]) || []),
+ totalElements: data?.totalElements || 0,
+ totalPages: data?.totalPages || 0,
+ currentPage: data?.number || 0,
+ pageSize: data?.size || size,
+ hasNext: !data?.last || false,
+ hasPrevious: !data?.first || false,
+ livesLoading: isLoading,
+ livesError: error,
+ livesValidating: isValidating,
+ }),
+ [data, error, isLoading, isValidating, size]
+ );
+
+ return memoizedValue;
+}
+
export function useGetLive(liveId: number | string) {
diff --git a/src/shared/api/server.ts b/src/shared/api/server.ts
index 0665a160..089138bc 100644
--- a/src/shared/api/server.ts
+++ b/src/shared/api/server.ts
@@ -97,6 +97,7 @@ export const endpoints = {
},
live: {
client: '/api/stream/live/client',
+ clientWithPagination: (page: number, size: number) => `/api/stream/live/client/paginated?page=${page}&size=${size}`,
get: (liveId: string) => `/api/stream/live?liveId=${liveId}`,
ongoing: '/api/stream/live/ongoing',
--
GitLab