feat: add cover selection workflow and docker profiles
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
@@ -15,18 +15,29 @@ const BulkCropStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
||||
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const croppedImages = useAppStore((state) => state.croppedImages);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const targetImages = useMemo(
|
||||
() => uploadedImages.filter((img) => img.id !== coverImageId),
|
||||
[coverImageId, uploadedImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const shouldCrop = targetImages.length && cropConfig?.imageWidth;
|
||||
if (!shouldCrop) {
|
||||
setProcessing(false);
|
||||
setCroppedImages([]);
|
||||
return undefined;
|
||||
}
|
||||
const runCrop = async () => {
|
||||
if (!uploadedImages.length || !cropConfig?.imageWidth) return;
|
||||
setProcessing(true);
|
||||
try {
|
||||
const results = await applyCropToImages(uploadedImages, cropConfig);
|
||||
const results = await applyCropToImages(targetImages, cropConfig);
|
||||
if (!cancelled) {
|
||||
setCroppedImages(results);
|
||||
}
|
||||
@@ -40,13 +51,19 @@ const BulkCropStep = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!croppedImages.length && uploadedImages.length && cropConfig?.imageWidth) {
|
||||
if (!croppedImages.length) {
|
||||
runCrop();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cropConfig, croppedImages.length, setCroppedImages, setError, uploadedImages]);
|
||||
}, [
|
||||
cropConfig,
|
||||
croppedImages.length,
|
||||
setCroppedImages,
|
||||
setError,
|
||||
targetImages,
|
||||
]);
|
||||
|
||||
if (!uploadedImages.length) {
|
||||
return (
|
||||
@@ -59,7 +76,7 @@ const BulkCropStep = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!cropConfig?.imageWidth) {
|
||||
if (targetImages.length && !cropConfig?.imageWidth) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="warning">Crop ayarını kaydetmeden bu adıma geçemezsin.</Alert>
|
||||
@@ -70,6 +87,17 @@ const BulkCropStep = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetImages.length) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Kapak dışında crop uygulanacak görsel bulunmuyor. Bu adımı geçebilirsin.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
||||
OCR'ye geç
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { cropSingleImage } from '../utils/cropUtils';
|
||||
|
||||
const MIN_SELECTION = 5;
|
||||
const DEFAULT_SELECTION = { top: 10, left: 10, width: 80, height: 80 };
|
||||
@@ -36,11 +37,22 @@ const CropStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
||||
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||
const coverCropConfig = useAppStore((state) => state.coverCropConfig);
|
||||
const updateCropConfig = useAppStore((state) => state.updateCropConfig);
|
||||
const updateCoverCropConfig = useAppStore((state) => state.updateCoverCropConfig);
|
||||
const setCroppedCoverImage = useAppStore((state) => state.setCroppedCoverImage);
|
||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||
const [selectedImageId, setSelectedImageId] = useState(
|
||||
cropConfig?.referenceImageId || uploadedImages[0]?.id || null,
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const nonCoverImages = useMemo(
|
||||
() => uploadedImages.filter((img) => img.id !== coverImageId),
|
||||
[coverImageId, uploadedImages],
|
||||
);
|
||||
const [selectedImageId, setSelectedImageId] = useState(() => {
|
||||
if (cropConfig?.referenceImageId) return cropConfig.referenceImageId;
|
||||
const firstNonCover = nonCoverImages[0];
|
||||
return firstNonCover?.id || uploadedImages[0]?.id || null;
|
||||
});
|
||||
const [imageSize, setImageSize] = useState({
|
||||
width: cropConfig?.imageWidth || 0,
|
||||
height: cropConfig?.imageHeight || 0,
|
||||
@@ -49,11 +61,13 @@ const CropStep = () => {
|
||||
cropConfig?.selection ? cropConfig.selection : DEFAULT_SELECTION,
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [savingCover, setSavingCover] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const dragInfoRef = useRef(null);
|
||||
const canProceed =
|
||||
Boolean(cropConfig?.imageWidth) &&
|
||||
cropConfig?.referenceImageId === selectedImageId;
|
||||
const hasGeneralTargets = nonCoverImages.length > 0;
|
||||
const canProceed = hasGeneralTargets ? Boolean(cropConfig?.imageWidth) : true;
|
||||
const isCoverSelected = selectedImageId && selectedImageId === coverImageId;
|
||||
const activeConfig = isCoverSelected ? coverCropConfig : cropConfig;
|
||||
|
||||
const selectedImage = useMemo(
|
||||
() => uploadedImages.find((img) => img.id === selectedImageId),
|
||||
@@ -62,10 +76,14 @@ const CropStep = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadedImages.length) return;
|
||||
if (!selectedImage && uploadedImages[0]) {
|
||||
setSelectedImageId(uploadedImages[0].id);
|
||||
if (!selectedImage) {
|
||||
const fallback =
|
||||
nonCoverImages[0] || uploadedImages[0];
|
||||
if (fallback) {
|
||||
setSelectedImageId(fallback.id);
|
||||
}
|
||||
}
|
||||
}, [selectedImage, uploadedImages]);
|
||||
}, [nonCoverImages, selectedImage, uploadedImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImage) return;
|
||||
@@ -78,16 +96,16 @@ const CropStep = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cropConfig?.referenceImageId === selectedImageId &&
|
||||
cropConfig?.imageWidth
|
||||
activeConfig?.referenceImageId === selectedImageId &&
|
||||
activeConfig?.imageWidth
|
||||
) {
|
||||
setSelection(selectionFromConfig(cropConfig));
|
||||
setSelection(selectionFromConfig(activeConfig));
|
||||
setSaved(true);
|
||||
} else {
|
||||
setSelection(DEFAULT_SELECTION);
|
||||
setSaved(false);
|
||||
}
|
||||
}, [cropConfig, selectedImageId]);
|
||||
}, [activeConfig, selectedImageId]);
|
||||
|
||||
const applyMove = (base, deltaX, deltaY) => {
|
||||
const maxLeft = 100 - base.width;
|
||||
@@ -209,7 +227,7 @@ const CropStep = () => {
|
||||
};
|
||||
}, [imageSize.height, imageSize.width, selection]);
|
||||
|
||||
const handleSaveCrop = () => {
|
||||
const handleSaveCrop = async () => {
|
||||
if (!currentCropArea || !selectedImage) return;
|
||||
const config = {
|
||||
x: 0,
|
||||
@@ -228,6 +246,21 @@ const CropStep = () => {
|
||||
referenceImageId: selectedImage.id,
|
||||
selection,
|
||||
};
|
||||
if (isCoverSelected) {
|
||||
setSavingCover(true);
|
||||
try {
|
||||
updateCoverCropConfig(config);
|
||||
const croppedCover = await cropSingleImage(selectedImage, config);
|
||||
setCroppedCoverImage(croppedCover);
|
||||
resetFromStep('epub');
|
||||
setSaved(true);
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setSavingCover(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateCropConfig(config);
|
||||
resetFromStep('crop');
|
||||
setSaved(true);
|
||||
@@ -270,11 +303,14 @@ const CropStep = () => {
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedImageId(image.id);
|
||||
setSaved(false);
|
||||
resetFromStep('crop');
|
||||
if (image.id !== coverImageId) {
|
||||
resetFromStep('crop');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -282,11 +318,39 @@ const CropStep = () => {
|
||||
alt={image.filename}
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
{image.id === coverImageId && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'secondary.main',
|
||||
color: '#fff',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Kapak
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{isCoverSelected ? (
|
||||
<Alert severity="info">
|
||||
Kapak görseli için ayrı bir crop ayarı oluşturuyorsun. Bu ayar OCR sırasını etkilemez.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
Seçtiğin referans, kapak dışındaki tüm görsellere uygulanacak.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedImage && (
|
||||
<Box sx={{ width: '100%', overflow: 'hidden', borderRadius: 2, backgroundColor: '#0000000a' }}>
|
||||
<Box
|
||||
@@ -351,7 +415,9 @@ const CropStep = () => {
|
||||
{saved && (
|
||||
<Stack direction="row" spacing={1} alignItems="center" color="success.main">
|
||||
<CheckCircleIcon color="success" />
|
||||
<Typography>Crop ayarı kaydedildi.</Typography>
|
||||
<Typography>
|
||||
{isCoverSelected ? 'Kapak crop ayarı kaydedildi.' : 'Crop ayarı kaydedildi.'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -360,8 +426,12 @@ const CropStep = () => {
|
||||
Geri dön
|
||||
</Button>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Button variant="contained" onClick={handleSaveCrop} disabled={!currentCropArea}>
|
||||
Bu crop ayarını tüm görsellere uygula
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveCrop}
|
||||
disabled={!currentCropArea || (isCoverSelected && savingCover)}
|
||||
>
|
||||
{isCoverSelected ? 'Kapak görselini cropla' : 'Bu crop ayarını tüm görsellere uygula'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
||||
@@ -17,15 +17,18 @@ const EpubStep = () => {
|
||||
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
||||
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (!ocrText?.trim() || generatedEpub) return;
|
||||
if (!ocrText?.trim() || generatedEpub || needsCoverCrop) return;
|
||||
setProcessing(true);
|
||||
try {
|
||||
const epub = await createEpubFromOcr(ocrText);
|
||||
const epub = await createEpubFromOcr(ocrText, croppedCoverImage);
|
||||
if (!cancelled) {
|
||||
setGeneratedEpub(epub);
|
||||
}
|
||||
@@ -43,7 +46,7 @@ const EpubStep = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [generatedEpub, ocrText, setError, setGeneratedEpub]);
|
||||
}, [croppedCoverImage, generatedEpub, needsCoverCrop, ocrText, setError, setGeneratedEpub]);
|
||||
|
||||
if (!ocrText?.trim()) {
|
||||
return (
|
||||
@@ -64,12 +67,40 @@ const EpubStep = () => {
|
||||
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
||||
</Typography>
|
||||
</Box>
|
||||
{needsCoverCrop && (
|
||||
<Alert severity="warning">
|
||||
Kapak olarak işaretlediğin görseli croplamalısın. Crop adımında kapak görselini kaydet ve tekrar dene.
|
||||
</Alert>
|
||||
)}
|
||||
{processing && <LinearProgress />}
|
||||
{generatedEpub && (
|
||||
<Alert severity="success">
|
||||
EPUB hazır: {generatedEpub.filename}
|
||||
</Alert>
|
||||
)}
|
||||
{croppedCoverImage ? (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="subtitle1">Kapak önizlemesi</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={croppedCoverImage.url}
|
||||
alt="Epub kapak görseli"
|
||||
sx={{
|
||||
mt: 2,
|
||||
maxHeight: 260,
|
||||
width: 'auto',
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
coverImageId && (
|
||||
<Alert severity="info">
|
||||
Kapak seçili ancak crop işlemi tamamlanmadı. Crop adımına dönerek kapak kesimini belirle.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
||||
@@ -77,7 +108,7 @@ const EpubStep = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!generatedEpub || processing}
|
||||
disabled={!generatedEpub || processing || needsCoverCrop}
|
||||
onClick={() => navigate('/download')}
|
||||
>
|
||||
EPUB'i indir
|
||||
|
||||
@@ -28,6 +28,8 @@ const UploadStep = () => {
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const setUploadedImages = useAppStore((state) => state.setUploadedImages);
|
||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||
const setCoverImageId = useAppStore((state) => state.setCoverImageId);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles) => {
|
||||
@@ -45,6 +47,11 @@ const UploadStep = () => {
|
||||
[uploadedImages, resetFromStep, setUploadedImages],
|
||||
);
|
||||
|
||||
const handleCoverToggle = (imageId) => {
|
||||
const nextId = coverImageId === imageId ? null : imageId;
|
||||
setCoverImageId(nextId);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
@@ -98,6 +105,18 @@ const UploadStep = () => {
|
||||
<Typography variant="body2" noWrap>
|
||||
{image.filename}
|
||||
</Typography>
|
||||
<Button
|
||||
variant={image.id === coverImageId ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCoverToggle(image.id);
|
||||
}}
|
||||
>
|
||||
{image.id === coverImageId ? 'Kapak seçildi' : 'Kapak olarak işaretle'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user