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:
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;
|
||||
Reference in New Issue
Block a user