Improve session managment and cart section

This commit is contained in:
2025-09-24 17:47:55 +02:00
parent b7dfc9734d
commit ebd6a9d3c1
7 changed files with 482 additions and 220 deletions

View File

@@ -1,12 +1,24 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { removeAllPosters, removePoster } from "../services/action"; import { removeAllPosters, removePoster } from "../services/action";
import DeleteIcon from "@material-ui/icons/Delete"; import DeleteIcon from "@material-ui/icons/Delete";
import {
Box,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@material-ui/core";
const apiUrl = process.env.REACT_APP_API_URL; const apiUrl = process.env.REACT_APP_API_URL;
const Cart = () => { const Cart = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
const selectedPosters = useSelector((state) => { const selectedPosters = useSelector((state) => {
const selections = state.posterSelections; const selections = state.posterSelections;
return Object.entries(selections).flatMap(([movieId, posters]) => return Object.entries(selections).flatMap(([movieId, posters]) =>
@@ -20,14 +32,28 @@ const Cart = () => {
const [downloadFormat, setDownloadFormat] = useState("zip"); const [downloadFormat, setDownloadFormat] = useState("zip");
// Nouvel état pour la confirmation de suppression globale
const [openConfirmClear, setOpenConfirmClear] = useState(false);
const handleRemovePoster = (movieId, posterId) => { const handleRemovePoster = (movieId, posterId) => {
if (movieId && posterId) dispatch(removePoster(movieId, posterId)); if (movieId && posterId) dispatch(removePoster(movieId, posterId));
}; };
const handleRemoveAllPosters = () => { // Ouvre la dialog de confirmation (appelé par le bouton "Clear All Posters")
dispatch(removeAllPosters()); const handleOpenConfirmClear = () => {
setOpenConfirmClear(true);
}; };
// Ferme la dialog sans supprimer
const handleCloseConfirmClear = () => {
setOpenConfirmClear(false);
};
// Confirme et supprime tous les posters
const handleConfirmClearAllPosters = () => {
dispatch(removeAllPosters());
setOpenConfirmClear(false);
};
const handleDownload = async () => { const handleDownload = async () => {
try { try {
@@ -67,7 +93,19 @@ const Cart = () => {
<div className="cart-container"> <div className="cart-container">
<div className="poster-grid"> <div className="poster-grid">
{selectedPosters.length === 0 ? ( {selectedPosters.length === 0 ? (
<p>No posters selected</p> <Box className="empty-cart-container">
<Typography variant="body1" gutterBottom>
Your poster collection is empty. Start exploring movies to add
some posters!
</Typography>
<Button
variant="contained"
className="back-button"
onClick={() => navigate("/")}
>
Browse Movies
</Button>
</Box>
) : ( ) : (
selectedPosters.map((poster) => ( selectedPosters.map((poster) => (
<div <div
@@ -82,7 +120,10 @@ const Cart = () => {
<div className="delete-overlay"> <div className="delete-overlay">
<button <button
className="delete-button" className="delete-button"
onClick={() => handleRemovePoster(poster.movieId, poster.posterId)} onClick={() =>
handleRemovePoster(poster.movieId, poster.posterId)
}
aria-label="Supprimer ce poster"
> >
<DeleteIcon /> <DeleteIcon />
</button> </button>
@@ -112,12 +153,40 @@ const Cart = () => {
</button> </button>
<button <button
className="clear-button" className="clear-button"
onClick={handleRemoveAllPosters} onClick={handleOpenConfirmClear}
disabled={selectedPosters.length === 0} disabled={selectedPosters.length === 0}
aria-haspopup="dialog"
> >
Clear All Posters Clear All Posters
</button> </button>
</div> </div>
<Dialog
open={openConfirmClear}
onClose={handleCloseConfirmClear}
aria-labelledby="confirm-clear-title"
aria-describedby="confirm-clear-description"
>
<DialogTitle id="confirm-clear-title">Confirm deletion</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-clear-description">
Are you sure you want to delete all the posters in your collection?
This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseConfirmClear} color="primary">
Cancel
</Button>
<Button
onClick={handleConfirmClearAllPosters}
color="secondary"
autoFocus
>
Delete all
</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}; };

View File

@@ -1,13 +1,16 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import axios from "axios";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppBar, Toolbar, IconButton, Badge, Button } from '@material-ui/core'; import { AppBar, Toolbar, IconButton, Badge, Button } from "@material-ui/core";
import ShoppingCartIcon from '@material-ui/icons/ShoppingCart'; import ShoppingCartIcon from "@material-ui/icons/ShoppingCart";
import ArrowBackIcon from '@material-ui/icons/ArrowBack'; import ArrowBackIcon from "@material-ui/icons/ArrowBack";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
const apiUrl = process.env.REACT_APP_API_URL;
const selectPosterSelections = (state) => state.posterSelections; const selectPosterSelections = (state) => state.posterSelections;
const NavBar = ({ onRefreshFiles }) => { const NavBar = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -23,43 +26,37 @@ const NavBar = ({ onRefreshFiles }) => {
}; };
const handleCart = () => { const handleCart = () => {
navigate('/Cart'); navigate("/Cart");
}; };
const isPosterSelectorPage = location.pathname !== '/PosterSelector'; const handleResetprofile = async () => {
try {
await axios.delete(`${apiUrl}/api/delete-csv`, { withCredentials: true });
navigate("/");
} catch (error) {
console.error("Error deleting CSV file:", error);
}
};
const isPosterSelectorPage = location.pathname !== "/PosterSelector";
return ( return (
<AppBar position="fixed" className="navbar"> <AppBar position="fixed" className="navbar">
<Toolbar className="toolbar"> <Toolbar className="toolbar">
{isPosterSelectorPage ? ( {isPosterSelectorPage ? (
<IconButton <IconButton edge="start" className="back-button" onClick={handleBack}>
edge="start"
className="back-button"
onClick={handleBack}
>
<ArrowBackIcon /> <ArrowBackIcon />
</IconButton> </IconButton>
) : ( ) : (
<IconButton <IconButton edge="start" className="back-button" disabled>
edge="start"
className="back-button"
disabled
>
{/* Disabled IconButton */} {/* Disabled IconButton */}
</IconButton> </IconButton>
)} )}
<div> <div>
<Button <Button className="refresh-button" onClick={handleResetprofile}>
className="refresh-button" Reset Profile
onClick={onRefreshFiles}
>
Refresh Files
</Button> </Button>
<IconButton <IconButton edge="end" className="cart-button" onClick={handleCart}>
edge="end"
className="cart-button"
onClick={handleCart}
>
<Badge <Badge
badgeContent={totalSelected} badgeContent={totalSelected}
color="secondary" color="secondary"

View File

@@ -11,12 +11,15 @@ import {
CardMedia, CardMedia,
CircularProgress, CircularProgress,
Box, Box,
Paper, DialogContent, DialogTitle, DialogActions, Button, Paper,
DialogContent,
DialogTitle,
DialogActions,
Button,
} from "@material-ui/core"; } from "@material-ui/core";
import CheckIcon from "@material-ui/icons/Check"; import CheckIcon from "@material-ui/icons/Check";
import NavBar from "./NavBar"; import NavBar from "./NavBar";
import { Dialog } from "@mui/material"; import { Dialog } from "@mui/material";
import matchers from "@testing-library/jest-dom/matchers";
const apiUrl = process.env.REACT_APP_API_URL; const apiUrl = process.env.REACT_APP_API_URL;
@@ -66,18 +69,18 @@ const PosterGallery = () => {
} }
}, [movieName, movieYear]); }, [movieName, movieYear]);
const isPosterSelected = (posterId) => {
return selectedPosters.some((poster) => poster.posterId === posterId);
};
const handlePosterSelect = (posterId) => { const handlePosterSelect = (posterId) => {
const postersForCurrentMovie = selectedPosters.filter( const isSelected = isPosterSelected(posterId);
(poster) => poster.movieId !== movieId
);
const isPosterSelected = postersForCurrentMovie.some((poster) => poster.posterId === posterId) if (selectedPosters.length > 0 && !isSelected) {
if (postersForCurrentMovie.length > 0 && !isPosterSelected) {
setDialogOpen(true); setDialogOpen(true);
return; return;
} }
if (isPosterSelected) { if (isSelected) {
dispatch(deselectPoster(movieName, movieYear, posterId)); dispatch(deselectPoster(movieName, movieYear, posterId));
} else { } else {
dispatch(selectPoster(movieName, movieYear, posterId, watchedDate)); dispatch(selectPoster(movieName, movieYear, posterId, watchedDate));
@@ -86,7 +89,7 @@ const PosterGallery = () => {
const handleDialogClose = () => { const handleDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
} };
if (error) { if (error) {
return ( return (
@@ -114,13 +117,13 @@ const PosterGallery = () => {
</Box> </Box>
) : posters.length > 0 ? ( ) : posters.length > 0 ? (
<Grid container spacing={3}> <Grid container spacing={3}>
{posters.map((poster, index) => ( {posters.map((poster, index) => {
const isSelected = isPosterSelected(poster.file_path);
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}> <Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<Card <Card
className={`poster-card ${ className={`poster-card ${
selectedPosters.includes(poster.file_path) isSelected ? "selected-poster" : ""
? "selected-poster"
: ""
}`} }`}
onClick={() => handlePosterSelect(poster.file_path)} onClick={() => handlePosterSelect(poster.file_path)}
> >
@@ -129,12 +132,11 @@ const PosterGallery = () => {
image={`https://image.tmdb.org/t/p/original${poster.file_path}`} image={`https://image.tmdb.org/t/p/original${poster.file_path}`}
title={`${movieName} poster ${index + 1}`} title={`${movieName} poster ${index + 1}`}
/> />
{selectedPosters.includes(poster.file_path) && ( {isSelected && <CheckIcon className="check-icon" />}
<CheckIcon className="check-icon" />
)}
</Card> </Card>
</Grid> </Grid>
))} );
})}
</Grid> </Grid>
) : ( ) : (
<Typography align="center"> <Typography align="center">
@@ -148,7 +150,8 @@ const PosterGallery = () => {
<DialogTitle>Poster already selected</DialogTitle> <DialogTitle>Poster already selected</DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
You can only select one poster per movie. Please deselect the current poster first. You can only select one poster per movie. Please deselect the
current poster first.
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View File

@@ -67,7 +67,7 @@ const PosterSelector = () => {
const pageNumber = parsed.page ? parseInt(parsed.page, 10) : 1; const pageNumber = parsed.page ? parseInt(parsed.page, 10) : 1;
setPage(pageNumber); setPage(pageNumber);
fetchMovies(pageNumber).then((r) => console.log(r)); fetchMovies(pageNumber);
}, [location.search]); }, [location.search]);
// const fetchUsername = async () => { // const fetchUsername = async () => {
@@ -86,14 +86,7 @@ const PosterSelector = () => {
const response = await axios.get( const response = await axios.get(
`${apiUrl}/api/movies?page=${pageNumber}&limit=${moviesPerPage}`, `${apiUrl}/api/movies?page=${pageNumber}&limit=${moviesPerPage}`,
{ { withCredentials: true }
// onDownloadProgress: (progressEvent) => {
// const percentCompleted = Math.round(
// (progressEvent.loaded * 100) / progressEvent.total
// );
// setProgress(percentCompleted);
// },
}
); );
if (response.data.movies) { if (response.data.movies) {
@@ -109,20 +102,13 @@ const PosterSelector = () => {
const handleMovieClick = (movieName, movieYear, watchedDate) => { const handleMovieClick = (movieName, movieYear, watchedDate) => {
navigate( navigate(
`/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(movieYear)}`, `/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(
movieYear
)}`,
{ state: { watchedDate } } { state: { watchedDate } }
); );
}; };
const handleRefreshFiles = async () => {
try {
await axios.delete(`${apiUrl}/api/delete-csv`);
navigate("/");
} catch (error) {
console.error("Error deleting CSV file:", error);
}
};
const handlePageChange = (event, value) => { const handlePageChange = (event, value) => {
setPage(value); setPage(value);
navigate(`?page=${value}`); navigate(`?page=${value}`);
@@ -169,14 +155,20 @@ const PosterSelector = () => {
<ListItem <ListItem
button button
key={index} key={index}
onClick={() => handleMovieClick(movie.Name, movie.Year, movie["Watched Date"])} onClick={() =>
handleMovieClick(
movie.Name,
movie.Year,
movie["Watched Date"]
)
}
className="movie-item" className="movie-item"
> >
<Grid container alignItems="center"> <Grid container alignItems="center">
<Grid item> <Grid item>
<img <img
src={ src={
`https://image.tmdb.org/t/p/w500${movie.Poster.file_path}` || `https://image.tmdb.org/t/p/w500${movie.Poster}` ||
`/api/placeholder/50/75` `/api/placeholder/50/75`
} }
alt={movie.Name} alt={movie.Name}
@@ -256,7 +248,7 @@ const PosterSelector = () => {
)} )}
</Paper> </Paper>
</Container> </Container>
<NavBar onRefreshFiles={handleRefreshFiles} /> <NavBar />
</> </>
); );
}; };

View File

@@ -24,7 +24,9 @@ const UploadDiary = () => {
useEffect(() => { useEffect(() => {
const checkCSVFile = async () => { const checkCSVFile = async () => {
try { try {
const response = await axios.get(`${apiUrl}/api/check-csv`); const response = await axios.get(`${apiUrl}/api/check-csv`, {
withCredentials: true,
});
if (response.data.fileExists) { if (response.data.fileExists) {
navigate("/PosterSelector"); navigate("/PosterSelector");
} }
@@ -49,16 +51,26 @@ const UploadDiary = () => {
try { try {
if (username) { if (username) {
console.log(`Fetching diary for user: ${username}`); console.log(`Fetching diary for user: ${username}`);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Call backend to fetch diary from username
const response = await axios.post(
`${apiUrl}/api/fetch-diary`,
{ username },
{ withCredentials: true }
);
if (response.data && response.data.success) {
await new Promise((resolve) => setTimeout(resolve, 500));
navigate("/PosterSelector"); navigate("/PosterSelector");
} else {
console.error("Failed to fetch diary", response.data);
}
} else if (file) { } else if (file) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
await axios.post(`${apiUrl}/api/upload-csv`, formData, { await axios.post(`${apiUrl}/api/upload-csv`, formData, {
headers: { headers: { "Content-Type": "multipart/form-data" },
"Content-Type": "multipart/form-data", withCredentials: true,
},
}); });
navigate("/PosterSelector"); navigate("/PosterSelector");
@@ -84,6 +96,9 @@ const UploadDiary = () => {
fullWidth fullWidth
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
onKeyUp={(e) => {
if (e.key === "Enter") handleUpload();
}}
placeholder="Enter your Letterboxd username" placeholder="Enter your Letterboxd username"
/> />
<Typography variant="body2" className="or-text"> <Typography variant="body2" className="or-text">

View File

@@ -17,7 +17,7 @@ const posterSelectionReducer = (state = {}, action) => {
return { return {
...state, ...state,
[movieId]: state[movieId]?.filter( [movieId]: state[movieId]?.filter(
(id) => id !== posterId (poster) => poster.posterId !== posterId
), ),
}; };
} }
@@ -26,7 +26,7 @@ const posterSelectionReducer = (state = {}, action) => {
return { return {
...state, ...state,
[movieId]: state[movieId]?.filter( [movieId]: state[movieId]?.filter(
(id) => id !== posterId (poster) => poster.posterId !== posterId
), ),
}; };
} }

View File

@@ -3,18 +3,22 @@
padding: 2rem; padding: 2rem;
display: flex; display: flex;
gap: 2rem; gap: 2rem;
align-items: flex-start;
box-sizing: border-box;
width: 100%;
.poster-grid { .poster-grid {
flex: 1; flex: 1 1 auto;
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem; gap: 1rem;
max-height: calc(100vh - 4rem);
padding-right: 1rem; padding-right: 1rem;
overflow: visible;
.poster-card { .poster-card {
position: relative; position: relative;
aspect-ratio: 2/3; aspect-ratio: 2/3;
min-height: 220px;
&:hover .delete-overlay { &:hover .delete-overlay {
opacity: 1; opacity: 1;
@@ -23,6 +27,9 @@
.poster-image { .poster-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
border-radius: 8px;
display: block;
} }
.delete-overlay { .delete-overlay {
@@ -34,56 +41,86 @@
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
border-radius: 8px;
.delete-button { .delete-button {
background-color: #e74c3c; background-color: #e74c3c;
border: none; border: none;
color: white; color: white;
padding: 0.5rem; padding: 0.65rem;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease; transition: transform 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
&:hover { &:hover,
transform: scale(1.1); &:focus {
transform: scale(1.05);
outline: none;
} }
} }
} }
} }
.empty-poster { // État vide simple
width: 100%; .empty-cart-container {
height: 100%; grid-column: 1 / -1;
background-color: #f5f6fa10;
border: 2px #95a5a6;
border-radius: 8px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #7f8c8d; min-height: 60vh;
text-align: center;
svg { .MuiTypography-body1 {
margin-bottom: 1rem; color: #7f8c8d;
color: #95a5a6; margin-bottom: 2rem;
max-width: 400px;
} }
p { .back-button {
font-size: 0.875rem; color: #fff;
text-align: center; font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 2.4rem;
display: inline-block;
cursor: pointer;
padding: 0 1rem; padding: 0 1rem;
border: 0;
border-radius: 4px;
outline: none;
background: #526e89;
transition: background-color 0.3s ease;
min-width: 160px;
&:hover {
background-color: #1caff2;
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
} }
} }
} }
.action-panel { .action-panel {
width: 300px; flex: 0 0 320px;
max-width: 320px;
position: sticky; position: sticky;
top: 2rem; top: 2rem;
background-color: #34495e; background-color: #34495e;
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
height: fit-content; height: fit-content;
box-sizing: border-box;
align-self: flex-start;
h2 { h2 {
color: white; color: white;
@@ -94,32 +131,181 @@
.format-select { .format-select {
width: 100%; width: 100%;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 0.5rem; padding: 0.6rem;
background-color: #2c3e50; background-color: #2c3e50;
border: 1px solid #95a5a6; border: 1px solid #95a5a6;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
appearance: none;
} }
.download-button, .clear-button { .download-button,
.clear-button {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.85rem;
background-color: #00a346; background-color: #00a346;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 6px;
margin-bottom: 1rem; margin-bottom: 1rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease, transform 0.08s ease;
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
&:hover { &:active {
background-color: darken(#00a346, 10%); transform: translateY(1px);
}
&:hover:not(:disabled) {
filter: brightness(0.95);
} }
&:disabled { &:disabled {
background-color: darken(#00a346, 20%); background-color: #7f8c8d;
cursor: not-allowed; cursor: not-allowed;
} }
} }
.clear-button {
background-color: #e74c3c;
&:hover:not(:disabled) {
filter: brightness(0.95);
} }
} }
}
}
/* Tablette */
@media (max-width: 992px) {
.cart-container {
padding: 1.5rem;
gap: 1rem;
.poster-grid {
grid-template-columns: repeat(3, 1fr);
max-height: calc(100vh - 3rem);
.poster-card {
min-height: 200px;
}
}
.action-panel {
width: 260px;
top: 1.5rem;
padding: 1.25rem;
}
}
}
/* Small devices */
@media (max-width: 768px) {
.cart-container {
flex-direction: column;
padding: 1rem;
.poster-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding-right: 0;
max-height: none;
.poster-card {
aspect-ratio: 2/3;
min-height: 180px;
}
.empty-cart-container {
min-height: 50vh;
}
}
.action-panel {
position: relative;
top: 0;
width: 100%;
margin-top: 1rem;
border-radius: 8px;
padding: 1rem;
}
}
}
/* Mobile */
@media (max-width: 480px) {
.cart-container {
padding: 0.75rem;
gap: 0.75rem;
.poster-grid {
grid-template-columns: 1fr;
gap: 0.6rem;
.poster-card {
aspect-ratio: 2/3;
min-height: 260px;
border-radius: 6px;
.poster-image {
border-radius: 6px;
}
.delete-overlay .delete-button {
padding: 0.6rem;
font-size: 1rem;
}
}
.empty-cart-container {
padding: 1rem 0;
min-height: 40vh;
.MuiTypography-body1 {
max-width: 320px;
font-size: 0.95rem;
}
.back-button {
min-width: 140px;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
}
}
}
.action-panel {
width: 100%;
padding: 0.9rem;
border-radius: 6px;
h2 {
font-size: 1.05rem;
margin-bottom: 1rem;
}
.format-select {
padding: 0.5rem;
}
.download-button,
.clear-button {
padding: 0.85rem;
font-size: 0.95rem;
}
}
}
}
/* Focus states (accessibility) */
.poster-card .delete-button:focus,
.action-panel .download-button:focus,
.action-panel .clear-button:focus,
.action-panel .format-select:focus,
.empty-cart-container .back-button:focus {
outline: 3px solid rgba(255, 255, 255, 0.12);
outline-offset: 2px;
}