Refactor: responsive upload page, fix warnings, CI/CD setup
- Fix MUI v4/React 18 StrictMode warnings (justify→justifyContent, Button migration) - Responsive UploadDiary: footer visible without scroll, mobile layout centered, overview text hidden on mobile - Fix Redux selector memoization with createSelector - RSS sync: auto-refresh PosterSelector after sync, Snackbar feedback with film count - Add .gitea/workflows/deploy.yml for tag-based CI/CD - Fix nginx config (default.conf), increase Node heap for Docker build - Update README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
34
.gitea/workflows/deploy.yml
Normal file
34
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Deploy Frontend
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version tag
|
||||||
|
run: echo "VERSION_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg REACT_APP_API_URL="" \
|
||||||
|
-t poster-picker-frontend:latest \
|
||||||
|
-t poster-picker-frontend:${{ env.VERSION_TAG }} .
|
||||||
|
|
||||||
|
- name: Stop old container
|
||||||
|
run: |
|
||||||
|
cd ~/dev-server/poster-picker
|
||||||
|
docker compose stop frontend || true
|
||||||
|
|
||||||
|
- name: Start new container
|
||||||
|
run: |
|
||||||
|
cd ~/dev-server/poster-picker
|
||||||
|
docker compose up -d --no-build frontend
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=512"
|
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||||
RUN npm ci --silent --legacy-peer-deps
|
RUN npm ci --silent --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -22,7 +22,7 @@ FROM nginx:stable-alpine AS runner
|
|||||||
# Copy build output
|
# Copy build output
|
||||||
COPY --from=builder /app/build /usr/share/nginx/html
|
COPY --from=builder /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
COPY nginx-spa.conf /etc/nginx/conf.d/nginx-spa.conf
|
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Expose port 80 (Caddy fera reverse proxy)
|
# Expose port 80 (Caddy fera reverse proxy)
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
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
|
## Features
|
||||||
|
|
||||||
- **Letterboxd Integration**: Import your movie diary directly from uploading a CSV file.
|
- **CSV Import**: Upload your Letterboxd diary export to load your full watch history.
|
||||||
- **Poster Gallery**: Browse through posters of movies you've watched.
|
- **RSS Sync**: Sync your ~50 most recent diary entries directly via your Letterboxd username — no export needed.
|
||||||
- **User-friendly Interface**: Intuitive design for easy navigation and selection.
|
- **Poster Gallery**: Browse all available posters for each movie via TMDB.
|
||||||
- **Responsive Design**: Works seamlessly on desktop and mobile devices.
|
- **Poster Selection**: Pick your favorite poster for each film.
|
||||||
|
- **Selection Recap**: Review all your selections in one place.
|
||||||
|
- **ZIP Download**: Download all selected posters as a ZIP file.
|
||||||
|
- **Responsive Design**: Works on desktop and mobile.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ A web application that allows users to browse and select their favorite movie po
|
|||||||
|
|
||||||
- Node.js (v14.0.0 or later)
|
- Node.js (v14.0.0 or later)
|
||||||
- npm (v6.0.0 or later)
|
- npm (v6.0.0 or later)
|
||||||
|
- A running instance of the [backend](https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker-backend)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -48,18 +52,19 @@ A web application that allows users to browse and select their favorite movie po
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. On the homepage, upload your diary CSV file.
|
1. **Import your diary** — either upload a `diary.csv` exported from [letterboxd.com/data/export](https://letterboxd.com/data/export/), or enter your Letterboxd username to sync via RSS.
|
||||||
2. Once processed, you'll be redirected to the Poster Selector page.
|
2. **Browse posters** — for each film in your diary, scroll through the available posters fetched from TMDB.
|
||||||
3. Browse through your watched movies and select your favorite posters.
|
3. **Select a poster** — click to pick your favorite for each movie.
|
||||||
4. (Add any additional steps or features here)
|
4. **Download** — use the download button in the header to export all selected posters as a ZIP.
|
||||||
|
|
||||||
## Technologies Used
|
## Technologies
|
||||||
|
|
||||||
- React.js
|
- React / Redux
|
||||||
- Material-UI
|
- Material UI
|
||||||
- Backend private for now
|
- Flask (backend)
|
||||||
|
- TMDB API
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Letterboxd](https://letterboxd.com/) for the inspiration and data (diary.csv)
|
- [Letterboxd](https://letterboxd.com/) for the diary data
|
||||||
- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the movie poster images
|
- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the poster images
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
BIN
assets/project.png
Normal file
BIN
assets/project.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -6,7 +6,7 @@ server {
|
|||||||
|
|
||||||
# Proxy API to backend container
|
# Proxy API to backend container
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://poster-picker-backend-1:5000/api/;
|
proxy_pass http://backend:5000/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
867
package-lock.json
generated
867
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"axios": "^1.7.6",
|
"axios": "^1.7.6",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"material-ui-dropzone": "^3.5.0",
|
"material-ui-dropzone": "^3.5.0",
|
||||||
"query-string": "^9.1.0",
|
"query-string": "^9.1.0",
|
||||||
|
|||||||
45
src/App.js
45
src/App.js
@@ -1,33 +1,36 @@
|
|||||||
import React from "react";
|
|
||||||
import "./styles/App.scss";
|
import "./styles/App.scss";
|
||||||
import { HashRouter as Router, Route, Routes } from "react-router-dom";
|
import { HashRouter as Router, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { PersistGate } from "redux-persist/integration/react";
|
import { PersistGate } from "redux-persist/integration/react";
|
||||||
import { store, persistor } from "./services/store";
|
import { store, persistor } from "./services/store";
|
||||||
import PosterSelector from "./components/PosterSelector";
|
import Header from "./components/Header";
|
||||||
import PosterGallery from "./components/PosterGallery";
|
import PosterSelector from "./pages/PosterSelector";
|
||||||
import UploadDiary from "./components/UploadDiary";
|
import PosterGallery from "./pages/PosterGallery";
|
||||||
import Cart from "./components/Cart";
|
import UploadDiary from "./pages/UploadDiary";
|
||||||
|
import SelectionRecap from "./pages/SelectionRecap";
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
{location.pathname !== "/" && <Header />}
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<UploadDiary />} />
|
||||||
|
<Route path="/PosterSelector" element={<PosterSelector />} />
|
||||||
|
<Route path="/posters/:movieName/:movieYear" element={<PosterGallery />} />
|
||||||
|
<Route path="/recap" element={<SelectionRecap />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate loading={null} persistor={persistor}>
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
{
|
<Router>
|
||||||
<Router>
|
<AppContent />
|
||||||
<div className="App">
|
</Router>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { removeAllPosters, removePoster } from "../services/action";
|
|
||||||
import DeleteIcon from "@material-ui/icons/Delete";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogActions,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL;
|
|
||||||
|
|
||||||
const Cart = () => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const selectedPosters = useSelector((state) => {
|
|
||||||
const selections = state.posterSelections;
|
|
||||||
return Object.entries(selections).flatMap(([movieId, posters]) =>
|
|
||||||
posters.map((poster) => ({
|
|
||||||
movieId,
|
|
||||||
posterId: typeof poster === "string" ? poster : poster.posterId,
|
|
||||||
watchedDate: poster.watchedDate,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [downloadFormat, setDownloadFormat] = useState("zip");
|
|
||||||
|
|
||||||
// Nouvel état pour la confirmation de suppression globale
|
|
||||||
const [openConfirmClear, setOpenConfirmClear] = useState(false);
|
|
||||||
|
|
||||||
const handleRemovePoster = (movieId, posterId) => {
|
|
||||||
if (movieId && posterId) dispatch(removePoster(movieId, posterId));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ouvre la dialog de confirmation (appelé par le bouton "Clear All Posters")
|
|
||||||
const handleOpenConfirmClear = () => {
|
|
||||||
setOpenConfirmClear(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ferme la dialog sans supprimer
|
|
||||||
const handleCloseConfirmClear = () => {
|
|
||||||
setOpenConfirmClear(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Confirme et supprime tous les posters
|
|
||||||
const handleConfirmClearAllPosters = () => {
|
|
||||||
dispatch(removeAllPosters());
|
|
||||||
setOpenConfirmClear(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${apiUrl}/api/download-posters`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
posters: selectedPosters,
|
|
||||||
format: downloadFormat,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Download failed");
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.includes("application/zip")) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "posters.zip";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid response format");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to download posters:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container>
|
|
||||||
<div className="cart-container">
|
|
||||||
<div className="poster-grid">
|
|
||||||
{selectedPosters.length === 0 ? (
|
|
||||||
<Box className="empty-cart-container">
|
|
||||||
<Typography variant="body1" gutterBottom>
|
|
||||||
Your poster collection is empty. Start exploring movies to add
|
|
||||||
some posters!
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
className="back-button"
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
>
|
|
||||||
Browse Movies
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{selectedPosters.map((poster) => (
|
|
||||||
<div
|
|
||||||
key={`${poster.movieId}-${poster.posterId}`}
|
|
||||||
className="poster-card"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`https://image.tmdb.org/t/p/original${poster.posterId}`}
|
|
||||||
alt="Movie poster"
|
|
||||||
className="poster-image"
|
|
||||||
/>
|
|
||||||
<div className="delete-overlay">
|
|
||||||
<button
|
|
||||||
className="delete-button"
|
|
||||||
onClick={() =>
|
|
||||||
handleRemovePoster(poster.movieId, poster.posterId)
|
|
||||||
}
|
|
||||||
aria-label="Supprimer ce poster"
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="poster-card add-new" onClick={() => navigate("/")}>
|
|
||||||
<span className="add-symbol">+</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="action-panel">
|
|
||||||
<h2>Download Options</h2>
|
|
||||||
<select
|
|
||||||
value={downloadFormat}
|
|
||||||
onChange={(e) => setDownloadFormat(e.target.value)}
|
|
||||||
className="format-select"
|
|
||||||
>
|
|
||||||
<option value="zip">ZIP</option>
|
|
||||||
<option value="tar">TAR</option>
|
|
||||||
<option value="7z">7Z</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="download-button"
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={selectedPosters.length === 0}
|
|
||||||
>
|
|
||||||
Download Selected
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="clear-button"
|
|
||||||
onClick={handleOpenConfirmClear}
|
|
||||||
disabled={selectedPosters.length === 0}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
>
|
|
||||||
Clear All Posters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={openConfirmClear}
|
|
||||||
onClose={handleCloseConfirmClear}
|
|
||||||
aria-labelledby="confirm-clear-title"
|
|
||||||
aria-describedby="confirm-clear-description"
|
|
||||||
>
|
|
||||||
<DialogTitle id="confirm-clear-title">Confirm deletion</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText id="confirm-clear-description">
|
|
||||||
Are you sure you want to delete all the posters in your collection?
|
|
||||||
This action cannot be undone.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleCloseConfirmClear} color="primary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmClearAllPosters}
|
|
||||||
color="secondary"
|
|
||||||
autoFocus
|
|
||||||
>
|
|
||||||
Delete all
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Cart;
|
|
||||||
@@ -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,74 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { AppBar, Toolbar, IconButton, Badge, Button } from "@material-ui/core";
|
|
||||||
import ShoppingCartIcon from "@material-ui/icons/ShoppingCart";
|
|
||||||
import ArrowBackIcon from "@material-ui/icons/ArrowBack";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL;
|
|
||||||
|
|
||||||
const selectPosterSelections = (state) => state.posterSelections;
|
|
||||||
|
|
||||||
const NavBar = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const posterSelections = useSelector(selectPosterSelections);
|
|
||||||
const selectedPosters = useMemo(() => {
|
|
||||||
return Object.values(posterSelections).flat();
|
|
||||||
}, [posterSelections]);
|
|
||||||
|
|
||||||
const totalSelected = selectedPosters.length;
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCart = () => {
|
|
||||||
navigate("/Cart");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetprofile = async () => {
|
|
||||||
try {
|
|
||||||
await axios.delete(`${apiUrl}/api/delete-csv`, { withCredentials: true });
|
|
||||||
navigate("/");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting CSV file:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPosterSelectorPage = location.pathname !== "/PosterSelector";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppBar position="fixed" className="navbar">
|
|
||||||
<Toolbar className="toolbar">
|
|
||||||
{isPosterSelectorPage ? (
|
|
||||||
<IconButton edge="start" className="back-button" onClick={handleBack}>
|
|
||||||
<ArrowBackIcon />
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<IconButton edge="start" className="back-button" disabled>
|
|
||||||
{/* Disabled IconButton */}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Button className="refresh-button" onClick={handleResetprofile}>
|
|
||||||
Reset Profile
|
|
||||||
</Button>
|
|
||||||
<IconButton edge="end" className="cart-button" onClick={handleCart}>
|
|
||||||
<Badge
|
|
||||||
badgeContent={totalSelected}
|
|
||||||
color="secondary"
|
|
||||||
overlap="rectangular"
|
|
||||||
>
|
|
||||||
<ShoppingCartIcon />
|
|
||||||
</Badge>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavBar;
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
|
||||||
import { selectPoster, deselectPoster } from "../services/action";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Card,
|
|
||||||
CardMedia,
|
|
||||||
CircularProgress,
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import CheckIcon from "@material-ui/icons/Check";
|
|
||||||
import NavBar from "./NavBar";
|
|
||||||
import { Dialog } from "@mui/material";
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL;
|
|
||||||
|
|
||||||
const PosterGallery = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
const { watchedDate } = location.state || {};
|
|
||||||
const { movieName, movieYear } = useParams();
|
|
||||||
const [posters, setPosters] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const movieId = `${movieName}-${movieYear}`;
|
|
||||||
|
|
||||||
const selectedPosters = useSelector(
|
|
||||||
(state) => state.posterSelections[movieId] || []
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPosters = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const encodedName = encodeURIComponent(movieName);
|
|
||||||
const encodedYear = encodeURIComponent(movieYear);
|
|
||||||
|
|
||||||
const response = await axios.get(
|
|
||||||
`${apiUrl}/api/posters/${encodedName}/${encodedYear}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data && response.data.posters) {
|
|
||||||
setPosters(response.data.posters);
|
|
||||||
} else {
|
|
||||||
throw new Error("No posters data in response");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching posters:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (movieName && movieYear) {
|
|
||||||
fetchPosters();
|
|
||||||
}
|
|
||||||
}, [movieName, movieYear]);
|
|
||||||
|
|
||||||
const isPosterSelected = (posterId) => {
|
|
||||||
return selectedPosters.some((poster) => poster.posterId === posterId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePosterSelect = (posterId) => {
|
|
||||||
const isSelected = isPosterSelected(posterId);
|
|
||||||
|
|
||||||
if (selectedPosters.length > 0 && !isSelected) {
|
|
||||||
setDialogOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSelected) {
|
|
||||||
dispatch(deselectPoster(movieName, movieYear, posterId));
|
|
||||||
} else {
|
|
||||||
dispatch(selectPoster(movieName, movieYear, posterId, watchedDate));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Paper elevation={3} className="content-paper">
|
|
||||||
<Typography color="error" align="center">
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
<NavBar />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container className="poster-gallery">
|
|
||||||
<Paper elevation={3} className="content-paper">
|
|
||||||
<Typography variant="h4" gutterBottom className="title">
|
|
||||||
Posters for {movieName} ({movieYear})
|
|
||||||
</Typography>
|
|
||||||
{loading ? (
|
|
||||||
<Box className="loading-container">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
) : posters.length > 0 ? (
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
{posters.map((poster, index) => {
|
|
||||||
const isSelected = isPosterSelected(poster.file_path);
|
|
||||||
return (
|
|
||||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
|
||||||
<Card
|
|
||||||
className={`poster-card ${
|
|
||||||
isSelected ? "selected-poster" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handlePosterSelect(poster.file_path)}
|
|
||||||
>
|
|
||||||
<CardMedia
|
|
||||||
className="poster-image"
|
|
||||||
image={`https://image.tmdb.org/t/p/original${poster.file_path}`}
|
|
||||||
title={`${movieName} poster ${index + 1}`}
|
|
||||||
/>
|
|
||||||
{isSelected && <CheckIcon className="check-icon" />}
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
) : (
|
|
||||||
<Typography align="center">
|
|
||||||
No posters found for this movie.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
<NavBar />
|
|
||||||
<Dialog open={dialogOpen} onClose={handleDialogClose}>
|
|
||||||
<DialogTitle>Poster already selected</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography>
|
|
||||||
You can only select one poster per movie. Please deselect the
|
|
||||||
current poster first.
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={handleDialogClose} color="primary">
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PosterGallery;
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
Grid,
|
|
||||||
Typography,
|
|
||||||
LinearProgress,
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
Button,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import Pagination from "@mui/material/Pagination";
|
|
||||||
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
|
||||||
import queryString from "query-string";
|
|
||||||
import pulpGif from "../static/images/pulp.gif";
|
|
||||||
import NavBar from "./NavBar";
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL;
|
|
||||||
|
|
||||||
const paginationTheme = createTheme({
|
|
||||||
palette: {
|
|
||||||
mode: "dark",
|
|
||||||
primary: {
|
|
||||||
main: "#667788",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: "#ffffff",
|
|
||||||
secondary: "#667788",
|
|
||||||
},
|
|
||||||
action: {
|
|
||||||
hover: "rgba(102, 119, 136, 0.2)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiPaginationItem: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
"&.Mui-selected": {
|
|
||||||
backgroundColor: "#667788",
|
|
||||||
color: "#ffffff",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#778899",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const PosterSelector = () => {
|
|
||||||
const [movies, setMovies] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
//const [progress, setProgress] = useState(0);
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const moviesPerPage = 8;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parsed = queryString.parse(location.search);
|
|
||||||
const pageNumber = parsed.page ? parseInt(parsed.page, 10) : 1;
|
|
||||||
setPage(pageNumber);
|
|
||||||
|
|
||||||
fetchMovies(pageNumber);
|
|
||||||
}, [location.search]);
|
|
||||||
|
|
||||||
// const fetchUsername = async () => {
|
|
||||||
// try {
|
|
||||||
// const response = await axios.get(`${apiUrl}/api/username`);
|
|
||||||
// setUsername(response.data.username);
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Error fetching username:", error);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const fetchMovies = async (pageNumber) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
// setProgress(0);
|
|
||||||
|
|
||||||
const response = await axios.get(
|
|
||||||
`${apiUrl}/api/movies?page=${pageNumber}&limit=${moviesPerPage}`,
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.movies) {
|
|
||||||
setMovies(response.data.movies);
|
|
||||||
setTotalPages(Math.ceil(response.data.total / moviesPerPage));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching movies:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMovieClick = (movieName, movieYear, watchedDate) => {
|
|
||||||
navigate(
|
|
||||||
`/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(
|
|
||||||
movieYear
|
|
||||||
)}`,
|
|
||||||
{ state: { watchedDate } }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (event, value) => {
|
|
||||||
setPage(value);
|
|
||||||
navigate(`?page=${value}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoBack = () => {
|
|
||||||
navigate("/");
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatWatchedDate = (dateString) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const day = date.getDate().toString().padStart(2, "0");
|
|
||||||
const month = date
|
|
||||||
.toLocaleString("default", { month: "short" })
|
|
||||||
.toUpperCase();
|
|
||||||
return { day, month };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container className="poster-selector">
|
|
||||||
<Paper elevation={3} className="content-paper">
|
|
||||||
<Typography variant="h4" className="title">
|
|
||||||
Your diary
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{username && (
|
|
||||||
<Typography variant="h6" className="username">
|
|
||||||
Letterboxd User: {username}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Box className="progress-container">
|
|
||||||
<LinearProgress />
|
|
||||||
<Typography className="progress-label">
|
|
||||||
Downloading movies...
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : movies.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<List className="movies-list">
|
|
||||||
{movies.map((movie, index) => (
|
|
||||||
<ListItem
|
|
||||||
button
|
|
||||||
key={index}
|
|
||||||
onClick={() =>
|
|
||||||
handleMovieClick(
|
|
||||||
movie.Name,
|
|
||||||
movie.Year,
|
|
||||||
movie["Watched Date"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="movie-item"
|
|
||||||
>
|
|
||||||
<Grid container alignItems="center">
|
|
||||||
<Grid item>
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
`https://image.tmdb.org/t/p/w500${movie.Poster}` ||
|
|
||||||
`/api/placeholder/50/75`
|
|
||||||
}
|
|
||||||
alt={movie.Name}
|
|
||||||
className="movie-poster"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item className="movie-info">
|
|
||||||
<Typography variant="subtitle1" className="movie-title">
|
|
||||||
{movie.Name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" className="movie-year">
|
|
||||||
{movie.Year}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Box className="watched-date">
|
|
||||||
{(() => {
|
|
||||||
const { day, month } = formatWatchedDate(
|
|
||||||
movie["Watched Date"]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="watched-day"
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
className="watched-month"
|
|
||||||
>
|
|
||||||
{month}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
<Box className="pagination-container">
|
|
||||||
<ThemeProvider theme={paginationTheme}>
|
|
||||||
<Pagination
|
|
||||||
count={totalPages}
|
|
||||||
page={page}
|
|
||||||
onChange={handlePageChange}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Box className="no-movies-container">
|
|
||||||
<div className="gif-container">
|
|
||||||
<div className="circle-background"></div>
|
|
||||||
<img
|
|
||||||
src={pulpGif}
|
|
||||||
alt="Confused reaction"
|
|
||||||
className="reaction-gif"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Typography variant="body1" gutterBottom>
|
|
||||||
No movies found in your diary. Please check your Letterboxd
|
|
||||||
username or CSV file.
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
className="back-button"
|
|
||||||
onClick={handleGoBack}
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
<NavBar />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PosterSelector;
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Grid,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
TextField,
|
|
||||||
Box,
|
|
||||||
Link,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import { CloudUploadOutlined } from "@material-ui/icons";
|
|
||||||
import CloudDoneOutlinedIcon from "@mui/icons-material/CloudDoneOutlined";
|
|
||||||
import { useDropzone } from "react-dropzone";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL;
|
|
||||||
const VERSION = require("../../package.json").version;
|
|
||||||
|
|
||||||
const UploadDiary = () => {
|
|
||||||
const [file, setFile] = useState(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCSVFile = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${apiUrl}/api/check-csv`, {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
if (response.data.fileExists) {
|
|
||||||
navigate("/PosterSelector");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking CSV file:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkCSVFile();
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps } = useDropzone({
|
|
||||||
onDrop: (acceptedFiles) => {
|
|
||||||
setFile(acceptedFiles[0]);
|
|
||||||
},
|
|
||||||
maxFiles: 1,
|
|
||||||
accept: ".csv",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (username || file) {
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
if (username) {
|
|
||||||
const response = await axios.post(
|
|
||||||
`${apiUrl}/api/fetch-diary`,
|
|
||||||
{ username },
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data && response.data.success) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
navigate("/PosterSelector");
|
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch diary", response.data);
|
|
||||||
}
|
|
||||||
} else if (file) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
await axios.post(`${apiUrl}/api/upload-csv`, formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate("/PosterSelector");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing diary:", error);
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container className="upload-diary">
|
|
||||||
<div className="upload-card">
|
|
||||||
<div className="logo-container">
|
|
||||||
<img src="/icon.svg" alt="logo" className="logo" />
|
|
||||||
</div>
|
|
||||||
<Typography variant="h4" className="title">
|
|
||||||
Upload Letterboxd Diary
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
className="username-field"
|
|
||||||
label="Letterboxd Username"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
onKeyUp={(e) => {
|
|
||||||
if (e.key === "Enter") handleUpload();
|
|
||||||
}}
|
|
||||||
placeholder="Enter your Letterboxd username"
|
|
||||||
/>
|
|
||||||
<Typography variant="body2" className="or-text">
|
|
||||||
Or upload a CSV file:
|
|
||||||
</Typography>
|
|
||||||
<div {...getRootProps()} className="dropzone">
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
{file ? (
|
|
||||||
<>
|
|
||||||
<CloudDoneOutlinedIcon className="dropzone-icon" />
|
|
||||||
<Typography variant="h6" className="dropzone-text">
|
|
||||||
Your file has been uploaded: {file.name}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CloudUploadOutlined className="dropzone-icon" />
|
|
||||||
<Typography variant="h6" className="dropzone-text">
|
|
||||||
Drag and drop a CSV file here or click to select
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Grid container justify="center" className="progress-container">
|
|
||||||
{uploading ? (
|
|
||||||
<CircularProgress />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="submit-button"
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={!username && !file}
|
|
||||||
>
|
|
||||||
SUBMIT
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
</div>
|
|
||||||
<Typography variant="body1" className="overview-text">
|
|
||||||
This tool allows you to easily import your Letterboxd diary and select
|
|
||||||
your favorite movie poster. Follow these simple steps:
|
|
||||||
<br />
|
|
||||||
<strong>1. Enter Your Letterboxd Username:</strong> Type your
|
|
||||||
Letterboxd username to automatically fetch your diary.
|
|
||||||
<br />
|
|
||||||
<strong>2. Or Upload Your CSV File:</strong> If you prefer, you can
|
|
||||||
still upload your diary.csv file directly.
|
|
||||||
<br />
|
|
||||||
<strong>3. Automatic Processing:</strong> Once submitted, the
|
|
||||||
application will process your diary.
|
|
||||||
<br />
|
|
||||||
<strong>4. Poster Selection:</strong> After processing, you'll be
|
|
||||||
redirected to the Poster Selector page, where you can browse and
|
|
||||||
choose your favorite movie poster based on your Letterboxd diary.
|
|
||||||
</Typography>
|
|
||||||
</Container>
|
|
||||||
<Box
|
|
||||||
component="footer"
|
|
||||||
className="footer"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
className="footer-text"
|
|
||||||
>
|
|
||||||
<Typography variant="body2" style={{ opacity: 0.7 }}>
|
|
||||||
v{VERSION}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" style={{ opacity: 0.5 }}>
|
|
||||||
•
|
|
||||||
</Typography>
|
|
||||||
<Link
|
|
||||||
href="https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link"
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = 1)}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)}
|
|
||||||
>
|
|
||||||
<span>Hugyouu</span>
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UploadDiary;
|
|
||||||
@@ -5,11 +5,7 @@ import App from "./App";
|
|||||||
import reportWebVitals from "./reportWebVitals";
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||||
root.render(
|
root.render(<App />);
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
// If you want to start measuring performance in your app, pass a function
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
|||||||
150
src/pages/PosterGallery.js
Normal file
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 SELECT_POSTER = "SELECT_POSTER";
|
||||||
export const DESELECT_POSTER = "DESELECT_POSTER";
|
export const DESELECT_POSTER = "DESELECT_POSTER";
|
||||||
export const REMOVE_POSTER = "REMOVE_POSTER";
|
export const REMOVE_POSTER = "REMOVE_POSTER";
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const API_BASE_URL = "http://localhost:5000/api";
|
export const getUserId = () => {
|
||||||
|
let id = localStorage.getItem("poster_picker_uid");
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem("poster_picker_uid", id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchMovies = () => axios.get(`${API_BASE_URL}/movies`);
|
const api = axios.create({
|
||||||
export const downloadPoster = (movieId) =>
|
baseURL: process.env.REACT_APP_API_URL,
|
||||||
axios.post(`${API_BASE_URL}/download-poster`, { movie_id: movieId });
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
config.headers["X-User-ID"] = getUserId();
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { legacy_createStore as createStore, combineReducers } from "redux";
|
import { legacy_createStore as createStore, combineReducers } from "redux";
|
||||||
import { persistStore, persistReducer } from "redux-persist";
|
import { persistStore, persistReducer } from "redux-persist";
|
||||||
import storage from "redux-persist/lib/storage";
|
import storage from "redux-persist/lib/storage";
|
||||||
import {DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER} from "./action";
|
import { DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER, SET_USERNAME, SET_DATA_SOURCE } from "./action";
|
||||||
|
|
||||||
const posterSelectionReducer = (state = {}, action) => {
|
const posterSelectionReducer = (state = {}, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@@ -9,7 +9,7 @@ const posterSelectionReducer = (state = {}, action) => {
|
|||||||
const { movieId, posterId, watchedDate } = action.payload;
|
const { movieId, posterId, watchedDate } = action.payload;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
[movieId]: [...(state[movieId] || []), { posterId, watchedDate }],
|
[movieId]: [{ posterId, watchedDate }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case DESELECT_POSTER: {
|
case DESELECT_POSTER: {
|
||||||
@@ -38,9 +38,22 @@ const posterSelectionReducer = (state = {}, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const usernameReducer = (state = "", action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case SET_USERNAME: return action.payload;
|
||||||
|
default: return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceReducer = (state = null, action) => {
|
||||||
|
if (action.type === SET_DATA_SOURCE) return action.payload;
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
posterSelections: posterSelectionReducer,
|
posterSelections: posterSelectionReducer,
|
||||||
// other reducers...
|
username: usernameReducer,
|
||||||
|
dataSource: dataSourceReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use "./scss/uploadDiary";
|
@use "./scss/uploadDiary";
|
||||||
@use "./scss/posterSelector";
|
@use "./scss/posterSelector";
|
||||||
@use "./scss/posterGallery";
|
@use "./scss/posterGallery";
|
||||||
|
@use "./scss/selectionRecap";
|
||||||
@use "./scss/cart";
|
@use "./scss/cart";
|
||||||
@use "./scss/navbar";
|
@use "./scss/navbar";
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,206 @@
|
|||||||
// _navbar.scss
|
@use "sass:color";
|
||||||
|
|
||||||
// Variables de couleur et autres constantes
|
|
||||||
$background-color: #1c1f23;
|
$background-color: #1c1f23;
|
||||||
$icon-color: white;
|
$primary-color: #00a346;
|
||||||
$margin-right: 8px; // Correspondant à theme.spacing(2)
|
$text-white: #ffffff;
|
||||||
|
$text-gray: #667788;
|
||||||
|
|
||||||
.navbar {
|
.app-header {
|
||||||
top: auto !important;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: $background-color !important;
|
background-color: $background-color !important;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||||
|
|
||||||
.toolbar {
|
.header-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button,
|
.header-left {
|
||||||
.cart-button,
|
display: flex;
|
||||||
.refresh-button {
|
align-items: center;
|
||||||
color: $icon-color;
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover .header-title {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button {
|
.header-logo {
|
||||||
margin-right: $margin-right;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button[disabled] {
|
.header-title {
|
||||||
opacity: 0.3;
|
color: $text-white;
|
||||||
pointer-events: none;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-movie-name {
|
||||||
|
color: $text-white;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-username {
|
||||||
|
color: $text-gray;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '@';
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-btn {
|
||||||
|
display: none !important;
|
||||||
|
color: $text-gray !important;
|
||||||
|
&:hover { color: $text-white !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header-actions { display: none !important; }
|
||||||
|
.header-menu-btn { display: flex !important; }
|
||||||
|
.header-title { font-size: 1rem !important; }
|
||||||
|
.header-center { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-reset-btn {
|
||||||
|
color: $text-gray !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-recap-btn {
|
||||||
|
color: $text-gray !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $text-white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sync-btn {
|
||||||
|
color: $text-gray !important;
|
||||||
|
|
||||||
|
&:hover { color: $text-white !important; }
|
||||||
|
|
||||||
|
&--active { color: $primary-color !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-download-btn {
|
||||||
|
background-color: $primary-color !important;
|
||||||
|
color: $text-white !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color.adjust($primary-color, $lightness: -8%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
background-color: color.adjust($primary-color, $lightness: -12%) !important;
|
||||||
|
cursor: default !important;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-download-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 120px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-white;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
height: 4px !important;
|
||||||
|
|
||||||
|
.MuiLinearProgress-bar {
|
||||||
|
background-color: $text-white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.MuiLinearProgress-root {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Menu mobile (portail MUI, hors de .app-header)
|
||||||
|
.header-menu-paper {
|
||||||
|
background-color: #1c1f23 !important;
|
||||||
|
border: 1px solid #2c3038 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
.MuiList-root { padding: 4px 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-item {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px !important;
|
||||||
|
|
||||||
|
&:hover { background-color: rgba(102, 119, 136, 0.2) !important; }
|
||||||
|
&.Mui-disabled { opacity: 0.4 !important; color: #ffffff !important; }
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: #ef9a9a !important;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 1px solid #2c3038;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-icon {
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ $hover-scale: 1.05;
|
|||||||
$loading-height: 50vh;
|
$loading-height: 50vh;
|
||||||
|
|
||||||
.poster-gallery {
|
.poster-gallery {
|
||||||
margin-top: 2rem;
|
margin-top: 5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
.content-paper {
|
.content-paper {
|
||||||
@@ -15,6 +15,49 @@ $loading-height: 50vh;
|
|||||||
background-color: $background-dark;
|
background-color: $background-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-group {
|
||||||
|
border: 1px solid #2c3038 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle-btn {
|
||||||
|
color: #667788 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-left: 1px solid #2c3038 !important;
|
||||||
|
padding: 4px 14px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
min-width: unset !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-left: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: rgba(102, 119, 136, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Mui-selected {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: #667788 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-posters-msg {
|
||||||
|
color: #667788;
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
color: $text-white;
|
color: $text-white;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -24,30 +67,19 @@ $loading-height: 50vh;
|
|||||||
|
|
||||||
.poster-card {
|
.poster-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale($hover-scale);
|
transform: scale($hover-scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected-poster {
|
&.selected-poster {
|
||||||
border: 4px solid $primary-color;
|
outline: 4px solid $primary-color;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poster-image {
|
|
||||||
padding-top: 150%;
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: transform 0.3s ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-icon {
|
.check-icon {
|
||||||
@@ -61,6 +93,46 @@ $loading-height: 50vh;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poster-aspect-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 150%;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-skeleton {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, #1c1f23 25%, #2a2e33 50%, #1c1f23 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.4s infinite;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-image-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
|
||||||
|
&--loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,134 +1,314 @@
|
|||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
// Variables
|
|
||||||
$primary-color: #00a346;
|
$primary-color: #00a346;
|
||||||
$secondary-color: #667788;
|
$secondary-color: #667788;
|
||||||
$background-dark: #1c1f23;
|
$background-dark: #1c1f23;
|
||||||
$text-white: #ffffff;
|
$text-white: #ffffff;
|
||||||
$text-gray: #667788;
|
$text-gray: #667788;
|
||||||
$border-color: #667788;
|
$border-color: #2c3038;
|
||||||
$hover-color: rgba(102, 119, 136, 0.2);
|
$hover-color: rgba(102, 119, 136, 0.2);
|
||||||
|
|
||||||
.poster-selector {
|
.poster-selector {
|
||||||
margin-top: 2rem;
|
margin-top: 5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
.content-paper {
|
.content-paper {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: $background-dark !important;
|
background-color: $background-dark;
|
||||||
|
max-width: calc((120vh - 340px) * 7 / 9 + 3rem);
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
color: $text-white;
|
color: $text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
// Calendar navigation
|
||||||
color: $primary-color;
|
.calendar-nav {
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movies-list {
|
|
||||||
max-height: 800px;
|
|
||||||
min-height: 900px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-item {
|
|
||||||
padding: 0 2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: nowrap;
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
color: $text-white !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $hover-color;
|
background-color: $hover-color !important;
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
margin-top: 1.5rem;
|
gap: 4px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
// Override MUI Pagination styles
|
&:hover {
|
||||||
.MuiPagination-root {
|
border-color: $text-gray;
|
||||||
.MuiPaginationItem-root {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
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 {
|
.progress-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -148,12 +328,23 @@ $hover-color: rgba(102, 119, 136, 0.2);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.no-movies-container {
|
.no-movies-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 400px;
|
|
||||||
color: $text-white;
|
color: $text-white;
|
||||||
|
background: rgba(28, 31, 35, 0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: fade-in 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.gif-container {
|
.gif-container {
|
||||||
@@ -193,3 +384,142 @@ $hover-color: rgba(102, 119, 136, 0.2);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.poster-selector {
|
||||||
|
margin-top: 3.5rem !important;
|
||||||
|
padding-left: 0.5rem !important;
|
||||||
|
padding-right: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .content-paper {
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav : deux rangées
|
||||||
|
// Rangée 1 : Today (gauche) | chevrons + label (droite)
|
||||||
|
// Rangée 2 : selects centrés
|
||||||
|
.poster-selector .calendar-nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-today-btn {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-nav-center {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-month-label {
|
||||||
|
min-width: unset !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-month-selects {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center !important;
|
||||||
|
border: none !important;
|
||||||
|
border-top: 1px solid #2c3038 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
padding: 6px 0 0 !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grille
|
||||||
|
.poster-selector .calendar-grid {
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-day-header {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-cell {
|
||||||
|
min-height: unset; // aspect-ratio 2/3 contrôle la hauteur
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-day-number {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-movie-title {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-selector .calendar-more-badge,
|
||||||
|
.poster-selector .calendar-selected-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover multi-films (rendu en portal, hors de .poster-selector)
|
||||||
|
.multi-movie-popover {
|
||||||
|
background-color: #1c1f23 !important;
|
||||||
|
border: 1px solid #2c3038;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #2c3038;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&:hover { background-color: rgba(102, 119, 136, 0.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-poster {
|
||||||
|
width: 36px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--empty { background-color: #2c3038; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-year {
|
||||||
|
color: #667788;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-movie-check {
|
||||||
|
color: #00a346 !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
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 {
|
.upload-page {
|
||||||
padding: 3rem;
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background-color: #14181c;
|
background-color: #14181c;
|
||||||
min-height: calc(100vh - 60px);
|
}
|
||||||
|
|
||||||
|
.upload-diary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 3rem;
|
||||||
|
padding-top: 5rem;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
padding: 1.25rem;
|
||||||
|
padding-top: 4.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
min-height: calc(100svh - 80px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.upload-card {
|
.upload-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
@@ -17,6 +41,11 @@
|
|||||||
padding-top: 4rem;
|
padding-top: 4rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
padding: 1.25rem;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-container {
|
.logo-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -80px;
|
top: -80px;
|
||||||
@@ -24,11 +53,20 @@
|
|||||||
transform: translate(-50%);
|
transform: translate(-50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
top: -55px;
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,35 +74,11 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
|
||||||
|
|
||||||
.username-field {
|
@media (max-width: 600px) {
|
||||||
margin-bottom: 1.5rem;
|
font-size: 1.4rem !important;
|
||||||
|
margin-bottom: 1rem;
|
||||||
.MuiOutlinedInput-root {
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
fieldset {
|
|
||||||
border-color: #00a346 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover fieldset {
|
|
||||||
border-color: #00a346 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Mui-focused fieldset {
|
|
||||||
border-color: #1caff2 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.MuiInputLabel-root {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.or-text {
|
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone {
|
.dropzone {
|
||||||
@@ -74,6 +88,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #1f252a;
|
background-color: #1f252a;
|
||||||
}
|
}
|
||||||
@@ -82,11 +100,20 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #00a346;
|
color: #00a346;
|
||||||
font-size: 4rem;
|
font-size: 4rem;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone-text {
|
.dropzone-text {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +121,101 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.75rem 0 1.25rem;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
margin: 1.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-divider-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #2c3038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-divider-text {
|
||||||
|
color: #526e89;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-label {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.MuiOutlinedInput-root {
|
||||||
|
background-color: #14181c;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
fieldset { border-color: #2c3038; }
|
||||||
|
&:hover fieldset { border-color: #526e89; }
|
||||||
|
&.Mui-focused fieldset { border-color: #00a346; }
|
||||||
|
|
||||||
|
input { padding: 8px 10px 8px 4px; color: #ffffff; }
|
||||||
|
input::placeholder { color: #526e89; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-url-prefix {
|
||||||
|
color: #526e89;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-sync-button {
|
||||||
|
background-color: #00a346 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
padding: 7px 16px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
|
||||||
|
&:hover { background-color: #008a39 !important; }
|
||||||
|
&:disabled { opacity: 0.6 !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-note {
|
||||||
|
color: #526e89 !important;
|
||||||
|
font-size: 0.72rem !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rss-error {
|
||||||
|
color: #e57373 !important;
|
||||||
|
font-size: 0.78rem !important;
|
||||||
|
margin-top: 0.4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-button {
|
.submit-button {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -125,6 +247,15 @@
|
|||||||
color: #456;
|
color: #456;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
// display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +273,7 @@
|
|||||||
|
|
||||||
.link {
|
.link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit
|
color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user