Refactor: responsive upload page, fix warnings, CI/CD setup

- Fix MUI v4/React 18 StrictMode warnings (justify→justifyContent, Button migration)
- Responsive UploadDiary: footer visible without scroll, mobile layout centered, overview text hidden on mobile
- Fix Redux selector memoization with createSelector
- RSS sync: auto-refresh PosterSelector after sync, Snackbar feedback with film count
- Add .gitea/workflows/deploy.yml for tag-based CI/CD
- Fix nginx config (default.conf), increase Node heap for Docker build
- Update README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 17:24:38 +02:00
parent 569e904ddd
commit 0dbcdcfcf4
31 changed files with 2896 additions and 1508 deletions

View File

@@ -0,0 +1,34 @@
name: Deploy Frontend
on:
push:
tags:
- 'v*.*.*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version tag
run: echo "VERSION_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build \
--build-arg REACT_APP_API_URL="" \
-t poster-picker-frontend:latest \
-t poster-picker-frontend:${{ env.VERSION_TAG }} .
- name: Stop old container
run: |
cd ~/dev-server/poster-picker
docker compose stop frontend || true
- name: Start new container
run: |
cd ~/dev-server/poster-picker
docker compose up -d --no-build frontend

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*storybook.log
storybook-static

View File

@@ -5,7 +5,7 @@ WORKDIR /app
COPY package*.json ./
ENV NODE_OPTIONS="--max-old-space-size=512"
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm ci --silent --legacy-peer-deps
COPY . .
@@ -22,7 +22,7 @@ FROM nginx:stable-alpine AS runner
# Copy build output
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/nginx-spa.conf
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
# Expose port 80 (Caddy fera reverse proxy)
EXPOSE 80

View File

@@ -1,15 +1,18 @@
# Movie Poster Gallery
# Letterboxd Diary Poster Picker
![Project Logo](/assets/project.jpg)
![Project Screenshot](/assets/project.png)
A web application that allows users to browse and select their favorite movie posters based on their Letterboxd diary.
A web application to browse and select your favorite movie posters from your Letterboxd diary.
## Features
- **Letterboxd Integration**: Import your movie diary directly from uploading a CSV file.
- **Poster Gallery**: Browse through posters of movies you've watched.
- **User-friendly Interface**: Intuitive design for easy navigation and selection.
- **Responsive Design**: Works seamlessly on desktop and mobile devices.
- **CSV Import**: Upload your Letterboxd diary export to load your full watch history.
- **RSS Sync**: Sync your ~50 most recent diary entries directly via your Letterboxd username — no export needed.
- **Poster Gallery**: Browse all available posters for each movie via TMDB.
- **Poster Selection**: Pick your favorite poster for each film.
- **Selection Recap**: Review all your selections in one place.
- **ZIP Download**: Download all selected posters as a ZIP file.
- **Responsive Design**: Works on desktop and mobile.
## Getting Started
@@ -17,6 +20,7 @@ A web application that allows users to browse and select their favorite movie po
- Node.js (v14.0.0 or later)
- npm (v6.0.0 or later)
- A running instance of the [backend](https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker-backend)
### Installation
@@ -48,18 +52,19 @@ A web application that allows users to browse and select their favorite movie po
## Usage
1. On the homepage, upload your diary CSV file.
2. Once processed, you'll be redirected to the Poster Selector page.
3. Browse through your watched movies and select your favorite posters.
4. (Add any additional steps or features here)
1. **Import your diary** — either upload a `diary.csv` exported from [letterboxd.com/data/export](https://letterboxd.com/data/export/), or enter your Letterboxd username to sync via RSS.
2. **Browse posters** — for each film in your diary, scroll through the available posters fetched from TMDB.
3. **Select a poster** — click to pick your favorite for each movie.
4. **Download** — use the download button in the header to export all selected posters as a ZIP.
## Technologies Used
## Technologies
- React.js
- Material-UI
- Backend private for now
- React / Redux
- Material UI
- Flask (backend)
- TMDB API
## Acknowledgments
- [Letterboxd](https://letterboxd.com/) for the inspiration and data (diary.csv)
- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the movie poster images
- [Letterboxd](https://letterboxd.com/) for the diary data
- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the poster images

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/project.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -6,7 +6,7 @@ server {
# Proxy API to backend container
location /api/ {
proxy_pass http://poster-picker-backend-1:5000/api/;
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.7.6",
"jszip": "^3.10.1",
"lucide-react": "^0.453.0",
"material-ui-dropzone": "^3.5.0",
"query-string": "^9.1.0",

View File

@@ -1,33 +1,36 @@
import React from "react";
import "./styles/App.scss";
import { HashRouter as Router, Route, Routes } from "react-router-dom";
import { HashRouter as Router, Route, Routes, useLocation } from "react-router-dom";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./services/store";
import PosterSelector from "./components/PosterSelector";
import PosterGallery from "./components/PosterGallery";
import UploadDiary from "./components/UploadDiary";
import Cart from "./components/Cart";
import Header from "./components/Header";
import PosterSelector from "./pages/PosterSelector";
import PosterGallery from "./pages/PosterGallery";
import UploadDiary from "./pages/UploadDiary";
import SelectionRecap from "./pages/SelectionRecap";
function AppContent() {
const location = useLocation();
return (
<div className="App">
{location.pathname !== "/" && <Header />}
<Routes>
<Route path="/" element={<UploadDiary />} />
<Route path="/PosterSelector" element={<PosterSelector />} />
<Route path="/posters/:movieName/:movieYear" element={<PosterGallery />} />
<Route path="/recap" element={<SelectionRecap />} />
</Routes>
</div>
);
}
function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{
<Router>
<div className="App">
<Routes>
<Route path="/" element={<UploadDiary />} />
<Route path="/PosterSelector" element={<PosterSelector />} />
<Route
path="/posters/:movieName/:movieYear"
element={<PosterGallery />}
/>
<Route path="/Cart" element={<Cart />} />
</Routes>
</div>
</Router>
}
<Router>
<AppContent />
</Router>
</PersistGate>
</Provider>
);

View File

@@ -1,204 +0,0 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { removeAllPosters, removePoster } from "../services/action";
import DeleteIcon from "@material-ui/icons/Delete";
import {
Container,
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,
}))
);
});
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));
};
// 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 {
const response = await fetch(`${apiUrl}/api/download-posters`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
posters: selectedPosters,
format: downloadFormat,
}),
});
if (!response.ok) throw new Error("Download failed");
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/zip")) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "posters.zip";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} else {
throw new Error("Invalid response format");
}
} catch (error) {
console.error("Failed to download posters:", error);
}
};
return (
<>
<Container>
<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 className="poster-card add-new" onClick={() => navigate("/")}>
<span className="add-symbol">+</span>
</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>
</Container>
</>
);
};
export default Cart;

View File

@@ -1,45 +0,0 @@
import React from "react";
import { useSelector } from "react-redux";
import { Fab, makeStyles } from "@material-ui/core";
import GetAppIcon from "@material-ui/icons/GetApp";
const useStyles = makeStyles((theme) => ({
downloadFab: {
backgroundColor: "#1caff2",
position: "fixed",
bottom: theme.spacing(4),
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
},
}));
const GlobalDownloadButton = () => {
const classes = useStyles();
const selectedPosters = useSelector((state) => {
const selections = state.posterSelections;
return Object.values(selections).flat();
});
const totalSelected = selectedPosters.length;
if (totalSelected === 0) return null;
const handleDownload = () => {
// Implement your download logic here
console.log("Downloading", totalSelected, "posters");
};
return (
<Fab
variant="extended"
className={classes.downloadFab}
onClick={handleDownload}
>
<GetAppIcon />
Download ({totalSelected})
</Fab>
);
};
export default GlobalDownloadButton;

252
src/components/Header.js Normal file
View File

@@ -0,0 +1,252 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useNavigate, useMatch } from "react-router-dom";
import { createSelector } from "reselect";
import { AppBar, Toolbar, Typography, Button, IconButton, LinearProgress, Box, Tooltip, Link, Menu, MenuItem } from "@material-ui/core";
import { Snackbar, Alert } from "@mui/material";
import GetAppIcon from "@material-ui/icons/GetApp";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";
import ViewModuleIcon from "@material-ui/icons/ViewModule";
import SyncIcon from "@material-ui/icons/Sync";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import JSZip from "jszip";
import api from "../services/api";
import { removeAllPosters } from "../services/action";
const selectSelectedPosters = createSelector(
(state) => state.posterSelections,
(posterSelections) =>
Object.entries(posterSelections).flatMap(([movieId, posters]) =>
(posters || []).map((p) => ({ movieId, posterId: p.posterId, watchedDate: p.watchedDate }))
)
);
const Header = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const galleryMatch = useMatch("/posters/:movieName/:movieYear");
const movieName = galleryMatch ? decodeURIComponent(galleryMatch.params.movieName) : null;
const [downloadState, setDownloadState] = useState(null); // null | { current, total }
const [syncState, setSyncState] = useState(null); // null | 'syncing' | { added: number }
const [menuAnchor, setMenuAnchor] = useState(null);
const [snackbar, setSnackbar] = useState(null); // null | { message, severity }
const selectedPosters = useSelector(selectSelectedPosters);
const username = useSelector((state) => state.username);
const count = selectedPosters.length;
const handleDownload = async () => {
if (downloadState) return;
const total = selectedPosters.length;
setDownloadState({ current: 0, total });
try {
const zip = new JSZip();
for (let i = 0; i < selectedPosters.length; i++) {
const { movieId, posterId, watchedDate } = selectedPosters[i];
const url = `https://image.tmdb.org/t/p/original${posterId}`;
const res = await fetch(url);
const blob = await res.blob();
zip.file(`${watchedDate}_${movieId}.jpg`, blob);
setDownloadState({ current: i + 1, total });
}
const content = await zip.generateAsync({ type: "blob" });
const objectUrl = URL.createObjectURL(content);
const link = document.createElement("a");
link.href = objectUrl;
link.download = "posters.zip";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
} catch (err) {
console.error("Download error:", err);
} finally {
setDownloadState(null);
}
};
const handleRssSync = async () => {
if (!username || syncState === 'syncing') return;
setSyncState('syncing');
try {
const res = await api.post("/api/sync-rss", { username });
setSyncState({ added: res.data.added });
setTimeout(() => setSyncState(null), 3000);
if (res.data.added > 0) {
setSnackbar({ message: `${res.data.added} new film${res.data.added > 1 ? 's' : ''} added to your diary`, severity: 'success' });
window.dispatchEvent(new Event('diary-synced'));
} else {
setSnackbar({ message: 'Already up to date', severity: 'info' });
}
} catch {
setSyncState(null);
setSnackbar({ message: 'Sync failed. Please try again.', severity: 'error' });
}
};
const handleReset = async () => {
try {
await api.delete("/api/delete-csv");
dispatch(removeAllPosters());
navigate("/");
} catch (err) {
console.error("Reset error:", err);
}
};
const isDownloading = !!downloadState;
const progress = isDownloading
? Math.round((downloadState.current / downloadState.total) * 100)
: 0;
return (
<>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={() => setSnackbar(null)} severity={snackbar?.severity} sx={{ width: '100%' }}>
{snackbar?.message}
</Alert>
</Snackbar>
<AppBar position="fixed" className="app-header">
<Toolbar className="header-toolbar">
<div className="header-left" onClick={() => navigate("/PosterSelector")}>
<img src="/icon.svg" alt="logo" className="header-logo" />
<Typography variant="h6" className="header-title">
Poster Picker
</Typography>
</div>
<div className="header-center">
{movieName ? (
<Typography variant="h6" className="header-movie-name">
{movieName}
</Typography>
) : username ? (
<Link
href={`https://letterboxd.com/${username}`}
target="_blank"
rel="noopener noreferrer"
className="header-username"
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)}
>
{username}
</Link>
) : null}
</div>
{/* Desktop actions */}
<div className="header-actions">
{username && (
<Tooltip title={
syncState === 'syncing' ? 'Syncing…'
: syncState?.added === 0 ? 'Already up to date'
: syncState?.added > 0 ? `+${syncState.added} new film${syncState.added > 1 ? 's' : ''}`
: `Sync from ${username}'s RSS`
}>
<span>
<IconButton
className={`header-sync-btn${syncState ? ' header-sync-btn--active' : ''}`}
onClick={handleRssSync}
disabled={isDownloading || syncState === 'syncing'}
>
<SyncIcon className={syncState === 'syncing' ? 'spin' : ''} />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Reset diary">
<span>
<IconButton className="header-reset-btn" onClick={handleReset} disabled={isDownloading}>
<ExitToAppIcon />
</IconButton>
</span>
</Tooltip>
{count > 0 && (
<IconButton className="header-recap-btn" onClick={() => navigate("/recap")} disabled={isDownloading}>
<ViewModuleIcon />
</IconButton>
)}
{count > 0 && (
<Button
className={`header-download-btn${isDownloading ? " header-download-btn--loading" : ""}`}
startIcon={!isDownloading && <GetAppIcon />}
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<Box className="header-download-progress">
<span>{downloadState.current} / {downloadState.total}</span>
<LinearProgress variant="determinate" value={progress} className="header-progress-bar" />
</Box>
) : (
`Download (${count})`
)}
</Button>
)}
</div>
{/* Mobile actions — collapsed into a menu */}
<IconButton className="header-menu-btn" onClick={(e) => setMenuAnchor(e.currentTarget)}>
<MoreVertIcon />
</IconButton>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
PaperProps={{ className: "header-menu-paper" }}
getContentAnchorEl={null}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{username && (
<MenuItem
onClick={() => { handleRssSync(); setMenuAnchor(null); }}
disabled={isDownloading || syncState === 'syncing'}
className="header-menu-item"
>
<SyncIcon fontSize="small" className={`header-menu-icon${syncState === 'syncing' ? ' spin' : ''}`} />
{syncState === 'syncing' ? 'Syncing…'
: syncState?.added === 0 ? 'Up to date'
: syncState?.added > 0 ? `+${syncState.added} new film${syncState.added > 1 ? 's' : ''}`
: 'Sync RSS'}
</MenuItem>
)}
{count > 0 && (
<MenuItem onClick={() => { navigate('/recap'); setMenuAnchor(null); }} className="header-menu-item">
<ViewModuleIcon fontSize="small" className="header-menu-icon" />
My selections ({count})
</MenuItem>
)}
{count > 0 && (
<MenuItem
onClick={() => { handleDownload(); setMenuAnchor(null); }}
disabled={isDownloading}
className="header-menu-item"
>
<GetAppIcon fontSize="small" className="header-menu-icon" />
{isDownloading
? `Downloading… ${downloadState.current}/${downloadState.total}`
: `Download (${count})`}
</MenuItem>
)}
<MenuItem onClick={() => { handleReset(); setMenuAnchor(null); }} className="header-menu-item header-menu-item--danger">
<ExitToAppIcon fontSize="small" className="header-menu-icon" />
Reset diary
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
</>
);
};
export default Header;

View File

@@ -1,74 +0,0 @@
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";
const apiUrl = process.env.REACT_APP_API_URL;
const selectPosterSelections = (state) => state.posterSelections;
const NavBar = () => {
const navigate = useNavigate();
const location = useLocation();
const posterSelections = useSelector(selectPosterSelections);
const selectedPosters = useMemo(() => {
return Object.values(posterSelections).flat();
}, [posterSelections]);
const totalSelected = selectedPosters.length;
const handleBack = () => {
navigate(-1);
};
const handleCart = () => {
navigate("/Cart");
};
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 (
<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;

View File

@@ -1,167 +0,0 @@
import React, { useState, useEffect } from "react";
import { useLocation, useParams } from "react-router-dom";
import axios from "axios";
import { useSelector, useDispatch } from "react-redux";
import { selectPoster, deselectPoster } from "../services/action";
import {
Container,
Typography,
Grid,
Card,
CardMedia,
CircularProgress,
Box,
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";
const apiUrl = process.env.REACT_APP_API_URL;
const PosterGallery = () => {
const location = useLocation();
const { watchedDate } = location.state || {};
const { movieName, movieYear } = useParams();
const [posters, setPosters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
const dispatch = useDispatch();
const movieId = `${movieName}-${movieYear}`;
const selectedPosters = useSelector(
(state) => state.posterSelections[movieId] || []
);
useEffect(() => {
const fetchPosters = async () => {
try {
setLoading(true);
setError(null);
const encodedName = encodeURIComponent(movieName);
const encodedYear = encodeURIComponent(movieYear);
const response = await axios.get(
`${apiUrl}/api/posters/${encodedName}/${encodedYear}`
);
if (response.data && response.data.posters) {
setPosters(response.data.posters);
} else {
throw new Error("No posters data in response");
}
} catch (error) {
console.error("Error fetching posters:", error);
} finally {
setLoading(false);
}
};
if (movieName && movieYear) {
fetchPosters();
}
}, [movieName, movieYear]);
const isPosterSelected = (posterId) => {
return selectedPosters.some((poster) => poster.posterId === posterId);
};
const handlePosterSelect = (posterId) => {
const isSelected = isPosterSelected(posterId);
if (selectedPosters.length > 0 && !isSelected) {
setDialogOpen(true);
return;
}
if (isSelected) {
dispatch(deselectPoster(movieName, movieYear, posterId));
} else {
dispatch(selectPoster(movieName, movieYear, posterId, watchedDate));
}
};
const handleDialogClose = () => {
setDialogOpen(false);
};
if (error) {
return (
<Container>
<Paper elevation={3} className="content-paper">
<Typography color="error" align="center">
{error}
</Typography>
</Paper>
<NavBar />
</Container>
);
}
return (
<>
<Container className="poster-gallery">
<Paper elevation={3} className="content-paper">
<Typography variant="h4" gutterBottom className="title">
Posters for {movieName} ({movieYear})
</Typography>
{loading ? (
<Box className="loading-container">
<CircularProgress />
</Box>
) : posters.length > 0 ? (
<Grid container spacing={3}>
{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.
</Typography>
)}
</Paper>
</Container>
<NavBar />
<Dialog open={dialogOpen} onClose={handleDialogClose}>
<DialogTitle>Poster already selected</DialogTitle>
<DialogContent>
<Typography>
You can only select one poster per movie. Please deselect the
current poster first.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose} color="primary">
OK
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default PosterGallery;

View File

@@ -1,256 +0,0 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { useNavigate, useLocation } from "react-router-dom";
import {
Container,
List,
ListItem,
Grid,
Typography,
LinearProgress,
Box,
Paper,
Button,
} from "@material-ui/core";
import Pagination from "@mui/material/Pagination";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import queryString from "query-string";
import pulpGif from "../static/images/pulp.gif";
import NavBar from "./NavBar";
const apiUrl = process.env.REACT_APP_API_URL;
const paginationTheme = createTheme({
palette: {
mode: "dark",
primary: {
main: "#667788",
},
text: {
primary: "#ffffff",
secondary: "#667788",
},
action: {
hover: "rgba(102, 119, 136, 0.2)",
},
},
components: {
MuiPaginationItem: {
styleOverrides: {
root: {
"&.Mui-selected": {
backgroundColor: "#667788",
color: "#ffffff",
"&:hover": {
backgroundColor: "#778899",
},
},
},
},
},
},
});
const PosterSelector = () => {
const [movies, setMovies] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
//const [progress, setProgress] = useState(0);
const [username, setUsername] = useState("");
const navigate = useNavigate();
const location = useLocation();
const moviesPerPage = 8;
useEffect(() => {
const parsed = queryString.parse(location.search);
const pageNumber = parsed.page ? parseInt(parsed.page, 10) : 1;
setPage(pageNumber);
fetchMovies(pageNumber);
}, [location.search]);
// const fetchUsername = async () => {
// try {
// const response = await axios.get(`${apiUrl}/api/username`);
// setUsername(response.data.username);
// } catch (error) {
// console.error("Error fetching username:", error);
// }
// };
const fetchMovies = async (pageNumber) => {
try {
setLoading(true);
// setProgress(0);
const response = await axios.get(
`${apiUrl}/api/movies?page=${pageNumber}&limit=${moviesPerPage}`,
{ withCredentials: true }
);
if (response.data.movies) {
setMovies(response.data.movies);
setTotalPages(Math.ceil(response.data.total / moviesPerPage));
}
} catch (error) {
console.error("Error fetching movies:", error);
} finally {
setLoading(false);
}
};
const handleMovieClick = (movieName, movieYear, watchedDate) => {
navigate(
`/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(
movieYear
)}`,
{ state: { watchedDate } }
);
};
const handlePageChange = (event, value) => {
setPage(value);
navigate(`?page=${value}`);
};
const handleGoBack = () => {
navigate("/");
};
const formatWatchedDate = (dateString) => {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, "0");
const month = date
.toLocaleString("default", { month: "short" })
.toUpperCase();
return { day, month };
};
return (
<>
<Container className="poster-selector">
<Paper elevation={3} className="content-paper">
<Typography variant="h4" className="title">
Your diary
</Typography>
{username && (
<Typography variant="h6" className="username">
Letterboxd User: {username}
</Typography>
)}
{loading ? (
<Box className="progress-container">
<LinearProgress />
<Typography className="progress-label">
Downloading movies...
</Typography>
</Box>
) : movies.length > 0 ? (
<>
<List className="movies-list">
{movies.map((movie, index) => (
<ListItem
button
key={index}
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}` ||
`/api/placeholder/50/75`
}
alt={movie.Name}
className="movie-poster"
/>
</Grid>
<Grid item className="movie-info">
<Typography variant="subtitle1" className="movie-title">
{movie.Name}
</Typography>
<Typography variant="body2" className="movie-year">
{movie.Year}
</Typography>
</Grid>
<Grid item>
<Box className="watched-date">
{(() => {
const { day, month } = formatWatchedDate(
movie["Watched Date"]
);
return (
<>
<Typography
variant="body2"
className="watched-day"
>
{day}
</Typography>
<Typography
variant="body2"
className="watched-month"
>
{month}
</Typography>
</>
);
})()}
</Box>
</Grid>
</Grid>
</ListItem>
))}
</List>
<Box className="pagination-container">
<ThemeProvider theme={paginationTheme}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</ThemeProvider>
</Box>
</>
) : (
<Box className="no-movies-container">
<div className="gif-container">
<div className="circle-background"></div>
<img
src={pulpGif}
alt="Confused reaction"
className="reaction-gif"
/>
</div>
<Typography variant="body1" gutterBottom>
No movies found in your diary. Please check your Letterboxd
username or CSV file.
</Typography>
<Button
variant="contained"
className="back-button"
onClick={handleGoBack}
>
Go Back
</Button>
</Box>
)}
</Paper>
</Container>
<NavBar />
</>
);
};
export default PosterSelector;

View File

@@ -1,192 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Container,
Grid,
Typography,
Button,
CircularProgress,
TextField,
Box,
Link,
} from "@material-ui/core";
import { CloudUploadOutlined } from "@material-ui/icons";
import CloudDoneOutlinedIcon from "@mui/icons-material/CloudDoneOutlined";
import { useDropzone } from "react-dropzone";
import axios from "axios";
const apiUrl = process.env.REACT_APP_API_URL;
const VERSION = require("../../package.json").version;
const UploadDiary = () => {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [username, setUsername] = useState("");
const navigate = useNavigate();
useEffect(() => {
const checkCSVFile = async () => {
try {
const response = await axios.get(`${apiUrl}/api/check-csv`, {
withCredentials: true,
});
if (response.data.fileExists) {
navigate("/PosterSelector");
}
} catch (error) {
console.error("Error checking CSV file:", error);
}
};
checkCSVFile();
}, [navigate]);
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0]);
},
maxFiles: 1,
accept: ".csv",
});
const handleUpload = async () => {
if (username || file) {
setUploading(true);
try {
if (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" },
withCredentials: true,
});
navigate("/PosterSelector");
}
} catch (error) {
console.error("Error processing diary:", error);
} finally {
setUploading(false);
}
}
};
return (
<Box>
<Container className="upload-diary">
<div className="upload-card">
<div className="logo-container">
<img src="/icon.svg" alt="logo" className="logo" />
</div>
<Typography variant="h4" className="title">
Upload Letterboxd Diary
</Typography>
<TextField
className="username-field"
label="Letterboxd Username"
variant="outlined"
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">
Or upload a CSV file:
</Typography>
<div {...getRootProps()} className="dropzone">
<input {...getInputProps()} />
{file ? (
<>
<CloudDoneOutlinedIcon className="dropzone-icon" />
<Typography variant="h6" className="dropzone-text">
Your file has been uploaded: {file.name}
</Typography>
</>
) : (
<>
<CloudUploadOutlined className="dropzone-icon" />
<Typography variant="h6" className="dropzone-text">
Drag and drop a CSV file here or click to select
</Typography>
</>
)}
</div>
<Grid container justify="center" className="progress-container">
{uploading ? (
<CircularProgress />
) : (
<Button
className="submit-button"
variant="contained"
onClick={handleUpload}
disabled={!username && !file}
>
SUBMIT
</Button>
)}
</Grid>
</div>
<Typography variant="body1" className="overview-text">
This tool allows you to easily import your Letterboxd diary and select
your favorite movie poster. Follow these simple steps:
<br />
<strong>1. Enter Your Letterboxd Username:</strong> Type your
Letterboxd username to automatically fetch your diary.
<br />
<strong>2. Or Upload Your CSV File:</strong> If you prefer, you can
still upload your diary.csv file directly.
<br />
<strong>3. Automatic Processing:</strong> Once submitted, the
application will process your diary.
<br />
<strong>4. Poster Selection:</strong> After processing, you'll be
redirected to the Poster Selector page, where you can browse and
choose your favorite movie poster based on your Letterboxd diary.
</Typography>
</Container>
<Box
component="footer"
className="footer"
>
<Box
className="footer-text"
>
<Typography variant="body2" style={{ opacity: 0.7 }}>
v{VERSION}
</Typography>
<Typography variant="body2" style={{ opacity: 0.5 }}>
</Typography>
<Link
href="https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker"
target="_blank"
rel="noopener noreferrer"
className="link"
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)}
>
<span>Hugyouu</span>
</Link>
</Box>
</Box>
</Box>
);
};
export default UploadDiary;

View File

@@ -5,11 +5,7 @@ import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
root.render(<App />);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))

150
src/pages/PosterGallery.js Normal file
View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from "react";
import { useLocation, useParams, useNavigate } from "react-router-dom";
import api from "../services/api";
import { useSelector, useDispatch } from "react-redux";
import { selectPoster, deselectPoster } from "../services/action";
import {
Container,
Typography,
Grid,
Card,
Box,
Paper,
} from "@material-ui/core";
import { ToggleButton, ToggleButtonGroup } from "@mui/material";
import CheckIcon from "@material-ui/icons/Check";
const LANG_FILTERS = [
{ value: "all", label: "All" },
{ value: "en", label: "EN" },
{ value: "fr", label: "FR" },
{ value: "ja", label: "JA" },
{ value: "none", label: "—" },
];
const PosterCard = ({ poster, movieName, isSelected, onClick }) => {
const [imgLoaded, setImgLoaded] = useState(false);
return (
<Card
className={`poster-card ${isSelected ? "selected-poster" : ""}`}
onClick={onClick}
>
<div className="poster-aspect-wrapper">
<div className={`poster-skeleton${imgLoaded ? " poster-skeleton--hidden" : ""}`} />
<img
src={`https://image.tmdb.org/t/p/w500${poster.file_path}`}
alt={movieName}
className={`poster-image-img${imgLoaded ? " poster-image-img--loaded" : ""}`}
onLoad={() => setImgLoaded(true)}
/>
</div>
{isSelected && <CheckIcon className="check-icon" />}
</Card>
);
};
const PosterGallery = () => {
const location = useLocation();
const { watchedDate } = location.state || {};
const { movieName, movieYear } = useParams();
const navigate = useNavigate();
const [posters, setPosters] = useState([]);
const [loading, setLoading] = useState(true);
const [langFilter, setLangFilter] = useState("all");
const dispatch = useDispatch();
const movieId = `${movieName}-${movieYear}`;
const selectedPosters = useSelector(
(state) => state.posterSelections[movieId] || []
);
useEffect(() => {
const fetchPosters = async () => {
try {
setLoading(true);
const response = await api.get(
`/api/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(movieYear)}`
);
if (response.data?.posters) setPosters(response.data.posters);
} catch (error) {
console.error("Error fetching posters:", error);
} finally {
setLoading(false);
}
};
if (movieName && movieYear) fetchPosters();
}, [movieName, movieYear]);
const filteredPosters = posters.filter((p) => {
if (langFilter === "all") return true;
if (langFilter === "none") return !p.iso_639_1;
return p.iso_639_1 === langFilter;
});
const isPosterSelected = (posterId) =>
selectedPosters.some((p) => p.posterId === posterId);
const handlePosterSelect = (posterId) => {
if (isPosterSelected(posterId)) {
dispatch(deselectPoster(movieName, movieYear, posterId));
} else {
dispatch(selectPoster(movieName, movieYear, posterId, watchedDate));
navigate(-1);
}
};
const visiblePosters = loading
? Array.from({ length: 8 }).map((_, i) => ({ _skeleton: true, file_path: `sk-${i}` }))
: filteredPosters;
return (
<Container className="poster-gallery">
<Paper elevation={3} className="content-paper">
<Box className="lang-filter-bar">
<ToggleButtonGroup
value={langFilter}
exclusive
onChange={(_, v) => v && setLangFilter(v)}
className="lang-toggle-group"
>
{LANG_FILTERS.map(({ value, label }) => (
<ToggleButton key={value} value={value} className="lang-toggle-btn">
{label}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
{!loading && filteredPosters.length === 0 ? (
<Typography align="center" className="no-posters-msg">
No posters for this language.
</Typography>
) : (
<Grid container spacing={3}>
{visiblePosters.map((poster, index) =>
poster._skeleton ? (
<Grid item xs={12} sm={6} md={4} lg={3} key={poster.file_path}>
<div className="poster-aspect-wrapper">
<div className="poster-skeleton" />
</div>
</Grid>
) : (
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
<PosterCard
poster={poster}
movieName={movieName}
isSelected={isPosterSelected(poster.file_path)}
onClick={() => handlePosterSelect(poster.file_path)}
/>
</Grid>
)
)}
</Grid>
)}
</Paper>
</Container>
);
};
export default PosterGallery;

388
src/pages/PosterSelector.js Normal file
View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import api from "../services/api";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Container,
Typography,
LinearProgress,
Box,
Paper,
Button,
IconButton,
Select,
MenuItem,
Popover,
} from "@material-ui/core";
// LinearProgress kept for the initializing state only
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import CheckIcon from "@material-ui/icons/Check";
import pulpGif from "../static/images/pulp.gif";
const CURRENT_YEAR = new Date().getFullYear();
const YEARS = Array.from({ length: CURRENT_YEAR - 2000 + 1}, (_, i) => 2000 + i).reverse();
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const PosterSelector = () => {
const [currentMonth, setCurrentMonth] = useState(null);
const [moviesByDate, setMoviesByDate] = useState({});
const [loading, setLoading] = useState(true);
const [initializing, setInitializing] = useState(true);
const [popoverAnchor, setPopoverAnchor] = useState(null);
const [popoverMovies, setPopoverMovies] = useState([]);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const posterSelections = useSelector((state) => state.posterSelections);
const dataSource = useSelector((state) => state.dataSource);
useEffect(() => {
const init = async () => {
try {
const csvCheck = await api.get("/api/check-csv");
if (!csvCheck.data.fileExists) {
navigate("/");
return;
}
const urlMonth = searchParams.get("month");
if (urlMonth) {
const [year, month] = urlMonth.split("-").map(Number);
setCurrentMonth({ year, month: month - 1 });
setInitializing(false);
return;
}
const res = await api.get("/api/movies?page=1&limit=1");
if (res.data.movies?.length > 0) {
const latestDate = res.data.movies[0]["Watched Date"];
const [year, month] = latestDate.split("-").map(Number);
setCurrentMonth({ year, month: month - 1 });
} else {
const now = new Date();
setCurrentMonth({ year: now.getFullYear(), month: now.getMonth() });
}
} catch {
navigate("/");
} finally {
setInitializing(false);
}
};
init();
}, [navigate, searchParams]);
useEffect(() => {
if (!currentMonth) return;
fetchMoviesForMonth(currentMonth.year, currentMonth.month);
}, [currentMonth]);
const fetchMoviesForMonth = useCallback(async (year, month) => {
setLoading(true);
try {
const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`;
const res = await api.get(`/api/movies?month=${monthStr}`);
const map = {};
res.data.movies?.forEach((movie) => {
const date = movie["Watched Date"];
if (!map[date]) map[date] = [];
map[date].push(movie);
});
setMoviesByDate(map);
} catch (err) {
console.error("Error fetching movies:", err);
} finally {
setLoading(false);
}
}, []);
const currentMonthRef = useRef(currentMonth);
useEffect(() => { currentMonthRef.current = currentMonth; }, [currentMonth]);
useEffect(() => {
const handleSync = () => {
const m = currentMonthRef.current;
if (m) fetchMoviesForMonth(m.year, m.month);
};
window.addEventListener('diary-synced', handleSync);
return () => window.removeEventListener('diary-synced', handleSync);
}, [fetchMoviesForMonth]);
const updateMonth = (year, month) => {
setCurrentMonth({ year, month });
setSearchParams({ month: `${year}-${String(month + 1).padStart(2, "0")}` });
};
const goToPrevMonth = () => {
const { year, month } = currentMonth;
if (month === 0) updateMonth(year - 1, 11);
else updateMonth(year, month - 1);
};
const goToNextMonth = () => {
const { year, month } = currentMonth;
if (month === 11) updateMonth(year + 1, 0);
else updateMonth(year, month + 1);
};
const goToToday = () => {
const now = new Date();
updateMonth(now.getFullYear(), now.getMonth());
};
const handleGoToUpload = async () => {
try {
await api.delete("/api/delete-csv");
} catch {
// server may have restarted, proceed anyway
}
navigate("/");
};
const handleMovieClick = (movie) => {
navigate(
`/posters/${encodeURIComponent(movie.Name)}/${encodeURIComponent(movie.Year)}`,
{ state: { watchedDate: movie["Watched Date"] } }
);
};
const handleCellClick = (e, movies) => {
if (!movies.length) return;
if (movies.length === 1) {
handleMovieClick(movies[0]);
} else {
setPopoverMovies(movies);
setPopoverAnchor(e.currentTarget);
}
};
const buildCalendarCells = () => {
if (!currentMonth) return [];
const { year, month } = currentMonth;
const firstDayOfWeek = new Date(year, month, 1).getDay();
const startOffset = (firstDayOfWeek + 6) % 7; // Mon=0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startOffset; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
return cells;
};
if (initializing) {
return (
<>
<Container className="poster-selector">
<Paper elevation={3} className="content-paper">
<Box className="progress-container">
<LinearProgress />
</Box>
</Paper>
</Container>
</>
);
}
const cells = buildCalendarCells();
const hasAnyMovie = Object.keys(moviesByDate).length > 0;
const today = new Date();
const isCurrentMonth =
currentMonth.year === today.getFullYear() &&
currentMonth.month === today.getMonth();
const isFutureMonth =
currentMonth.year > today.getFullYear() ||
(currentMonth.year === today.getFullYear() && currentMonth.month > today.getMonth());
return (
<>
<Container className="poster-selector">
<Paper elevation={3} className="content-paper">
<Box className="calendar-nav">
<Button className="calendar-today-btn" onClick={goToToday} size="small">
Today
</Button>
<Box className="calendar-nav-center">
<IconButton onClick={goToPrevMonth} className="calendar-nav-btn">
<ChevronLeftIcon />
</IconButton>
<Typography variant="h6" className="calendar-month-label">
{currentMonth
? `${MONTH_NAMES[currentMonth.month]} ${currentMonth.year}`
: ""}
</Typography>
<IconButton onClick={goToNextMonth} className="calendar-nav-btn" disabled={isCurrentMonth}>
<ChevronRightIcon />
</IconButton>
</Box>
<Box className="calendar-month-selects">
<Select
value={currentMonth?.month ?? ""}
onChange={(e) => updateMonth(currentMonth.year, e.target.value)}
className="calendar-select"
disableUnderline
MenuProps={{ PaperProps: { className: "calendar-select-menu" } }}
>
{MONTH_NAMES.map((name, i) => {
const disabled =
currentMonth.year === today.getFullYear() && i > today.getMonth();
return <MenuItem key={i} value={i} disabled={disabled}>{name}</MenuItem>;
})}
</Select>
<Select
value={currentMonth?.year ?? ""}
onChange={(e) => updateMonth(e.target.value, currentMonth.month)}
className="calendar-select"
disableUnderline
MenuProps={{ PaperProps: { className: "calendar-select-menu" } }}
>
{YEARS.map((y) => (
<MenuItem key={y} value={y}>{y}</MenuItem>
))}
</Select>
</Box>
</Box>
{dataSource === 'rss' && (
<Box className="rss-notice">
<span className="rss-notice-icon"></span>
RSS sync showing your last ~50 films only.{' '}
<span className="rss-notice-link" onClick={handleGoToUpload}>
Upload a CSV
</span>{' '}
for your full history.
</Box>
)}
<div className="calendar-grid-wrapper">
<div className="calendar-grid">
{DAY_LABELS.map((label) => (
<div key={label} className="calendar-day-header">{label}</div>
))}
{cells.map((day, i) => {
if (!day) {
return <div key={`empty-${i}`} className="calendar-cell calendar-cell--empty" />;
}
const { year, month } = currentMonth;
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const isFuture = isFutureMonth || (isCurrentMonth && day > today.getDate());
const isToday = isCurrentMonth && day === today.getDate();
const movies = isFuture ? [] : (moviesByDate[dateStr] || []);
const movie = movies[0];
const movieId = movie ? `${movie.Name}-${movie.Year}` : null;
const selection = movieId ? posterSelections[movieId]?.[0] : null;
const posterSrc = selection
? `https://image.tmdb.org/t/p/w200${selection.posterId}`
: movie?.Poster
? `https://image.tmdb.org/t/p/w200${movie.Poster}`
: null;
return (
<div
key={dateStr}
className={[
"calendar-cell",
isFuture ? "calendar-cell--future" : "",
isToday ? "calendar-cell--today" : "",
!loading && movie ? "calendar-cell--has-movie" : "",
selection ? "calendar-cell--selected" : "",
].filter(Boolean).join(" ")}
onClick={(e) => !loading && movies.length > 0 && handleCellClick(e, movies)}
>
<span className="calendar-day-number">{day}</span>
{loading && !isFuture && (
<div className="calendar-poster-wrapper">
<div className="calendar-cell-skeleton" />
</div>
)}
{!loading && movie && posterSrc && (
<div className="calendar-poster-wrapper">
<img
src={posterSrc}
alt={movie.Name}
className="calendar-poster"
/>
<div className="calendar-poster-overlay">
<span className="calendar-movie-title">{movie.Name}</span>
<span className="calendar-movie-year">{movie.Year}</span>
</div>
{selection && (
<span className="calendar-selected-badge">
<CheckIcon style={{ fontSize: 12 }} />
</span>
)}
{movies.length > 1 && (
<span className="calendar-more-badge">+{movies.length - 1}</span>
)}
</div>
)}
</div>
);
})}
</div>
{!loading && !hasAnyMovie && !isFutureMonth && (
<Box className="no-movies-container">
<div className="gif-container">
<div className="circle-background"></div>
<img src={pulpGif} alt="No movies" className="reaction-gif" />
</div>
<Typography variant="body1" gutterBottom>
No movies watched this month.
</Typography>
<Button variant="contained" className="back-button" onClick={() => navigate("/")}>
Go Back
</Button>
</Box>
)}
</div>
</Paper>
</Container>
<Popover
open={Boolean(popoverAnchor)}
anchorEl={popoverAnchor}
onClose={() => setPopoverAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
PaperProps={{ className: "multi-movie-popover" }}
>
{popoverMovies.map((movie) => {
const movieId = `${movie.Name}-${movie.Year}`;
const selection = posterSelections[movieId]?.[0];
const posterSrc = selection
? `https://image.tmdb.org/t/p/w200${selection.posterId}`
: movie.Poster
? `https://image.tmdb.org/t/p/w200${movie.Poster}`
: null;
return (
<div
key={movieId}
className="multi-movie-item"
onClick={() => { handleMovieClick(movie); setPopoverAnchor(null); }}
>
{posterSrc ? (
<img src={posterSrc} alt={movie.Name} className="multi-movie-poster" />
) : (
<div className="multi-movie-poster multi-movie-poster--empty" />
)}
<div className="multi-movie-info">
<span className="multi-movie-title">{movie.Name}</span>
<span className="multi-movie-year">{movie.Year}</span>
</div>
{selection && <CheckIcon className="multi-movie-check" style={{ fontSize: 16 }} />}
</div>
);
})}
</Popover>
</>
);
};
export default PosterSelector;

173
src/pages/SelectionRecap.js Normal file
View File

@@ -0,0 +1,173 @@
import React, { useState } from "react";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
Container,
Typography,
Box,
Button,
IconButton,
LinearProgress,
} from "@material-ui/core";
import GetAppIcon from "@material-ui/icons/GetApp";
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
import CheckIcon from "@material-ui/icons/Check";
import JSZip from "jszip";
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const SelectionRecap = () => {
const navigate = useNavigate();
const posterSelections = useSelector((state) => state.posterSelections);
const [downloadState, setDownloadState] = useState(null);
const entries = Object.entries(posterSelections)
.flatMap(([movieId, posters]) => {
if (!posters?.length) return [];
const match = movieId.match(/^(.+)-(\d{4})$/);
if (!match) return [];
return posters.map((p) => ({
movieId,
movieName: match[1],
movieYear: match[2],
posterId: p.posterId,
watchedDate: p.watchedDate || "",
}));
})
.sort((a, b) => a.watchedDate.localeCompare(b.watchedDate));
const groups = entries.reduce((acc, entry) => {
const key = entry.watchedDate.slice(0, 7);
if (!acc[key]) acc[key] = [];
acc[key].push(entry);
return acc;
}, {});
const sortedMonths = Object.keys(groups).sort().reverse();
const handleDownload = async () => {
if (downloadState) return;
setDownloadState({ current: 0, total: entries.length });
try {
const zip = new JSZip();
for (let i = 0; i < entries.length; i++) {
const { movieId, posterId, watchedDate } = entries[i];
const res = await fetch(`https://image.tmdb.org/t/p/original${posterId}`);
const blob = await res.blob();
zip.file(`${watchedDate}_${movieId}.jpg`, blob);
setDownloadState({ current: i + 1, total: entries.length });
}
const content = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(content);
const link = document.createElement("a");
link.href = url;
link.download = "posters.zip";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Download error:", err);
} finally {
setDownloadState(null);
}
};
if (entries.length === 0) {
return (
<Container className="selection-recap">
<Box className="recap-empty">
<Typography variant="h6">No posters selected yet.</Typography>
<Button
variant="contained"
className="recap-go-btn"
onClick={() => navigate("/PosterSelector")}
>
Go to calendar
</Button>
</Box>
</Container>
);
}
const isDownloading = !!downloadState;
const progress = isDownloading
? Math.round((downloadState.current / downloadState.total) * 100)
: 0;
return (
<Container className="selection-recap">
<Box className="recap-header">
<IconButton className="recap-back-btn" onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h5" className="recap-title">
Selected Posters ({entries.length})
</Typography>
<Button
className={`recap-download-btn${isDownloading ? " recap-download-btn--loading" : ""}`}
startIcon={!isDownloading && <GetAppIcon />}
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<Box className="recap-download-progress">
<span>{downloadState.current} / {downloadState.total}</span>
<LinearProgress
variant="determinate"
value={progress}
className="recap-progress-bar"
/>
</Box>
) : (
"Download all"
)}
</Button>
</Box>
{sortedMonths.map((monthKey) => {
const [year, month] = monthKey.split("-").map(Number);
const monthLabel = `${MONTH_NAMES[month - 1]} ${year}`;
return (
<Box key={monthKey} className="recap-month-section">
<Typography className="recap-month-label">{monthLabel}</Typography>
<div className="recap-grid">
{groups[monthKey].map((entry) => (
<div
key={entry.movieId}
className="recap-card"
onClick={() =>
navigate(
`/posters/${encodeURIComponent(entry.movieName)}/${encodeURIComponent(entry.movieYear)}`,
{ state: { watchedDate: entry.watchedDate } }
)
}
>
<div className="recap-poster-wrapper">
<img
src={`https://image.tmdb.org/t/p/w300${entry.posterId}`}
alt={entry.movieName}
className="recap-poster"
/>
<div className="recap-overlay">
<span className="recap-movie-title">{entry.movieName}</span>
<span className="recap-movie-year">{entry.movieYear}</span>
</div>
<span className="recap-check-badge">
<CheckIcon style={{ fontSize: 12 }} />
</span>
</div>
</div>
))}
</div>
</Box>
);
})}
</Container>
);
};
export default SelectionRecap;

236
src/pages/UploadDiary.js Normal file
View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import {
Container,
Grid,
Typography,
CircularProgress,
Box,
Link,
TextField,
InputAdornment,
} from "@material-ui/core";
import { Button } from "@mui/material";
import { CloudUploadOutlined } from "@material-ui/icons";
import CloudDoneOutlinedIcon from "@mui/icons-material/CloudDoneOutlined";
import SyncIcon from "@material-ui/icons/Sync";
import { useDropzone } from "react-dropzone";
import api from "../services/api";
import { setUsername, setDataSource } from "../services/action";
const VERSION = require("../../package.json").version;
const UploadDiary = () => {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [usernameInput, setUsernameInput] = useState("");
const [syncing, setSyncing] = useState(false);
const [syncError, setSyncError] = useState(null);
const navigate = useNavigate();
const dispatch = useDispatch();
const storedUsername = useSelector((state) => state.username);
useEffect(() => {
if (storedUsername) setUsernameInput(storedUsername);
}, [storedUsername]);
useEffect(() => {
const checkCSVFile = async () => {
try {
const response = await api.get("/api/check-csv");
if (response.data.fileExists) {
navigate("/PosterSelector");
}
} catch (error) {
console.error("Error checking CSV file:", error);
}
};
checkCSVFile();
}, [navigate]);
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFile(acceptedFiles[0]);
},
maxFiles: 1,
accept: ".csv",
});
const handleUpload = async () => {
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
await api.post("/api/upload-csv", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
dispatch(setDataSource('csv'));
navigate("/PosterSelector");
} catch (error) {
console.error("Error processing diary:", error);
} finally {
setUploading(false);
}
};
const handleRssSync = async () => {
const username = usernameInput.trim();
if (!username) return;
setSyncing(true);
setSyncError(null);
try {
const res = await api.post("/api/sync-rss", { username });
dispatch(setUsername(username));
if (res.data.fresh) dispatch(setDataSource('rss'));
navigate("/PosterSelector");
} catch (err) {
const msg = err.response?.data?.error || "Could not fetch RSS feed.";
setSyncError(msg);
} finally {
setSyncing(false);
}
};
return (
<Box className="upload-page">
<Container className="upload-diary">
<div className="upload-card-wrapper">
<div className="upload-card">
<div className="logo-container">
<img src="/icon.svg" alt="logo" className="logo" />
</div>
{/* CSV upload */}
<Typography variant="h4" className="title">
Upload Letterboxd Diary
</Typography>
<div {...getRootProps()} className="dropzone">
<input {...getInputProps()} />
{file ? (
<>
<CloudDoneOutlinedIcon className="dropzone-icon" />
<Typography variant="h6" className="dropzone-text">
Your file has been uploaded: {file.name}
</Typography>
</>
) : (
<>
<CloudUploadOutlined className="dropzone-icon" />
<Typography variant="h6" className="dropzone-text">
Drag and drop a CSV file here or click to select
</Typography>
</>
)}
</div>
<Grid container justifyContent="center" className="progress-container">
{uploading ? (
<CircularProgress />
) : (
<Button
className="submit-button"
variant="contained"
onClick={handleUpload}
disabled={!file}
>
SUBMIT
</Button>
)}
</Grid>
{/* Divider */}
<div className="upload-divider">
<span className="upload-divider-line" />
<span className="upload-divider-text">or</span>
<span className="upload-divider-line" />
</div>
{/* RSS sync */}
<Typography variant="body1" className="rss-label">
Sync from Letterboxd RSS
</Typography>
<div className="rss-row">
<TextField
className="rss-input"
variant="outlined"
size="small"
placeholder="your-username"
value={usernameInput}
onChange={(e) => { setUsernameInput(e.target.value); setSyncError(null); }}
onKeyDown={(e) => e.key === "Enter" && handleRssSync()}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<span className="rss-url-prefix">letterboxd.com/</span>
</InputAdornment>
),
}}
/>
<Button
className="rss-sync-button"
variant="contained"
onClick={handleRssSync}
disabled={syncing || !usernameInput.trim()}
startIcon={syncing ? <CircularProgress size={14} color="inherit" /> : <SyncIcon />}
>
{syncing ? "Syncing…" : "Sync"}
</Button>
</div>
{syncError && (
<Typography className="rss-error">{syncError}</Typography>
)}
<Typography className="rss-note">
Syncs your ~50 most recent diary entries via the public RSS feed.
Use CSV export for full history.
</Typography>
</div>
</div>
<Typography variant="body1" className="overview-text">
This tool allows you to easily import your Letterboxd diary and select
your favorite movie poster. Follow these simple steps:
<br />
<strong>1. Export your diary:</strong>{" "}
<Link
href="https://letterboxd.com/data/export/"
target="_blank"
rel="noopener noreferrer"
className="link"
>
Export your Letterboxd data
</Link>{" "}
and extract the <code>diary.csv</code> file from the ZIP.
<br />
<strong>2. Upload the CSV:</strong> Drop your <code>diary.csv</code>{" "}
file above or click to select it.
<br />
<strong>3. Poster Selection:</strong> After processing, you'll be
redirected to the calendar where you can browse and choose your
favorite poster for each movie.
</Typography>
</Container>
<Box component="footer" className="footer">
<Box className="footer-text">
<Typography variant="body2" style={{ opacity: 0.7 }}>
v{VERSION}
</Typography>
<Typography variant="body2" style={{ opacity: 0.5 }}>
</Typography>
<Link
href="https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker"
target="_blank"
rel="noopener noreferrer"
className="link"
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)}
>
<span>Hugyouu</span>
</Link>
</Box>
</Box>
</Box>
);
};
export default UploadDiary;

View File

@@ -1,3 +1,10 @@
export const SET_USERNAME = "SET_USERNAME";
export const setUsername = (username) => ({ type: SET_USERNAME, payload: username });
export const SET_DATA_SOURCE = "SET_DATA_SOURCE";
// source: 'csv' | 'rss'
export const setDataSource = (source) => ({ type: SET_DATA_SOURCE, payload: source });
export const SELECT_POSTER = "SELECT_POSTER";
export const DESELECT_POSTER = "DESELECT_POSTER";
export const REMOVE_POSTER = "REMOVE_POSTER";

View File

@@ -1,7 +1,22 @@
import axios from "axios";
const API_BASE_URL = "http://localhost:5000/api";
export const getUserId = () => {
let id = localStorage.getItem("poster_picker_uid");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("poster_picker_uid", id);
}
return id;
};
export const fetchMovies = () => axios.get(`${API_BASE_URL}/movies`);
export const downloadPoster = (movieId) =>
axios.post(`${API_BASE_URL}/download-poster`, { movie_id: movieId });
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true,
});
api.interceptors.request.use((config) => {
config.headers["X-User-ID"] = getUserId();
return config;
});
export default api;

View File

@@ -1,7 +1,7 @@
import { legacy_createStore as createStore, combineReducers } from "redux";
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import {DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER} from "./action";
import { DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER, SET_USERNAME, SET_DATA_SOURCE } from "./action";
const posterSelectionReducer = (state = {}, action) => {
switch (action.type) {
@@ -9,7 +9,7 @@ const posterSelectionReducer = (state = {}, action) => {
const { movieId, posterId, watchedDate } = action.payload;
return {
...state,
[movieId]: [...(state[movieId] || []), { posterId, watchedDate }],
[movieId]: [{ posterId, watchedDate }],
};
}
case DESELECT_POSTER: {
@@ -38,9 +38,22 @@ const posterSelectionReducer = (state = {}, action) => {
}
};
const usernameReducer = (state = "", action) => {
switch (action.type) {
case SET_USERNAME: return action.payload;
default: return state;
}
};
const dataSourceReducer = (state = null, action) => {
if (action.type === SET_DATA_SOURCE) return action.payload;
return state;
};
const rootReducer = combineReducers({
posterSelections: posterSelectionReducer,
// other reducers...
username: usernameReducer,
dataSource: dataSourceReducer,
});
const persistConfig = {

View File

@@ -1,6 +1,7 @@
@use "./scss/uploadDiary";
@use "./scss/posterSelector";
@use "./scss/posterGallery";
@use "./scss/selectionRecap";
@use "./scss/cart";
@use "./scss/navbar";

View File

@@ -1,32 +1,206 @@
// _navbar.scss
@use "sass:color";
// Variables de couleur et autres constantes
$background-color: #1c1f23;
$icon-color: white;
$margin-right: 8px; // Correspondant à theme.spacing(2)
$primary-color: #00a346;
$text-white: #ffffff;
$text-gray: #667788;
.navbar {
top: auto !important;
bottom: 0;
.app-header {
background-color: $background-color !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) !important;
.toolbar {
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem;
}
.back-button,
.cart-button,
.refresh-button {
color: $icon-color;
.header-left {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
flex-shrink: 0;
&:hover .header-title {
color: $primary-color;
}
}
.refresh-button {
margin-right: $margin-right;
.header-logo {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.back-button[disabled] {
opacity: 0.3;
pointer-events: none;
.header-title {
color: $text-white;
font-weight: 700;
letter-spacing: 0.02em;
transition: color 0.15s ease;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 40%;
}
.header-movie-name {
color: $text-white;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-username {
color: $text-gray;
font-size: 0.85rem !important;
letter-spacing: 0.03em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::before {
content: '@';
opacity: 0.6;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-menu-btn {
display: none !important;
color: $text-gray !important;
&:hover { color: $text-white !important; }
}
@media (max-width: 640px) {
.header-actions { display: none !important; }
.header-menu-btn { display: flex !important; }
.header-title { font-size: 1rem !important; }
.header-center { display: none; }
}
.header-reset-btn {
color: $text-gray !important;
&:hover {
color: $text-white !important;
}
}
.header-recap-btn {
color: $text-gray !important;
&:hover {
color: $text-white !important;
}
}
.header-sync-btn {
color: $text-gray !important;
&:hover { color: $text-white !important; }
&--active { color: $primary-color !important; }
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.header-download-btn {
background-color: $primary-color !important;
color: $text-white !important;
font-weight: 600;
padding: 0.4rem 1rem;
border-radius: 4px;
text-transform: none;
font-size: 0.875rem;
&:hover {
background-color: color.adjust($primary-color, $lightness: -8%) !important;
}
&--loading {
background-color: color.adjust($primary-color, $lightness: -12%) !important;
cursor: default !important;
min-width: 140px;
}
}
.header-download-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 120px;
span {
font-size: 0.8rem;
font-weight: 600;
color: $text-white;
line-height: 1;
}
}
.header-progress-bar {
width: 100%;
border-radius: 2px;
height: 4px !important;
.MuiLinearProgress-bar {
background-color: $text-white !important;
}
&.MuiLinearProgress-root {
background-color: rgba(255, 255, 255, 0.3) !important;
}
}
}
// Menu mobile (portail MUI, hors de .app-header)
.header-menu-paper {
background-color: #1c1f23 !important;
border: 1px solid #2c3038 !important;
border-radius: 6px !important;
min-width: 200px;
.MuiList-root { padding: 4px 0; }
}
.header-menu-item {
color: #ffffff !important;
font-size: 0.875rem !important;
gap: 10px;
padding: 10px 16px !important;
&:hover { background-color: rgba(102, 119, 136, 0.2) !important; }
&.Mui-disabled { opacity: 0.4 !important; color: #ffffff !important; }
&--danger {
color: #ef9a9a !important;
margin-top: 4px;
border-top: 1px solid #2c3038;
}
}
.header-menu-icon {
opacity: 0.6;
flex-shrink: 0;
}

View File

@@ -6,7 +6,7 @@ $hover-scale: 1.05;
$loading-height: 50vh;
.poster-gallery {
margin-top: 2rem;
margin-top: 5rem;
margin-bottom: 2rem;
.content-paper {
@@ -15,6 +15,49 @@ $loading-height: 50vh;
background-color: $background-dark;
}
.lang-filter-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 1.25rem;
}
.lang-toggle-group {
border: 1px solid #2c3038 !important;
border-radius: 4px !important;
overflow: hidden;
}
.lang-toggle-btn {
color: #667788 !important;
border: none !important;
border-left: 1px solid #2c3038 !important;
padding: 4px 14px !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
text-transform: none !important;
min-width: unset !important;
background-color: transparent !important;
&:first-child {
border-left: none !important;
}
&:hover {
color: #ffffff !important;
background-color: rgba(102, 119, 136, 0.15) !important;
}
&.Mui-selected {
color: #ffffff !important;
background-color: #667788 !important;
}
}
.no-posters-msg {
color: #667788;
padding: 3rem 0;
}
.title {
color: $text-white;
margin-bottom: 1.5rem;
@@ -24,30 +67,19 @@ $loading-height: 50vh;
.poster-card {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
background-color: transparent;
box-shadow: none;
cursor: pointer;
transition: all 0.3s ease;
transition: transform 0.3s ease;
border-radius: 4px;
overflow: hidden;
&:hover {
transform: scale($hover-scale);
}
&.selected-poster {
border: 4px solid $primary-color;
border-radius: 4px;
}
.poster-image {
padding-top: 150%;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
border-radius: 4px;
transition: transform 0.3s ease-in-out;
outline: 4px solid $primary-color;
}
.check-icon {
@@ -61,6 +93,46 @@ $loading-height: 50vh;
}
}
.poster-aspect-wrapper {
position: relative;
padding-top: 150%;
border-radius: 4px;
overflow: hidden;
}
.poster-skeleton {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #1c1f23 25%, #2a2e33 50%, #1c1f23 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s infinite;
transition: opacity 0.3s ease;
&--hidden {
opacity: 0;
pointer-events: none;
}
}
.poster-image-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.4s ease;
&--loaded {
opacity: 1;
}
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loading-container {
display: flex;
justify-content: center;

View File

@@ -1,134 +1,314 @@
@use "sass:color";
// Variables
$primary-color: #00a346;
$secondary-color: #667788;
$background-dark: #1c1f23;
$text-white: #ffffff;
$text-gray: #667788;
$border-color: #667788;
$border-color: #2c3038;
$hover-color: rgba(102, 119, 136, 0.2);
.poster-selector {
margin-top: 2rem;
margin-top: 5rem;
margin-bottom: 2rem;
.content-paper {
padding: 1.5rem;
border-radius: 4px;
background-color: $background-dark !important;
background-color: $background-dark;
max-width: calc((120vh - 340px) * 7 / 9 + 3rem);
margin: 0 auto;
}
.title {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
color: $text-white;
}
.username {
color: $primary-color;
margin-bottom: 1rem;
}
.movies-list {
max-height: 800px;
min-height: 900px;
padding: 0;
}
.movie-item {
padding: 0 2rem;
margin-bottom: 1rem;
transition: background-color 0.3s ease;
// Calendar navigation
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
margin-bottom: 1.5rem;
}
.calendar-nav-center {
display: flex;
align-items: center;
gap: 0.25rem;
}
.calendar-nav-btn {
color: $text-white !important;
&:hover {
background-color: $hover-color;
}
&:not(:last-child) {
border-bottom: 1px solid $border-color;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
// Override MUI ListItem styles
&.MuiListItem-root {
padding: 0.5rem !important;
}
.MuiGrid-container {
display: flex;
justify-content: space-between;
width: 100%;
}
.movie-poster {
width: 50px;
height: 75px;
object-fit: cover;
margin-right: 1rem;
border-radius: 4px;
}
.movie-info {
flex-grow: 1;
max-width: calc(100% - 110px);
}
.movie-title {
color: $text-white;
font-family: "TiemposTextWeb-Semibold, Georgia, serif";
font-size: 1.38461538rem;
font-weight: 400;
transition: color 0.3s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: $primary-color;
}
}
.movie-year {
color: $text-gray;
}
.watched-date {
color: $text-gray;
}
.watched-day {
font-size: 2rem;
line-height: 1;
margin-bottom: 0.25rem;
}
.watched-month {
font-size: 0.875rem;
background-color: $hover-color !important;
}
}
.pagination-container {
.calendar-month-label {
color: $text-white;
min-width: 160px;
text-align: center;
}
.calendar-today-btn {
color: $text-gray !important;
font-size: 0.75rem !important;
text-transform: none !important;
padding: 2px 8px !important;
border: 1px solid $text-gray !important;
border-radius: 4px !important;
min-width: unset !important;
&:hover {
color: $text-white !important;
border-color: $text-white !important;
}
}
.calendar-month-selects {
display: flex;
justify-content: center;
margin-top: 1.5rem;
align-items: center;
gap: 4px;
border: 1px solid $border-color;
border-radius: 4px;
padding: 0 4px;
// Override MUI Pagination styles
.MuiPagination-root {
.MuiPaginationItem-root {
&:hover {
border-color: $text-gray;
}
}
.calendar-select {
color: $text-gray !important;
font-size: 0.8rem !important;
.MuiSelect-root {
padding: 4px 24px 4px 6px !important;
}
.MuiSelect-icon {
color: $text-gray;
}
&:hover {
color: $text-white !important;
}
}
.calendar-select-menu {
background-color: #1c1f23 !important;
color: $text-white !important;
border: 1px solid $border-color;
.MuiMenuItem-root {
font-size: 0.85rem;
color: $text-gray;
&:hover, &.Mui-selected {
background-color: rgba(102, 119, 136, 0.2) !important;
color: $text-white;
&.Mui-selected {
background-color: $secondary-color;
}
}
}
}
.rss-notice {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: $text-gray;
background-color: rgba(102, 119, 136, 0.08);
border: 1px solid rgba(102, 119, 136, 0.2);
border-radius: 4px;
padding: 6px 10px;
margin-bottom: 1rem;
}
.rss-notice-icon {
font-size: 0.8rem;
flex-shrink: 0;
}
.rss-notice-link {
color: $primary-color;
cursor: pointer;
text-decoration: underline;
&:hover { color: color.scale($primary-color, $lightness: 15%); }
}
// Calendar grid
.calendar-grid-wrapper {
position: relative;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
max-width: calc((120vh - 340px) * 7 / 9);
min-width: 280px;
margin: 0 auto;
}
.calendar-day-header {
color: $text-gray;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
padding: 0.25rem 0;
letter-spacing: 0.05em;
}
.calendar-cell {
position: relative;
border-radius: 4px;
border: 1px solid $border-color;
overflow: hidden;
background-color: #14181c;
aspect-ratio: 2/3;
min-height: 14vh;
&--empty {
border-color: transparent;
background-color: transparent;
}
&--future {
background-color: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.04);
.calendar-day-number {
opacity: 0.25;
}
}
&--today {
border-color: $secondary-color;
.calendar-day-number {
color: $text-white;
font-weight: 700;
}
}
&--has-movie {
cursor: pointer;
border-color: transparent;
&:hover .calendar-poster-overlay {
opacity: 1;
}
&:hover .calendar-day-number {
color: $text-white;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
}
}
.calendar-day-number {
position: absolute;
top: 4px;
left: 6px;
font-size: 0.7rem;
color: $text-gray;
z-index: 2;
line-height: 1;
}
.calendar-poster-wrapper {
position: absolute;
inset: 0;
}
.calendar-cell-skeleton {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #1c1f23 25%, #252930 50%, #1c1f23 75%);
background-size: 200% 100%;
animation: calendar-shimmer 1.4s infinite;
}
@keyframes calendar-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.calendar-poster {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.calendar-poster-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.2) 60%, transparent 100%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 6px;
opacity: 0;
transition: opacity 0.2s ease;
}
.calendar-movie-title {
color: $text-white;
font-size: 0.65rem;
font-weight: 600;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.calendar-movie-year {
color: $text-gray;
font-size: 0.6rem;
margin-top: 2px;
}
.calendar-more-badge {
position: absolute;
top: 4px;
right: 4px;
background-color: $primary-color;
color: $text-white;
font-size: 0.6rem;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
z-index: 2;
}
.calendar-selected-badge {
position: absolute;
bottom: 4px;
right: 4px;
background-color: $primary-color;
color: $text-white;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.calendar-cell--selected {
box-shadow: 0 0 0 2px $primary-color;
border-radius: 4px;
}
// Shared states
.progress-container {
display: flex;
justify-content: center;
@@ -148,12 +328,23 @@ $hover-color: rgba(102, 119, 136, 0.2);
}
.no-movies-container {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: $text-white;
background: rgba(28, 31, 35, 0.7);
backdrop-filter: blur(6px);
border-radius: 4px;
animation: fade-in 0.3s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.gif-container {
@@ -193,3 +384,142 @@ $hover-color: rgba(102, 119, 136, 0.2);
}
}
}
// Responsive
@media (max-width: 768px) {
.poster-selector {
margin-top: 3.5rem !important;
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.poster-selector .content-paper {
padding: 0.75rem !important;
max-width: 100% !important;
}
// Nav : deux rangées
// Rangée 1 : Today (gauche) | chevrons + label (droite)
// Rangée 2 : selects centrés
.poster-selector .calendar-nav {
flex-wrap: wrap;
row-gap: 8px;
margin-bottom: 0.75rem;
}
.poster-selector .calendar-today-btn {
order: 1;
}
.poster-selector .calendar-nav-center {
order: 2;
flex: 1;
justify-content: flex-end;
}
.poster-selector .calendar-month-label {
min-width: unset !important;
font-size: 0.9rem !important;
}
.poster-selector .calendar-month-selects {
order: 3;
width: 100%;
justify-content: center !important;
border: none !important;
border-top: 1px solid #2c3038 !important;
border-radius: 0 !important;
padding: 6px 0 0 !important;
gap: 8px !important;
}
// Grille
.poster-selector .calendar-grid {
gap: 3px;
max-width: 100%;
min-width: unset;
}
.poster-selector .calendar-day-header {
font-size: 0.6rem;
padding: 0.15rem 0;
}
.poster-selector .calendar-cell {
min-height: unset; // aspect-ratio 2/3 contrôle la hauteur
border-radius: 3px;
}
.poster-selector .calendar-day-number {
font-size: 0.6rem;
}
.poster-selector .calendar-movie-title {
font-size: 0.55rem;
}
.poster-selector .calendar-more-badge,
.poster-selector .calendar-selected-badge {
display: none;
}
}
// Popover multi-films (rendu en portal, hors de .poster-selector)
.multi-movie-popover {
background-color: #1c1f23 !important;
border: 1px solid #2c3038;
border-radius: 6px !important;
overflow: hidden;
min-width: 220px;
}
.multi-movie-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #2c3038;
&:last-child { border-bottom: none; }
&:hover { background-color: rgba(102, 119, 136, 0.2); }
}
.multi-movie-poster {
width: 36px;
height: 54px;
object-fit: cover;
border-radius: 2px;
flex-shrink: 0;
&--empty { background-color: #2c3038; }
}
.multi-movie-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.multi-movie-title {
color: #ffffff;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.multi-movie-year {
color: #667788;
font-size: 0.7rem;
}
.multi-movie-check {
color: #00a346 !important;
flex-shrink: 0;
}

View File

@@ -0,0 +1,187 @@
@use "sass:color";
$primary-color: #00a346;
$background-dark: #1c1f23;
$text-white: #ffffff;
$text-gray: #667788;
$border-color: #2c3038;
.selection-recap {
margin-top: 5rem;
margin-bottom: 3rem;
.recap-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2.5rem;
}
.recap-back-btn {
color: $text-gray !important;
&:hover { color: $text-white !important; }
}
.recap-title {
flex: 1;
color: $text-white;
font-weight: 600;
}
.recap-download-btn {
background-color: $primary-color !important;
color: $text-white !important;
font-weight: 600;
text-transform: none !important;
padding: 0.4rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
&:hover { background-color: color.adjust($primary-color, $lightness: -8%) !important; }
&--loading {
background-color: color.adjust($primary-color, $lightness: -12%) !important;
cursor: default !important;
min-width: 150px;
}
&.Mui-disabled {
color: $text-white !important;
opacity: 0.8 !important;
}
}
.recap-download-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 120px;
span {
font-size: 0.8rem;
font-weight: 600;
color: $text-white;
line-height: 1;
}
}
.recap-progress-bar {
width: 100%;
border-radius: 2px;
height: 4px !important;
.MuiLinearProgress-bar { background-color: $text-white !important; }
&.MuiLinearProgress-root { background-color: rgba(255, 255, 255, 0.3) !important; }
}
.recap-month-section {
margin-bottom: 2.5rem;
}
.recap-month-label {
color: $text-gray;
font-size: 0.8rem !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1rem !important;
padding-bottom: 0.5rem;
border-bottom: 1px solid $border-color;
}
.recap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.recap-card {
cursor: pointer;
border-radius: 4px;
overflow: hidden;
&:hover .recap-overlay { opacity: 1; }
&:hover .recap-poster { transform: scale(1.04); }
}
.recap-poster-wrapper {
position: relative;
padding-top: 150%;
overflow: hidden;
border-radius: 4px;
background-color: #14181c;
}
.recap-poster {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.recap-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.15) 55%, transparent 100%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.recap-movie-title {
color: $text-white;
font-size: 0.68rem;
font-weight: 600;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recap-movie-year {
color: rgba(255, 255, 255, 0.55);
font-size: 0.62rem;
margin-top: 2px;
}
.recap-check-badge {
position: absolute;
top: 6px;
right: 6px;
background-color: $primary-color;
color: $text-white;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.recap-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 1.5rem;
color: $text-gray;
.MuiTypography-root { color: $text-gray; }
}
.recap-go-btn {
background-color: $primary-color !important;
color: $text-white !important;
text-transform: none !important;
}
}

View File

@@ -1,12 +1,36 @@
.upload-diary {
padding: 3rem;
.upload-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #14181c;
min-height: calc(100vh - 60px);
}
.upload-diary {
flex: 1;
padding: 3rem;
padding-top: 5rem;
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
padding: 1.25rem;
padding-top: 4.5rem;
justify-content: flex-start;
}
.upload-card-wrapper {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
min-height: calc(100svh - 80px);
}
}
.upload-card {
width: 100%;
max-width: 600px;
@@ -17,6 +41,11 @@
padding-top: 4rem;
position: relative;
@media (max-width: 600px) {
padding: 1.25rem;
padding-top: 3rem;
}
.logo-container {
position: absolute;
top: -80px;
@@ -24,11 +53,20 @@
transform: translate(-50%);
z-index: 10;
@media (max-width: 600px) {
top: -55px;
}
.logo {
width: 150px;
height: 150px;
border-radius: 50%;
object-fit: cover;
@media (max-width: 600px) {
width: 100px;
height: 100px;
}
}
}
@@ -36,35 +74,11 @@
margin-bottom: 1.5rem;
font-weight: bold;
color: #ffffff;
}
.username-field {
margin-bottom: 1.5rem;
.MuiOutlinedInput-root {
color: #ffffff;
fieldset {
border-color: #00a346 !important;
}
&:hover fieldset {
border-color: #00a346 !important;
}
&.Mui-focused fieldset {
border-color: #1caff2 !important;
}
@media (max-width: 600px) {
font-size: 1.4rem !important;
margin-bottom: 1rem;
}
.MuiInputLabel-root {
color: #ffffff !important;
}
}
.or-text {
color: #ffffff;
margin-bottom: 1rem;
}
.dropzone {
@@ -74,6 +88,10 @@
text-align: center;
cursor: pointer;
@media (max-width: 600px) {
padding: 1.25rem;
}
&:hover {
background-color: #1f252a;
}
@@ -82,11 +100,20 @@
margin-bottom: 1rem;
color: #00a346;
font-size: 4rem;
@media (max-width: 600px) {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
}
.dropzone-text {
margin-bottom: 1rem;
color: #ffffff;
@media (max-width: 600px) {
font-size: 0.9rem !important;
}
}
}
@@ -94,6 +121,101 @@
margin-top: 1rem;
}
.upload-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.75rem 0 1.25rem;
@media (max-width: 600px) {
margin: 1.25rem 0 1rem;
}
}
.upload-divider-line {
flex: 1;
height: 1px;
background-color: #2c3038;
}
.upload-divider-text {
color: #526e89;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.rss-label {
color: #ffffff !important;
font-size: 0.9rem !important;
font-weight: 600 !important;
margin-bottom: 0.75rem !important;
}
.rss-row {
display: flex;
gap: 8px;
align-items: center;
}
.rss-input {
flex: 1;
min-width: 0;
.MuiOutlinedInput-root {
background-color: #14181c;
border-radius: 4px;
color: #ffffff;
font-size: 0.875rem;
fieldset { border-color: #2c3038; }
&:hover fieldset { border-color: #526e89; }
&.Mui-focused fieldset { border-color: #00a346; }
input { padding: 8px 10px 8px 4px; color: #ffffff; }
input::placeholder { color: #526e89; }
}
}
.rss-url-prefix {
color: #526e89;
font-size: 0.8rem;
white-space: nowrap;
user-select: none;
@media (max-width: 400px) {
display: none;
}
}
.rss-sync-button {
background-color: #00a346 !important;
color: #ffffff !important;
font-weight: 700 !important;
text-transform: none !important;
padding: 7px 16px !important;
border-radius: 4px !important;
white-space: nowrap;
font-size: 0.875rem !important;
&:hover { background-color: #008a39 !important; }
&:disabled { opacity: 0.6 !important; }
}
.rss-note {
color: #526e89 !important;
font-size: 0.72rem !important;
margin-top: 0.5rem !important;
line-height: 1.4 !important;
}
.rss-error {
color: #e57373 !important;
font-size: 0.78rem !important;
margin-top: 0.4rem !important;
}
.submit-button {
color: #fff;
font-size: 0.8rem;
@@ -125,6 +247,15 @@
color: #456;
margin-top: 2rem;
padding: 1.5rem;
max-width: 600px;
width: 100%;
@media (max-width: 600px) {
padding: 1rem 0.25rem;
margin-top: 1.25rem;
font-size: 0.85rem !important;
// display: block;
}
}
}
@@ -142,7 +273,7 @@
.link {
text-decoration: none;
color: inherit
color: inherit;
}
}
}