Compare commits

..

10 Commits

Author SHA1 Message Date
0dbcdcfcf4 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>
2026-04-26 17:24:38 +02:00
569e904ddd improve cart arrangement 2025-11-28 17:11:25 +01:00
37f7ccbb12 improve first page with logo and footer 2025-11-09 18:07:19 +01:00
88abed086f Augment timeout for requests in proxy 2025-10-06 18:46:13 +02:00
f0fbd9b113 custom nginx for proxi 2025-09-28 17:11:18 +02:00
45f58fc158 Change public url to / 2025-09-28 15:28:13 +02:00
5917a35da5 oups le build 2025-09-26 16:02:26 +02:00
9f3d4aecca optimize RAM image generation 2025-09-26 15:07:56 +02:00
4258c3ad61 Dockerify 2025-09-25 16:21:51 +02:00
ebd6a9d3c1 Improve session managment and cart section 2025-09-24 17:47:55 +02:00
35 changed files with 3253 additions and 1423 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
.env

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* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
*storybook.log
storybook-static

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# frontend/Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm ci --silent --legacy-peer-deps
COPY . .
ARG REACT_APP_API_URL=""
ARG PUBLIC_URL=/
ENV REACT_APP_API_URL=${REACT_APP_API_URL}
ENV PUBLIC_URL=${PUBLIC_URL}
RUN npm run build
# nginx pour servir les fichiers statiques
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/default.conf
# Expose port 80 (Caddy fera reverse proxy)
EXPOSE 80
# Utilisateur par défaut (nginx image gère user)
CMD ["nginx", "-g", "daemon off;"]

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

31
nginx-spa.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API to backend container
location /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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for requests
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Augmenter les tailles de buffer si nécessaire
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

865
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "poster-picker",
"version": "0.1.0", "version": "1.0.0",
"homepage": "https://hugyouu.github.io/Letterboxd-Diary-Posters-Picker", "homepage": "/",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
@@ -14,6 +14,7 @@
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^1.7.6", "axios": "^1.7.6",
"jszip": "^3.10.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"material-ui-dropzone": "^3.5.0", "material-ui-dropzone": "^3.5.0",
"query-string": "^9.1.0", "query-string": "^9.1.0",

13
public/icon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4096 4096" width="4096" height="4096">
<style>
.s0 { fill: #ff8000 }
.s1 { fill: #40bcf4 }
.s2 { fill: #00e054 }
.s3 { fill: #ffffff }
</style>
<path id="Forme 2 copy 3" fill-rule="evenodd" class="s0" d="m452.08 1293.69l678.89-145.27c163.64-35.01 324.68 69.26 359.69 232.9l282.79 1321.57c35.02 163.64-69.25 324.68-232.89 359.69l-678.9 145.27c-163.64 35.02-324.68-69.25-359.69-232.89l-282.79-1321.58c-35.01-163.63 69.26-324.67 232.9-359.69z"/>
<path id="Forme 2 copy 4" fill-rule="evenodd" class="s1" d="m2964.99 1139.1l679.01 144.72c163.66 34.89 268.06 195.84 233.17 359.51l-281.73 1321.79c-34.88 163.66-195.84 268.06-359.51 233.18l-679-144.73c-163.67-34.88-268.07-195.84-233.18-359.51l281.73-1321.78c34.89-163.67 195.84-268.07 359.51-233.18z"/>
<path id="Forme 2 copy 5" fill-rule="evenodd" class="s2" d="m1717.76 892.06h694.26c167.34 0 303 135.66 303 303v1351.48c0 167.34-135.66 303-303 303h-694.26c-167.34 0-303-135.66-303-303v-1351.48c0-167.34 135.66-303 303-303z"/>
<path id="Forme 2 copy 2" fill-rule="evenodd" class="s3" d="m2323.75 2694.06l281.73-1321.78c15.43-72.36 55.49-133.14 109.54-175.41v1349.67c0 167.34-135.66 303-303 303h-80.58c-15.61-48.65-19.11-101.92-7.69-155.48z"/>
<path id="Forme 2" fill-rule="evenodd" class="s3" d="m1490.66 1381.32l282.79 1321.57c10.77 50.33 8.36 100.41-4.89 146.65h-50.8c-167.34 0-303-135.66-303-303v-1309.82c36.88 38.99 63.85 88.28 75.9 144.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Poster Picker</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,33 +1,36 @@
import React from "react";
import "./styles/App.scss"; 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 { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react"; import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./services/store"; import { store, persistor } from "./services/store";
import PosterSelector from "./components/PosterSelector"; import Header from "./components/Header";
import PosterGallery from "./components/PosterGallery"; import PosterSelector from "./pages/PosterSelector";
import UploadDiary from "./components/UploadDiary"; import PosterGallery from "./pages/PosterGallery";
import Cart from "./components/Cart"; 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() { function App() {
return ( return (
<Provider store={store}> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
{
<Router> <Router>
<div className="App"> <AppContent />
<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>
}
</PersistGate> </PersistGate>
</Provider> </Provider>
); );

View File

@@ -1,125 +0,0 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {removeAllPosters, removePoster} from "../services/action";
import DeleteIcon from "@material-ui/icons/Delete";
const apiUrl = process.env.REACT_APP_API_URL;
const Cart = () => {
const dispatch = useDispatch();
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");
const handleRemovePoster = (movieId, posterId) => {
if (movieId && posterId) dispatch(removePoster(movieId, posterId));
};
const handleRemoveAllPosters = () => {
dispatch(removeAllPosters());
};
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 (
<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>
);
};
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,77 +0,0 @@
import React, { useMemo } from "react";
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 selectPosterSelections = (state) => state.posterSelections;
const NavBar = ({ onRefreshFiles }) => {
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 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={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>
);
};
export default NavBar;

View File

@@ -1,164 +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";
import matchers from "@testing-library/jest-dom/matchers";
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 handlePosterSelect = (posterId) => {
const postersForCurrentMovie = selectedPosters.filter(
(poster) => poster.movieId !== movieId
);
const isPosterSelected = postersForCurrentMovie.some((poster) => poster.posterId === posterId)
if (postersForCurrentMovie.length > 0 && !isPosterSelected) {
setDialogOpen(true);
return;
}
if (isPosterSelected) {
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) => (
<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>
))}
</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,264 +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).then((r) => console.log(r));
}, [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}`,
{
// onDownloadProgress: (progressEvent) => {
// const percentCompleted = Math.round(
// (progressEvent.loaded * 100) / progressEvent.total
// );
// setProgress(percentCompleted);
// },
}
);
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 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}`);
};
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.file_path}` ||
`/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 onRefreshFiles={handleRefreshFiles} />
</>
);
};
export default PosterSelector;

View File

@@ -1,146 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
Container,
Grid,
Typography,
Button,
CircularProgress,
TextField,
} 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 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`);
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) {
console.log(`Fetching diary for user: ${username}`);
await new Promise((resolve) => setTimeout(resolve, 1000));
navigate("/PosterSelector");
} 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",
},
});
navigate("/PosterSelector");
}
} catch (error) {
console.error("Error processing diary:", error);
} finally {
setUploading(false);
}
}
};
return (
<Container className="upload-diary">
<div className="upload-card">
<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)}
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>
);
};
export default UploadDiary;

View File

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

View File

@@ -1,7 +1,22 @@
import axios from "axios"; 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`); const api = axios.create({
export const downloadPoster = (movieId) => baseURL: process.env.REACT_APP_API_URL,
axios.post(`${API_BASE_URL}/download-poster`, { movie_id: movieId }); 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 { legacy_createStore as createStore, combineReducers } from "redux";
import { persistStore, persistReducer } from "redux-persist"; import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage"; 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) => { const posterSelectionReducer = (state = {}, action) => {
switch (action.type) { switch (action.type) {
@@ -9,7 +9,7 @@ const posterSelectionReducer = (state = {}, action) => {
const { movieId, posterId, watchedDate } = action.payload; const { movieId, posterId, watchedDate } = action.payload;
return { return {
...state, ...state,
[movieId]: [...(state[movieId] || []), { posterId, watchedDate }], [movieId]: [{ posterId, watchedDate }],
}; };
} }
case DESELECT_POSTER: { case DESELECT_POSTER: {
@@ -17,7 +17,7 @@ const posterSelectionReducer = (state = {}, action) => {
return { return {
...state, ...state,
[movieId]: state[movieId]?.filter( [movieId]: state[movieId]?.filter(
(id) => id !== posterId (poster) => poster.posterId !== posterId
), ),
}; };
} }
@@ -26,7 +26,7 @@ const posterSelectionReducer = (state = {}, action) => {
return { return {
...state, ...state,
[movieId]: state[movieId]?.filter( [movieId]: state[movieId]?.filter(
(id) => id !== posterId (poster) => poster.posterId !== posterId
), ),
}; };
} }
@@ -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({ const rootReducer = combineReducers({
posterSelections: posterSelectionReducer, posterSelections: posterSelectionReducer,
// other reducers... username: usernameReducer,
dataSource: dataSourceReducer,
}); });
const persistConfig = { const persistConfig = {

View File

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

View File

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

View File

@@ -1,32 +1,206 @@
// _navbar.scss @use "sass:color";
// Variables de couleur et autres constantes
$background-color: #1c1f23; $background-color: #1c1f23;
$icon-color: white; $primary-color: #00a346;
$margin-right: 8px; // Correspondant à theme.spacing(2) $text-white: #ffffff;
$text-gray: #667788;
.navbar { .app-header {
top: auto !important;
bottom: 0;
background-color: $background-color !important; background-color: $background-color !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) !important;
.toolbar { .header-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding: 0 1.5rem;
} }
.back-button, .header-left {
.cart-button, display: flex;
.refresh-button { align-items: center;
color: $icon-color; gap: 10px;
cursor: pointer;
flex-shrink: 0;
&:hover .header-title {
color: $primary-color;
}
} }
.refresh-button { .header-logo {
margin-right: $margin-right; width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
} }
.back-button[disabled] { .header-title {
opacity: 0.3; color: $text-white;
pointer-events: none; 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; $loading-height: 50vh;
.poster-gallery { .poster-gallery {
margin-top: 2rem; margin-top: 5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
.content-paper { .content-paper {
@@ -15,6 +15,49 @@ $loading-height: 50vh;
background-color: $background-dark; 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 { .title {
color: $text-white; color: $text-white;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@@ -24,30 +67,19 @@ $loading-height: 50vh;
.poster-card { .poster-card {
position: relative; position: relative;
height: 100%;
display: flex;
flex-direction: column;
background-color: transparent; background-color: transparent;
box-shadow: none; box-shadow: none;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: transform 0.3s ease;
border-radius: 4px;
overflow: hidden;
&:hover { &:hover {
transform: scale($hover-scale); transform: scale($hover-scale);
} }
&.selected-poster { &.selected-poster {
border: 4px solid $primary-color; outline: 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;
} }
.check-icon { .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 { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -1,133 +1,314 @@
// Variables @use "sass:color";
$primary-color: #00a346; $primary-color: #00a346;
$secondary-color: #667788; $secondary-color: #667788;
$background-dark: #1c1f23; $background-dark: #1c1f23;
$text-white: #ffffff; $text-white: #ffffff;
$text-gray: #667788; $text-gray: #667788;
$border-color: #667788; $border-color: #2c3038;
$hover-color: rgba(102, 119, 136, 0.2); $hover-color: rgba(102, 119, 136, 0.2);
.poster-selector { .poster-selector {
padding: 0 2rem; margin-top: 5rem;
max-width: 100% !important; // Override MUI Container margin-bottom: 2rem;
margin: 2rem auto 4rem !important;
.content-paper { .content-paper {
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: 4px;
background-color: $background-dark !important; background-color: $background-dark;
max-width: calc((120vh - 340px) * 7 / 9 + 3rem);
margin: 0 auto;
} }
.title { .title {
margin-bottom: 1.5rem; margin-bottom: 1rem;
color: $text-white; color: $text-white;
} }
.username { // Calendar navigation
color: $primary-color; .calendar-nav {
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;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; 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 { &:hover {
background-color: $hover-color; background-color: $hover-color !important;
}
} }
&:not(:last-child) { .calendar-month-label {
border-bottom: 1px solid $border-color; color: $text-white;
padding-top: 0.25rem; min-width: 160px;
padding-bottom: 0.25rem; text-align: center;
} }
// Override MUI ListItem styles .calendar-today-btn {
&.MuiListItem-root { color: $text-gray !important;
padding: 0.5rem !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;
}
} }
.MuiGrid-container { .calendar-month-selects {
display: flex; display: flex;
justify-content: space-between; align-items: center;
width: 100%; gap: 4px;
border: 1px solid $border-color;
border-radius: 4px;
padding: 0 4px;
&:hover {
border-color: $text-gray;
}
} }
.movie-poster { .calendar-select {
width: 50px; color: $text-gray !important;
height: 75px; 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;
}
}
}
.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; object-fit: cover;
margin-right: 1rem; 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; border-radius: 4px;
} }
.movie-info { // Shared states
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;
}
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 1.5rem;
// Override MUI Pagination styles
.MuiPagination-root {
.MuiPaginationItem-root {
color: $text-white;
&.Mui-selected {
background-color: $secondary-color;
}
}
}
}
.progress-container { .progress-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -147,12 +328,23 @@ $hover-color: rgba(102, 119, 136, 0.2);
} }
.no-movies-container { .no-movies-container {
position: absolute;
inset: 0;
z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 400px;
color: $text-white; 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 { .gif-container {
@@ -188,7 +380,146 @@ $hover-color: rgba(102, 119, 136, 0.2);
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
&:hover { &:hover {
background-color: darken($primary-color, 10%) !important; background-color: color.scale($primary-color, $lightness: 10%) !important;
} }
} }
} }
// 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 { .upload-page {
padding: 3rem;
background-color: #14181c;
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #14181c;
}
.upload-diary {
flex: 1;
padding: 3rem;
padding-top: 5rem;
display: flex !important; display: flex !important;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: 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 { .upload-card {
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
@@ -14,41 +38,48 @@
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
padding: 2rem; padding: 2rem;
padding-top: 4rem;
position: relative;
@media (max-width: 600px) {
padding: 1.25rem;
padding-top: 3rem;
}
.logo-container {
position: absolute;
top: -80px;
left: 50%;
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;
}
}
}
.title { .title {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
}
.username-field { @media (max-width: 600px) {
margin-bottom: 1.5rem; font-size: 1.4rem !important;
.MuiOutlinedInput-root {
color: #ffffff;
fieldset {
border-color: #00a346 !important;
}
&:hover fieldset {
border-color: #00a346 !important;
}
&.Mui-focused fieldset {
border-color: #1caff2 !important;
}
}
.MuiInputLabel-root {
color: #ffffff !important;
}
}
.or-text {
color: #ffffff;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
}
.dropzone { .dropzone {
border: 2px dashed #00a346; border: 2px dashed #00a346;
@@ -57,6 +88,10 @@
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
@media (max-width: 600px) {
padding: 1.25rem;
}
&:hover { &:hover {
background-color: #1f252a; background-color: #1f252a;
} }
@@ -65,11 +100,20 @@
margin-bottom: 1rem; margin-bottom: 1rem;
color: #00a346; color: #00a346;
font-size: 4rem; font-size: 4rem;
@media (max-width: 600px) {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
} }
.dropzone-text { .dropzone-text {
margin-bottom: 1rem; margin-bottom: 1rem;
color: #ffffff; color: #ffffff;
@media (max-width: 600px) {
font-size: 0.9rem !important;
}
} }
} }
@@ -77,6 +121,101 @@
margin-top: 1rem; 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 { .submit-button {
color: #fff; color: #fff;
font-size: 0.8rem; font-size: 0.8rem;
@@ -108,5 +247,33 @@
color: #456; color: #456;
margin-top: 2rem; margin-top: 2rem;
padding: 1.5rem; 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;
}
}
}
.footer {
padding: 20px;
text-align: center;
color: #526e89;
.footer-text {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
.link {
text-decoration: none;
color: inherit;
}
} }
} }