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,33 +1,59 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {removeAllPosters, removePoster} from "../services/action";
import { useNavigate } from "react-router-dom";
import { removeAllPosters, removePoster } from "../services/action";
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 Cart = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const selectedPosters = useSelector((state) => {
const selections = state.posterSelections;
return Object.entries(selections).flatMap(([movieId, posters]) =>
posters.map((poster) => ({
movieId,
posterId: typeof poster === "string" ? poster : poster.posterId,
watchedDate: poster.watchedDate,
}))
posters.map((poster) => ({
movieId,
posterId: typeof poster === "string" ? poster : poster.posterId,
watchedDate: poster.watchedDate,
}))
);
});
const [downloadFormat, setDownloadFormat] = useState("zip");
// Nouvel état pour la confirmation de suppression globale
const [openConfirmClear, setOpenConfirmClear] = useState(false);
const handleRemovePoster = (movieId, posterId) => {
if (movieId && posterId) dispatch(removePoster(movieId, posterId));
};
const handleRemoveAllPosters = () => {
dispatch(removeAllPosters());
// Ouvre la dialog de confirmation (appelé par le bouton "Clear All Posters")
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 () => {
try {
@@ -64,61 +90,104 @@ const Cart = () => {
};
return (
<div className="cart-container">
<div className="poster-grid">
{selectedPosters.length === 0 ? (
<p>No posters selected</p>
) : (
selectedPosters.map((poster) => (
<div
key={`${poster.movieId}-${poster.posterId}`}
className="poster-card"
>
<img
src={`https://image.tmdb.org/t/p/original${poster.posterId}`}
alt="Movie poster"
className="poster-image"
/>
<div className="delete-overlay">
<button
className="delete-button"
onClick={() => handleRemovePoster(poster.movieId, poster.posterId)}
>
<DeleteIcon/>
</button>
</div>
</div>
))
)}
</div>
<div className="action-panel">
<h2>Download Options</h2>
<select
value={downloadFormat}
onChange={(e) => setDownloadFormat(e.target.value)}
className="format-select"
>
<option value="zip">ZIP</option>
<option value="tar">TAR</option>
<option value="7z">7Z</option>
</select>
<button
className="download-button"
onClick={handleDownload}
disabled={selectedPosters.length === 0}
>
Download Selected
</button>
<button
className="clear-button"
onClick={handleRemoveAllPosters}
disabled={selectedPosters.length === 0}
>
Clear All Posters
</button>
</div>
<div className="cart-container">
<div className="poster-grid">
{selectedPosters.length === 0 ? (
<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) => (
<div
key={`${poster.movieId}-${poster.posterId}`}
className="poster-card"
>
<img
src={`https://image.tmdb.org/t/p/original${poster.posterId}`}
alt="Movie poster"
className="poster-image"
/>
<div className="delete-overlay">
<button
className="delete-button"
onClick={() =>
handleRemovePoster(poster.movieId, poster.posterId)
}
aria-label="Supprimer ce poster"
>
<DeleteIcon />
</button>
</div>
</div>
))
)}
</div>
<div className="action-panel">
<h2>Download Options</h2>
<select
value={downloadFormat}
onChange={(e) => setDownloadFormat(e.target.value)}
className="format-select"
>
<option value="zip">ZIP</option>
<option value="tar">TAR</option>
<option value="7z">7Z</option>
</select>
<button
className="download-button"
onClick={handleDownload}
disabled={selectedPosters.length === 0}
>
Download Selected
</button>
<button
className="clear-button"
onClick={handleOpenConfirmClear}
disabled={selectedPosters.length === 0}
aria-haspopup="dialog"
>
Clear All Posters
</button>
</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>
);
};

View File

@@ -1,77 +1,74 @@
import React, { useMemo } from "react";
import axios from "axios";
import { useSelector } from "react-redux";
import { AppBar, Toolbar, IconButton, Badge, Button } from '@material-ui/core';
import ShoppingCartIcon from '@material-ui/icons/ShoppingCart';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import { useNavigate, useLocation } from 'react-router-dom';
import { AppBar, Toolbar, IconButton, Badge, Button } from "@material-ui/core";
import ShoppingCartIcon from "@material-ui/icons/ShoppingCart";
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
import { useNavigate, useLocation } from "react-router-dom";
const apiUrl = process.env.REACT_APP_API_URL;
const selectPosterSelections = (state) => state.posterSelections;
const NavBar = ({ onRefreshFiles }) => {
const navigate = useNavigate();
const location = useLocation();
const NavBar = () => {
const navigate = useNavigate();
const location = useLocation();
const posterSelections = useSelector(selectPosterSelections);
const selectedPosters = useMemo(() => {
return Object.values(posterSelections).flat();
}, [posterSelections]);
const posterSelections = useSelector(selectPosterSelections);
const selectedPosters = useMemo(() => {
return Object.values(posterSelections).flat();
}, [posterSelections]);
const totalSelected = selectedPosters.length;
const totalSelected = selectedPosters.length;
const handleBack = () => {
navigate(-1);
};
const handleBack = () => {
navigate(-1);
};
const handleCart = () => {
navigate('/Cart');
};
const handleCart = () => {
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);
}
};
return (
<AppBar position="fixed" className="navbar">
<Toolbar className="toolbar">
{isPosterSelectorPage ? (
<IconButton
edge="start"
className="back-button"
onClick={handleBack}
>
<ArrowBackIcon />
</IconButton>
) : (
<IconButton
edge="start"
className="back-button"
disabled
>
{/* Disabled IconButton */}
</IconButton>
)}
<div>
<Button
className="refresh-button"
onClick={onRefreshFiles}
>
Refresh Files
</Button>
<IconButton
edge="end"
className="cart-button"
onClick={handleCart}
>
<Badge
badgeContent={totalSelected}
color="secondary"
overlap="rectangular"
>
<ShoppingCartIcon />
</Badge>
</IconButton>
</div>
</Toolbar>
</AppBar>
);
const isPosterSelectorPage = location.pathname !== "/PosterSelector";
return (
<AppBar position="fixed" className="navbar">
<Toolbar className="toolbar">
{isPosterSelectorPage ? (
<IconButton edge="start" className="back-button" onClick={handleBack}>
<ArrowBackIcon />
</IconButton>
) : (
<IconButton edge="start" className="back-button" disabled>
{/* Disabled IconButton */}
</IconButton>
)}
<div>
<Button className="refresh-button" onClick={handleResetprofile}>
Reset Profile
</Button>
<IconButton edge="end" className="cart-button" onClick={handleCart}>
<Badge
badgeContent={totalSelected}
color="secondary"
overlap="rectangular"
>
<ShoppingCartIcon />
</Badge>
</IconButton>
</div>
</Toolbar>
</AppBar>
);
};
export default NavBar;
export default NavBar;

View File

@@ -11,12 +11,15 @@ import {
CardMedia,
CircularProgress,
Box,
Paper, DialogContent, DialogTitle, DialogActions, Button,
Paper,
DialogContent,
DialogTitle,
DialogActions,
Button,
} from "@material-ui/core";
import CheckIcon from "@material-ui/icons/Check";
import NavBar from "./NavBar";
import {Dialog} from "@mui/material";
import matchers from "@testing-library/jest-dom/matchers";
import { Dialog } from "@mui/material";
const apiUrl = process.env.REACT_APP_API_URL;
@@ -46,7 +49,7 @@ const PosterGallery = () => {
const encodedYear = encodeURIComponent(movieYear);
const response = await axios.get(
`${apiUrl}/api/posters/${encodedName}/${encodedYear}`
`${apiUrl}/api/posters/${encodedName}/${encodedYear}`
);
if (response.data && response.data.posters) {
@@ -66,18 +69,18 @@ const PosterGallery = () => {
}
}, [movieName, movieYear]);
const isPosterSelected = (posterId) => {
return selectedPosters.some((poster) => poster.posterId === posterId);
};
const handlePosterSelect = (posterId) => {
const postersForCurrentMovie = selectedPosters.filter(
(poster) => poster.movieId !== movieId
);
const isSelected = isPosterSelected(posterId);
const isPosterSelected = postersForCurrentMovie.some((poster) => poster.posterId === posterId)
if (postersForCurrentMovie.length > 0 && !isPosterSelected) {
if (selectedPosters.length > 0 && !isSelected) {
setDialogOpen(true);
return;
}
if (isPosterSelected) {
if (isSelected) {
dispatch(deselectPoster(movieName, movieYear, posterId));
} else {
dispatch(selectPoster(movieName, movieYear, posterId, watchedDate));
@@ -86,7 +89,7 @@ const PosterGallery = () => {
const handleDialogClose = () => {
setDialogOpen(false);
}
};
if (error) {
return (
@@ -114,31 +117,30 @@ const PosterGallery = () => {
</Box>
) : posters.length > 0 ? (
<Grid container spacing={3}>
{posters.map((poster, index) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<Card
className={`poster-card ${
selectedPosters.includes(poster.file_path)
? "selected-poster"
: ""
}`}
onClick={() => handlePosterSelect(poster.file_path)}
>
<CardMedia
className="poster-image"
image={`https://image.tmdb.org/t/p/original${poster.file_path}`}
title={`${movieName} poster ${index + 1}`}
/>
{selectedPosters.includes(poster.file_path) && (
<CheckIcon className="check-icon" />
)}
</Card>
</Grid>
))}
{posters.map((poster, index) => {
const isSelected = isPosterSelected(poster.file_path);
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<Card
className={`poster-card ${
isSelected ? "selected-poster" : ""
}`}
onClick={() => handlePosterSelect(poster.file_path)}
>
<CardMedia
className="poster-image"
image={`https://image.tmdb.org/t/p/original${poster.file_path}`}
title={`${movieName} poster ${index + 1}`}
/>
{isSelected && <CheckIcon className="check-icon" />}
</Card>
</Grid>
);
})}
</Grid>
) : (
<Typography align="center">
No posters found for this movie.
No posters found for this movie.
</Typography>
)}
</Paper>
@@ -148,7 +150,8 @@ const PosterGallery = () => {
<DialogTitle>Poster already selected</DialogTitle>
<DialogContent>
<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>
</DialogContent>
<DialogActions>

View File

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

View File

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

View File

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

View File

@@ -3,18 +3,22 @@
padding: 2rem;
display: flex;
gap: 2rem;
align-items: flex-start;
box-sizing: border-box;
width: 100%;
.poster-grid {
flex: 1;
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
max-height: calc(100vh - 4rem);
padding-right: 1rem;
overflow: visible;
.poster-card {
position: relative;
aspect-ratio: 2/3;
min-height: 220px;
&:hover .delete-overlay {
opacity: 1;
@@ -23,6 +27,9 @@
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
display: block;
}
.delete-overlay {
@@ -34,56 +41,86 @@
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 8px;
.delete-button {
background-color: #e74c3c;
border: none;
color: white;
padding: 0.5rem;
padding: 0.65rem;
border-radius: 50%;
cursor: pointer;
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 {
transform: scale(1.1);
&:hover,
&:focus {
transform: scale(1.05);
outline: none;
}
}
}
}
.empty-poster {
width: 100%;
height: 100%;
background-color: #f5f6fa10;
border: 2px #95a5a6;
border-radius: 8px;
// État vide simple
.empty-cart-container {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #7f8c8d;
min-height: 60vh;
text-align: center;
svg {
margin-bottom: 1rem;
color: #95a5a6;
.MuiTypography-body1 {
color: #7f8c8d;
margin-bottom: 2rem;
max-width: 400px;
}
p {
font-size: 0.875rem;
text-align: center;
.back-button {
color: #fff;
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;
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 {
width: 300px;
flex: 0 0 320px;
max-width: 320px;
position: sticky;
top: 2rem;
background-color: #34495e;
border-radius: 8px;
padding: 1.5rem;
height: fit-content;
box-sizing: border-box;
align-self: flex-start;
h2 {
color: white;
@@ -94,32 +131,181 @@
.format-select {
width: 100%;
margin-bottom: 1.5rem;
padding: 0.5rem;
padding: 0.6rem;
background-color: #2c3e50;
border: 1px solid #95a5a6;
color: white;
border-radius: 4px;
appearance: none;
}
.download-button, .clear-button {
.download-button,
.clear-button {
width: 100%;
padding: 0.75rem;
padding: 0.85rem;
background-color: #00a346;
color: white;
border: none;
border-radius: 4px;
border-radius: 6px;
margin-bottom: 1rem;
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 {
background-color: darken(#00a346, 10%);
&:active {
transform: translateY(1px);
}
&:hover:not(:disabled) {
filter: brightness(0.95);
}
&:disabled {
background-color: darken(#00a346, 20%);
background-color: #7f8c8d;
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;
}