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

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

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

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

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