Compare commits
13 Commits
b7dfc9734d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76a8774695 | |||
| b185b8c1ad | |||
| c5770b4d63 | |||
| 0dbcdcfcf4 | |||
| 569e904ddd | |||
| 37f7ccbb12 | |||
| 88abed086f | |||
| f0fbd9b113 | |||
| 45f58fc158 | |||
| 5917a35da5 | |||
| 9f3d4aecca | |||
| 4258c3ad61 | |||
| ebd6a9d3c1 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
29
.gitea/workflows/deploy.yml
Normal file
29
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
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: Create shared network if missing
|
||||
run: docker network create poster-picker || true
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker compose build
|
||||
|
||||
- name: Stop old container (if exists)
|
||||
run: docker compose down --remove-orphans || true
|
||||
|
||||
- name: Run compose
|
||||
run: docker compose up -d
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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;"]
|
||||
39
README.md
39
README.md
@@ -1,15 +1,18 @@
|
||||
# Movie Poster Gallery
|
||||
# Letterboxd Diary Poster Picker
|
||||
|
||||

|
||||

|
||||
|
||||
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
BIN
assets/project.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
REACT_APP_API_URL: ""
|
||||
image: poster-picker-frontend:latest
|
||||
ports:
|
||||
- "8081:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- poster-picker
|
||||
- caddy-external
|
||||
|
||||
networks:
|
||||
poster-picker:
|
||||
external: true
|
||||
caddy-external:
|
||||
external: true
|
||||
31
nginx-spa.conf
Normal file
31
nginx-spa.conf
Normal 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
867
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
13
public/icon.svg
Normal 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 |
@@ -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>
|
||||
|
||||
45
src/App.js
45
src/App.js
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
252
src/components/Header.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
150
src/pages/PosterGallery.js
Normal 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
388
src/pages/PosterSelector.js
Normal 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
173
src/pages/SelectionRecap.js
Normal 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
236
src/pages/UploadDiary.js
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import axios from "axios";
|
||||
|
||||
const API_BASE_URL = "http://localhost:5000/api";
|
||||
const generateUUID = () => {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchMovies = () => axios.get(`${API_BASE_URL}/movies`);
|
||||
export const downloadPoster = (movieId) =>
|
||||
axios.post(`${API_BASE_URL}/download-poster`, { movie_id: movieId });
|
||||
export const getUserId = () => {
|
||||
let id = localStorage.getItem("poster_picker_uid");
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem("poster_picker_uid", id);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use "./scss/uploadDiary";
|
||||
@use "./scss/posterSelector";
|
||||
@use "./scss/posterGallery";
|
||||
@use "./scss/selectionRecap";
|
||||
@use "./scss/cart";
|
||||
@use "./scss/navbar";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
187
src/styles/scss/_selectionRecap.scss
Normal file
187
src/styles/scss/_selectionRecap.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user