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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/project.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

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;
}
}

867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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">
<head>
<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="theme-color" content="#000000" />
<meta
@@ -24,7 +24,7 @@
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`.
-->
<title>React App</title>
<title>Poster Picker</title>
</head>
<body>
<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 { HashRouter as Router, Route, Routes } from "react-router-dom";
import { HashRouter as Router, Route, Routes, useLocation } from "react-router-dom";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./services/store";
import PosterSelector from "./components/PosterSelector";
import PosterGallery from "./components/PosterGallery";
import UploadDiary from "./components/UploadDiary";
import Cart from "./components/Cart";
import Header from "./components/Header";
import PosterSelector from "./pages/PosterSelector";
import PosterGallery from "./pages/PosterGallery";
import UploadDiary from "./pages/UploadDiary";
import SelectionRecap from "./pages/SelectionRecap";
function AppContent() {
const location = useLocation();
return (
<div className="App">
{location.pathname !== "/" && <Header />}
<Routes>
<Route path="/" element={<UploadDiary />} />
<Route path="/PosterSelector" element={<PosterSelector />} />
<Route path="/posters/:movieName/:movieYear" element={<PosterGallery />} />
<Route path="/recap" element={<SelectionRecap />} />
</Routes>
</div>
);
}
function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{
<Router>
<div className="App">
<Routes>
<Route path="/" element={<UploadDiary />} />
<Route path="/PosterSelector" element={<PosterSelector />} />
<Route
path="/posters/:movieName/:movieYear"
element={<PosterGallery />}
/>
<Route path="/Cart" element={<Cart />} />
</Routes>
</div>
</Router>
}
<Router>
<AppContent />
</Router>
</PersistGate>
</Provider>
);

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,18 +3,23 @@
padding: 2rem;
display: flex;
gap: 2rem;
align-items: flex-start;
box-sizing: border-box;
width: 100%;
.poster-grid {
flex: 1;
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
justify-content: center;
gap: 1rem;
max-height: calc(100vh - 4rem);
padding-right: 1rem;
overflow: visible;
.poster-card {
position: relative;
aspect-ratio: 2/3;
min-height: 220px;
&:hover .delete-overlay {
opacity: 1;
@@ -23,6 +28,9 @@
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
display: block;
}
.delete-overlay {
@@ -34,56 +42,113 @@
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 8px;
.delete-button {
background-color: #e74c3c;
border: none;
color: white;
padding: 0.5rem;
padding: 0.65rem;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
transform: scale(1.1);
&:hover,
&:focus {
transform: scale(1.05);
outline: none;
}
}
}
}
.empty-poster {
width: 100%;
height: 100%;
background-color: #f5f6fa10;
border: 2px #95a5a6;
.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);
}
// État vide simple
.empty-cart-container {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #7f8c8d;
min-height: 60vh;
text-align: center;
svg {
margin-bottom: 1rem;
color: #95a5a6;
.MuiTypography-body1 {
color: #7f8c8d;
margin-bottom: 2rem;
max-width: 400px;
}
p {
font-size: 0.875rem;
text-align: center;
.back-button {
color: #fff;
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 2.4rem;
display: inline-block;
cursor: pointer;
padding: 0 1rem;
border: 0;
border-radius: 4px;
outline: none;
background: #526e89;
transition: background-color 0.3s ease;
min-width: 160px;
&:hover {
background-color: #1caff2;
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
}
}
}
.action-panel {
width: 300px;
flex: 0 0 320px;
max-width: 320px;
position: sticky;
top: 2rem;
background-color: #34495e;
border-radius: 8px;
padding: 1.5rem;
height: fit-content;
box-sizing: border-box;
align-self: flex-start;
h2 {
color: white;
@@ -94,32 +159,173 @@
.format-select {
width: 100%;
margin-bottom: 1.5rem;
padding: 0.5rem;
padding: 0.6rem;
background-color: #2c3e50;
border: 1px solid #95a5a6;
color: white;
border-radius: 4px;
appearance: none;
}
.download-button, .clear-button {
.download-button,
.clear-button {
width: 100%;
padding: 0.75rem;
padding: 0.85rem;
background-color: #00a346;
color: white;
border: none;
border-radius: 4px;
border-radius: 6px;
margin-bottom: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color 0.2s ease, transform 0.08s ease;
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
&:hover {
background-color: darken(#00a346, 10%);
&:active {
transform: translateY(1px);
}
&:hover:not(:disabled) {
filter: brightness(0.95);
}
&:disabled {
background-color: darken(#00a346, 20%);
background-color: #7f8c8d;
cursor: not-allowed;
}
}
.clear-button {
background-color: #e74c3c;
&:hover:not(:disabled) {
filter: brightness(0.95);
}
}
}
}
/* Tablette */
@media (max-width: 992px) {
.cart-container {
padding: 1.5rem;
gap: 1rem;
.poster-grid {
grid-template-columns: repeat(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;
$icon-color: white;
$margin-right: 8px; // Correspondant à theme.spacing(2)
$primary-color: #00a346;
$text-white: #ffffff;
$text-gray: #667788;
.navbar {
top: auto !important;
bottom: 0;
.app-header {
background-color: $background-color !important;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) !important;
.toolbar {
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem;
}
.back-button,
.cart-button,
.refresh-button {
color: $icon-color;
.header-left {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
flex-shrink: 0;
&:hover .header-title {
color: $primary-color;
}
}
.refresh-button {
margin-right: $margin-right;
.header-logo {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.back-button[disabled] {
opacity: 0.3;
pointer-events: none;
.header-title {
color: $text-white;
font-weight: 700;
letter-spacing: 0.02em;
transition: color 0.15s ease;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 40%;
}
.header-movie-name {
color: $text-white;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-username {
color: $text-gray;
font-size: 0.85rem !important;
letter-spacing: 0.03em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::before {
content: '@';
opacity: 0.6;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-menu-btn {
display: none !important;
color: $text-gray !important;
&:hover { color: $text-white !important; }
}
@media (max-width: 640px) {
.header-actions { display: none !important; }
.header-menu-btn { display: flex !important; }
.header-title { font-size: 1rem !important; }
.header-center { display: none; }
}
.header-reset-btn {
color: $text-gray !important;
&:hover {
color: $text-white !important;
}
}
.header-recap-btn {
color: $text-gray !important;
&:hover {
color: $text-white !important;
}
}
.header-sync-btn {
color: $text-gray !important;
&:hover { color: $text-white !important; }
&--active { color: $primary-color !important; }
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.header-download-btn {
background-color: $primary-color !important;
color: $text-white !important;
font-weight: 600;
padding: 0.4rem 1rem;
border-radius: 4px;
text-transform: none;
font-size: 0.875rem;
&:hover {
background-color: color.adjust($primary-color, $lightness: -8%) !important;
}
&--loading {
background-color: color.adjust($primary-color, $lightness: -12%) !important;
cursor: default !important;
min-width: 140px;
}
}
.header-download-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 120px;
span {
font-size: 0.8rem;
font-weight: 600;
color: $text-white;
line-height: 1;
}
}
.header-progress-bar {
width: 100%;
border-radius: 2px;
height: 4px !important;
.MuiLinearProgress-bar {
background-color: $text-white !important;
}
&.MuiLinearProgress-root {
background-color: rgba(255, 255, 255, 0.3) !important;
}
}
}
// Menu mobile (portail MUI, hors de .app-header)
.header-menu-paper {
background-color: #1c1f23 !important;
border: 1px solid #2c3038 !important;
border-radius: 6px !important;
min-width: 200px;
.MuiList-root { padding: 4px 0; }
}
.header-menu-item {
color: #ffffff !important;
font-size: 0.875rem !important;
gap: 10px;
padding: 10px 16px !important;
&:hover { background-color: rgba(102, 119, 136, 0.2) !important; }
&.Mui-disabled { opacity: 0.4 !important; color: #ffffff !important; }
&--danger {
color: #ef9a9a !important;
margin-top: 4px;
border-top: 1px solid #2c3038;
}
}
.header-menu-icon {
opacity: 0.6;
flex-shrink: 0;
}

View File

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

View File

@@ -1,133 +1,314 @@
// Variables
@use "sass:color";
$primary-color: #00a346;
$secondary-color: #667788;
$background-dark: #1c1f23;
$text-white: #ffffff;
$text-gray: #667788;
$border-color: #667788;
$border-color: #2c3038;
$hover-color: rgba(102, 119, 136, 0.2);
.poster-selector {
padding: 0 2rem;
max-width: 100% !important; // Override MUI Container
margin: 2rem auto 4rem !important;
margin-top: 5rem;
margin-bottom: 2rem;
.content-paper {
padding: 1.5rem;
border-radius: 4px;
background-color: $background-dark !important;
background-color: $background-dark;
max-width: calc((120vh - 340px) * 7 / 9 + 3rem);
margin: 0 auto;
}
.title {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
color: $text-white;
}
.username {
color: $primary-color;
margin-bottom: 1rem;
}
.movies-list {
max-height: 800px;
min-height: 900px;
padding: 0;
}
.movie-item {
padding: 0 2rem;
margin-bottom: 1rem;
transition: background-color 0.3s ease;
// Calendar navigation
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
margin-bottom: 1.5rem;
}
.calendar-nav-center {
display: flex;
align-items: center;
gap: 0.25rem;
}
.calendar-nav-btn {
color: $text-white !important;
&:hover {
background-color: $hover-color;
}
&:not(:last-child) {
border-bottom: 1px solid $border-color;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
// Override MUI ListItem styles
&.MuiListItem-root {
padding: 0.5rem !important;
}
.MuiGrid-container {
display: flex;
justify-content: space-between;
width: 100%;
}
.movie-poster {
width: 50px;
height: 75px;
object-fit: cover;
margin-right: 1rem;
border-radius: 4px;
}
.movie-info {
flex-grow: 1;
max-width: calc(100% - 110px);
}
.movie-title {
color: $text-white;
font-family: "TiemposTextWeb-Semibold, Georgia, serif";
font-size: 1.38461538rem;
font-weight: 400;
transition: color 0.3s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: $primary-color;
}
}
.movie-year {
color: $text-gray;
}
.watched-date {
color: $text-gray;
}
.watched-day {
font-size: 2rem;
line-height: 1;
margin-bottom: 0.25rem;
}
.watched-month {
font-size: 0.875rem;
background-color: $hover-color !important;
}
}
.pagination-container {
.calendar-month-label {
color: $text-white;
min-width: 160px;
text-align: center;
}
.calendar-today-btn {
color: $text-gray !important;
font-size: 0.75rem !important;
text-transform: none !important;
padding: 2px 8px !important;
border: 1px solid $text-gray !important;
border-radius: 4px !important;
min-width: unset !important;
&:hover {
color: $text-white !important;
border-color: $text-white !important;
}
}
.calendar-month-selects {
display: flex;
justify-content: center;
margin-top: 1.5rem;
align-items: center;
gap: 4px;
border: 1px solid $border-color;
border-radius: 4px;
padding: 0 4px;
// Override MUI Pagination styles
.MuiPagination-root {
.MuiPaginationItem-root {
&:hover {
border-color: $text-gray;
}
}
.calendar-select {
color: $text-gray !important;
font-size: 0.8rem !important;
.MuiSelect-root {
padding: 4px 24px 4px 6px !important;
}
.MuiSelect-icon {
color: $text-gray;
}
&:hover {
color: $text-white !important;
}
}
.calendar-select-menu {
background-color: #1c1f23 !important;
color: $text-white !important;
border: 1px solid $border-color;
.MuiMenuItem-root {
font-size: 0.85rem;
color: $text-gray;
&:hover, &.Mui-selected {
background-color: rgba(102, 119, 136, 0.2) !important;
color: $text-white;
&.Mui-selected {
background-color: $secondary-color;
}
}
}
}
.rss-notice {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: $text-gray;
background-color: rgba(102, 119, 136, 0.08);
border: 1px solid rgba(102, 119, 136, 0.2);
border-radius: 4px;
padding: 6px 10px;
margin-bottom: 1rem;
}
.rss-notice-icon {
font-size: 0.8rem;
flex-shrink: 0;
}
.rss-notice-link {
color: $primary-color;
cursor: pointer;
text-decoration: underline;
&:hover { color: color.scale($primary-color, $lightness: 15%); }
}
// Calendar grid
.calendar-grid-wrapper {
position: relative;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
max-width: calc((120vh - 340px) * 7 / 9);
min-width: 280px;
margin: 0 auto;
}
.calendar-day-header {
color: $text-gray;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
text-transform: uppercase;
padding: 0.25rem 0;
letter-spacing: 0.05em;
}
.calendar-cell {
position: relative;
border-radius: 4px;
border: 1px solid $border-color;
overflow: hidden;
background-color: #14181c;
aspect-ratio: 2/3;
min-height: 14vh;
&--empty {
border-color: transparent;
background-color: transparent;
}
&--future {
background-color: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.04);
.calendar-day-number {
opacity: 0.25;
}
}
&--today {
border-color: $secondary-color;
.calendar-day-number {
color: $text-white;
font-weight: 700;
}
}
&--has-movie {
cursor: pointer;
border-color: transparent;
&:hover .calendar-poster-overlay {
opacity: 1;
}
&:hover .calendar-day-number {
color: $text-white;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
}
}
.calendar-day-number {
position: absolute;
top: 4px;
left: 6px;
font-size: 0.7rem;
color: $text-gray;
z-index: 2;
line-height: 1;
}
.calendar-poster-wrapper {
position: absolute;
inset: 0;
}
.calendar-cell-skeleton {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #1c1f23 25%, #252930 50%, #1c1f23 75%);
background-size: 200% 100%;
animation: calendar-shimmer 1.4s infinite;
}
@keyframes calendar-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.calendar-poster {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.calendar-poster-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.2) 60%, transparent 100%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 6px;
opacity: 0;
transition: opacity 0.2s ease;
}
.calendar-movie-title {
color: $text-white;
font-size: 0.65rem;
font-weight: 600;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.calendar-movie-year {
color: $text-gray;
font-size: 0.6rem;
margin-top: 2px;
}
.calendar-more-badge {
position: absolute;
top: 4px;
right: 4px;
background-color: $primary-color;
color: $text-white;
font-size: 0.6rem;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
z-index: 2;
}
.calendar-selected-badge {
position: absolute;
bottom: 4px;
right: 4px;
background-color: $primary-color;
color: $text-white;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.calendar-cell--selected {
box-shadow: 0 0 0 2px $primary-color;
border-radius: 4px;
}
// Shared states
.progress-container {
display: flex;
justify-content: center;
@@ -147,12 +328,23 @@ $hover-color: rgba(102, 119, 136, 0.2);
}
.no-movies-container {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: $text-white;
background: rgba(28, 31, 35, 0.7);
backdrop-filter: blur(6px);
border-radius: 4px;
animation: fade-in 0.3s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.gif-container {
@@ -188,7 +380,146 @@ $hover-color: rgba(102, 119, 136, 0.2);
transition: background-color 0.3s ease;
&: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 {
padding: 3rem;
background-color: #14181c;
.upload-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #14181c;
}
.upload-diary {
flex: 1;
padding: 3rem;
padding-top: 5rem;
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
padding: 1.25rem;
padding-top: 4.5rem;
justify-content: flex-start;
}
.upload-card-wrapper {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
min-height: calc(100svh - 80px);
}
}
.upload-card {
width: 100%;
max-width: 600px;
@@ -14,40 +38,47 @@
border-radius: 4px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
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 {
margin-bottom: 1.5rem;
font-weight: bold;
color: #ffffff;
}
.username-field {
margin-bottom: 1.5rem;
.MuiOutlinedInput-root {
color: #ffffff;
fieldset {
border-color: #00a346 !important;
}
&:hover fieldset {
border-color: #00a346 !important;
}
&.Mui-focused fieldset {
border-color: #1caff2 !important;
}
@media (max-width: 600px) {
font-size: 1.4rem !important;
margin-bottom: 1rem;
}
.MuiInputLabel-root {
color: #ffffff !important;
}
}
.or-text {
color: #ffffff;
margin-bottom: 1rem;
}
.dropzone {
@@ -57,6 +88,10 @@
text-align: center;
cursor: pointer;
@media (max-width: 600px) {
padding: 1.25rem;
}
&:hover {
background-color: #1f252a;
}
@@ -65,11 +100,20 @@
margin-bottom: 1rem;
color: #00a346;
font-size: 4rem;
@media (max-width: 600px) {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
}
.dropzone-text {
margin-bottom: 1rem;
color: #ffffff;
@media (max-width: 600px) {
font-size: 0.9rem !important;
}
}
}
@@ -77,6 +121,101 @@
margin-top: 1rem;
}
.upload-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.75rem 0 1.25rem;
@media (max-width: 600px) {
margin: 1.25rem 0 1rem;
}
}
.upload-divider-line {
flex: 1;
height: 1px;
background-color: #2c3038;
}
.upload-divider-text {
color: #526e89;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.rss-label {
color: #ffffff !important;
font-size: 0.9rem !important;
font-weight: 600 !important;
margin-bottom: 0.75rem !important;
}
.rss-row {
display: flex;
gap: 8px;
align-items: center;
}
.rss-input {
flex: 1;
min-width: 0;
.MuiOutlinedInput-root {
background-color: #14181c;
border-radius: 4px;
color: #ffffff;
font-size: 0.875rem;
fieldset { border-color: #2c3038; }
&:hover fieldset { border-color: #526e89; }
&.Mui-focused fieldset { border-color: #00a346; }
input { padding: 8px 10px 8px 4px; color: #ffffff; }
input::placeholder { color: #526e89; }
}
}
.rss-url-prefix {
color: #526e89;
font-size: 0.8rem;
white-space: nowrap;
user-select: none;
@media (max-width: 400px) {
display: none;
}
}
.rss-sync-button {
background-color: #00a346 !important;
color: #ffffff !important;
font-weight: 700 !important;
text-transform: none !important;
padding: 7px 16px !important;
border-radius: 4px !important;
white-space: nowrap;
font-size: 0.875rem !important;
&:hover { background-color: #008a39 !important; }
&:disabled { opacity: 0.6 !important; }
}
.rss-note {
color: #526e89 !important;
font-size: 0.72rem !important;
margin-top: 0.5rem !important;
line-height: 1.4 !important;
}
.rss-error {
color: #e57373 !important;
font-size: 0.78rem !important;
margin-top: 0.4rem !important;
}
.submit-button {
color: #fff;
font-size: 0.8rem;
@@ -108,5 +247,33 @@
color: #456;
margin-top: 2rem;
padding: 1.5rem;
max-width: 600px;
width: 100%;
@media (max-width: 600px) {
padding: 1rem 0.25rem;
margin-top: 1.25rem;
font-size: 0.85rem !important;
// display: block;
}
}
}
.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;
}
}
}