diff --git a/src/components/live-filters/index.ts b/src/components/live-filters/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..982bc7f15f38274c575927b5a0224fbac50c615d --- /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 0000000000000000000000000000000000000000..6084af04383100f3812a07afab8d5d8afff78b65 --- /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 0000000000000000000000000000000000000000..7433829dbc2f2b784ffbc7796f538b673109c9bf --- /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 0000000000000000000000000000000000000000..1742a64675567c470584a80b7dacd0b98a1cd576 --- /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 0000000000000000000000000000000000000000..20e3c80819a9779cec99dc387413d3a56e996ea7 --- /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 0000000000000000000000000000000000000000..46b12e69dd7883f6e67ea943ff9208338979c9b6 --- /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 5a1eee11944c6ede53b73533fc5087e23284dd60..c0cacfd19455441cc2e9cbe0b391496ff954a880 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 a3f7b16007f89e6952302d00e07aca0b8ced84a7..6655cf961e93ccbee079109c3c6bc1cf3889c320 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 8f9737832c42073be4b7712bf577286aceab49ff..3dbe72bbf557bea4913eac73a2cfb1670fee6552 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 0665a160037ee038e23a99b6d5fa37fdc25bebb6..089138bc5b68bfcd1068606150e836fdd7178e1a 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',