feat: add cover selection workflow and docker profiles
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
public/tesseract/*.map
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
docker/
|
||||||
|
server/node_modules
|
||||||
|
server/dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
|
coverage
|
||||||
|
tmp
|
||||||
|
.DS_Store
|
||||||
@@ -5,15 +5,16 @@ services:
|
|||||||
target: dev
|
target: dev
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_BASE_URL=http://localhost:4000
|
- VITE_API_BASE_URL=http://localhost:4000
|
||||||
|
- CHOKIDAR_USEPOLLING=1
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app:delegated
|
||||||
- frontend_dev_node_modules:/app/node_modules
|
- /app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-dev
|
- backend-dev
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- imgpub-app-dev
|
||||||
|
|
||||||
backend-dev:
|
backend-dev:
|
||||||
build:
|
build:
|
||||||
@@ -25,10 +26,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./server:/app
|
- ./server:/app:delegated
|
||||||
- backend_dev_node_modules:/app/node_modules
|
- /app/node_modules
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- imgpub-app-dev
|
||||||
|
|
||||||
frontend-prod:
|
frontend-prod:
|
||||||
build:
|
build:
|
||||||
@@ -43,7 +44,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend-prod
|
- backend-prod
|
||||||
profiles:
|
profiles:
|
||||||
- prod
|
- imgpub-app
|
||||||
|
|
||||||
backend-prod:
|
backend-prod:
|
||||||
build:
|
build:
|
||||||
@@ -55,8 +56,4 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
profiles:
|
profiles:
|
||||||
- prod
|
- imgpub-app
|
||||||
|
|
||||||
volumes:
|
|
||||||
frontend_dev_node_modules:
|
|
||||||
backend_dev_node_modules:
|
|
||||||
|
|||||||
8
server/.dockerignore
Normal file
8
server/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
tmp
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
@@ -23,7 +23,7 @@ const sanitizeHtml = (text = '') =>
|
|||||||
.replace(/\n/g, '<br/>');
|
.replace(/\n/g, '<br/>');
|
||||||
|
|
||||||
app.post('/generate-epub', async (req, res) => {
|
app.post('/generate-epub', async (req, res) => {
|
||||||
const { text, meta } = req.body || {};
|
const { text, meta, cover } = req.body || {};
|
||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
return res.status(400).json({ message: 'text is required' });
|
return res.status(400).json({ message: 'text is required' });
|
||||||
}
|
}
|
||||||
@@ -40,12 +40,29 @@ app.post('/generate-epub', async (req, res) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const outputPath = join(tmpdir(), `imgpub-${uuidV4()}.epub`);
|
const outputPath = join(tmpdir(), `imgpub-${uuidV4()}.epub`);
|
||||||
|
let coverPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const epub = new Epub({ title, author, content }, outputPath);
|
if (cover?.data) {
|
||||||
|
const coverBuffer = Buffer.from(cover.data, 'base64');
|
||||||
|
const coverExtension =
|
||||||
|
cover?.mimeType?.split('/').pop() || cover?.filename?.split('.').pop() || 'png';
|
||||||
|
coverPath = join(tmpdir(), `imgpub-cover-${uuidV4()}.${coverExtension}`);
|
||||||
|
await fs.writeFile(coverPath, coverBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const epubOptions = { title, author, content };
|
||||||
|
if (coverPath) {
|
||||||
|
epubOptions.cover = coverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const epub = new Epub(epubOptions, outputPath);
|
||||||
await epub.promise;
|
await epub.promise;
|
||||||
const buffer = await fs.readFile(outputPath);
|
const buffer = await fs.readFile(outputPath);
|
||||||
await fs.unlink(outputPath).catch(() => {});
|
await fs.unlink(outputPath).catch(() => {});
|
||||||
|
if (coverPath) {
|
||||||
|
await fs.unlink(coverPath).catch(() => {});
|
||||||
|
}
|
||||||
res.json({ filename, data: buffer.toString('base64') });
|
res.json({ filename, data: buffer.toString('base64') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('EPUB generation failed:', error);
|
console.error('EPUB generation failed:', error);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -15,18 +15,29 @@ const BulkCropStep = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
const cropConfig = useAppStore((state) => state.cropConfig);
|
||||||
|
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||||
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
|
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
|
||||||
const setError = useAppStore((state) => state.setError);
|
const setError = useAppStore((state) => state.setError);
|
||||||
const croppedImages = useAppStore((state) => state.croppedImages);
|
const croppedImages = useAppStore((state) => state.croppedImages);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
const targetImages = useMemo(
|
||||||
|
() => uploadedImages.filter((img) => img.id !== coverImageId),
|
||||||
|
[coverImageId, uploadedImages],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const shouldCrop = targetImages.length && cropConfig?.imageWidth;
|
||||||
|
if (!shouldCrop) {
|
||||||
|
setProcessing(false);
|
||||||
|
setCroppedImages([]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const runCrop = async () => {
|
const runCrop = async () => {
|
||||||
if (!uploadedImages.length || !cropConfig?.imageWidth) return;
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const results = await applyCropToImages(uploadedImages, cropConfig);
|
const results = await applyCropToImages(targetImages, cropConfig);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCroppedImages(results);
|
setCroppedImages(results);
|
||||||
}
|
}
|
||||||
@@ -40,13 +51,19 @@ const BulkCropStep = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (!croppedImages.length && uploadedImages.length && cropConfig?.imageWidth) {
|
if (!croppedImages.length) {
|
||||||
runCrop();
|
runCrop();
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [cropConfig, croppedImages.length, setCroppedImages, setError, uploadedImages]);
|
}, [
|
||||||
|
cropConfig,
|
||||||
|
croppedImages.length,
|
||||||
|
setCroppedImages,
|
||||||
|
setError,
|
||||||
|
targetImages,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!uploadedImages.length) {
|
if (!uploadedImages.length) {
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +76,7 @@ const BulkCropStep = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cropConfig?.imageWidth) {
|
if (targetImages.length && !cropConfig?.imageWidth) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Alert severity="warning">Crop ayarını kaydetmeden bu adıma geçemezsin.</Alert>
|
<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 (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
<Box textAlign="center">
|
<Box textAlign="center">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
import { cropSingleImage } from '../utils/cropUtils';
|
||||||
|
|
||||||
const MIN_SELECTION = 5;
|
const MIN_SELECTION = 5;
|
||||||
const DEFAULT_SELECTION = { top: 10, left: 10, width: 80, height: 80 };
|
const DEFAULT_SELECTION = { top: 10, left: 10, width: 80, height: 80 };
|
||||||
@@ -36,11 +37,22 @@ const CropStep = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
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 updateCropConfig = useAppStore((state) => state.updateCropConfig);
|
||||||
|
const updateCoverCropConfig = useAppStore((state) => state.updateCoverCropConfig);
|
||||||
|
const setCroppedCoverImage = useAppStore((state) => state.setCroppedCoverImage);
|
||||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||||
const [selectedImageId, setSelectedImageId] = useState(
|
const setError = useAppStore((state) => state.setError);
|
||||||
cropConfig?.referenceImageId || uploadedImages[0]?.id || null,
|
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({
|
const [imageSize, setImageSize] = useState({
|
||||||
width: cropConfig?.imageWidth || 0,
|
width: cropConfig?.imageWidth || 0,
|
||||||
height: cropConfig?.imageHeight || 0,
|
height: cropConfig?.imageHeight || 0,
|
||||||
@@ -49,11 +61,13 @@ const CropStep = () => {
|
|||||||
cropConfig?.selection ? cropConfig.selection : DEFAULT_SELECTION,
|
cropConfig?.selection ? cropConfig.selection : DEFAULT_SELECTION,
|
||||||
);
|
);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [savingCover, setSavingCover] = useState(false);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const dragInfoRef = useRef(null);
|
const dragInfoRef = useRef(null);
|
||||||
const canProceed =
|
const hasGeneralTargets = nonCoverImages.length > 0;
|
||||||
Boolean(cropConfig?.imageWidth) &&
|
const canProceed = hasGeneralTargets ? Boolean(cropConfig?.imageWidth) : true;
|
||||||
cropConfig?.referenceImageId === selectedImageId;
|
const isCoverSelected = selectedImageId && selectedImageId === coverImageId;
|
||||||
|
const activeConfig = isCoverSelected ? coverCropConfig : cropConfig;
|
||||||
|
|
||||||
const selectedImage = useMemo(
|
const selectedImage = useMemo(
|
||||||
() => uploadedImages.find((img) => img.id === selectedImageId),
|
() => uploadedImages.find((img) => img.id === selectedImageId),
|
||||||
@@ -62,10 +76,14 @@ const CropStep = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uploadedImages.length) return;
|
if (!uploadedImages.length) return;
|
||||||
if (!selectedImage && uploadedImages[0]) {
|
if (!selectedImage) {
|
||||||
setSelectedImageId(uploadedImages[0].id);
|
const fallback =
|
||||||
|
nonCoverImages[0] || uploadedImages[0];
|
||||||
|
if (fallback) {
|
||||||
|
setSelectedImageId(fallback.id);
|
||||||
}
|
}
|
||||||
}, [selectedImage, uploadedImages]);
|
}
|
||||||
|
}, [nonCoverImages, selectedImage, uploadedImages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedImage) return;
|
if (!selectedImage) return;
|
||||||
@@ -78,16 +96,16 @@ const CropStep = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
cropConfig?.referenceImageId === selectedImageId &&
|
activeConfig?.referenceImageId === selectedImageId &&
|
||||||
cropConfig?.imageWidth
|
activeConfig?.imageWidth
|
||||||
) {
|
) {
|
||||||
setSelection(selectionFromConfig(cropConfig));
|
setSelection(selectionFromConfig(activeConfig));
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
} else {
|
} else {
|
||||||
setSelection(DEFAULT_SELECTION);
|
setSelection(DEFAULT_SELECTION);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
}
|
}
|
||||||
}, [cropConfig, selectedImageId]);
|
}, [activeConfig, selectedImageId]);
|
||||||
|
|
||||||
const applyMove = (base, deltaX, deltaY) => {
|
const applyMove = (base, deltaX, deltaY) => {
|
||||||
const maxLeft = 100 - base.width;
|
const maxLeft = 100 - base.width;
|
||||||
@@ -209,7 +227,7 @@ const CropStep = () => {
|
|||||||
};
|
};
|
||||||
}, [imageSize.height, imageSize.width, selection]);
|
}, [imageSize.height, imageSize.width, selection]);
|
||||||
|
|
||||||
const handleSaveCrop = () => {
|
const handleSaveCrop = async () => {
|
||||||
if (!currentCropArea || !selectedImage) return;
|
if (!currentCropArea || !selectedImage) return;
|
||||||
const config = {
|
const config = {
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -228,6 +246,21 @@ const CropStep = () => {
|
|||||||
referenceImageId: selectedImage.id,
|
referenceImageId: selectedImage.id,
|
||||||
selection,
|
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);
|
updateCropConfig(config);
|
||||||
resetFromStep('crop');
|
resetFromStep('crop');
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -270,11 +303,14 @@ const CropStep = () => {
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedImageId(image.id);
|
setSelectedImageId(image.id);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
|
if (image.id !== coverImageId) {
|
||||||
resetFromStep('crop');
|
resetFromStep('crop');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -282,11 +318,39 @@ const CropStep = () => {
|
|||||||
alt={image.filename}
|
alt={image.filename}
|
||||||
style={{ width: '100%', display: 'block' }}
|
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>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</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 && (
|
{selectedImage && (
|
||||||
<Box sx={{ width: '100%', overflow: 'hidden', borderRadius: 2, backgroundColor: '#0000000a' }}>
|
<Box sx={{ width: '100%', overflow: 'hidden', borderRadius: 2, backgroundColor: '#0000000a' }}>
|
||||||
<Box
|
<Box
|
||||||
@@ -351,7 +415,9 @@ const CropStep = () => {
|
|||||||
{saved && (
|
{saved && (
|
||||||
<Stack direction="row" spacing={1} alignItems="center" color="success.main">
|
<Stack direction="row" spacing={1} alignItems="center" color="success.main">
|
||||||
<CheckCircleIcon color="success" />
|
<CheckCircleIcon color="success" />
|
||||||
<Typography>Crop ayarı kaydedildi.</Typography>
|
<Typography>
|
||||||
|
{isCoverSelected ? 'Kapak crop ayarı kaydedildi.' : 'Crop ayarı kaydedildi.'}
|
||||||
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -360,8 +426,12 @@ const CropStep = () => {
|
|||||||
Geri dön
|
Geri dön
|
||||||
</Button>
|
</Button>
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
<Button variant="contained" onClick={handleSaveCrop} disabled={!currentCropArea}>
|
<Button
|
||||||
Bu crop ayarını tüm görsellere uygula
|
variant="contained"
|
||||||
|
onClick={handleSaveCrop}
|
||||||
|
disabled={!currentCropArea || (isCoverSelected && savingCover)}
|
||||||
|
>
|
||||||
|
{isCoverSelected ? 'Kapak görselini cropla' : 'Bu crop ayarını tüm görsellere uygula'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|||||||
@@ -17,15 +17,18 @@ const EpubStep = () => {
|
|||||||
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
||||||
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
||||||
const setError = useAppStore((state) => state.setError);
|
const setError = useAppStore((state) => state.setError);
|
||||||
|
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||||
|
const croppedCoverImage = useAppStore((state) => state.croppedCoverImage);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const needsCoverCrop = Boolean(coverImageId && !croppedCoverImage);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!ocrText?.trim() || generatedEpub) return;
|
if (!ocrText?.trim() || generatedEpub || needsCoverCrop) return;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
const epub = await createEpubFromOcr(ocrText);
|
const epub = await createEpubFromOcr(ocrText, croppedCoverImage);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setGeneratedEpub(epub);
|
setGeneratedEpub(epub);
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ const EpubStep = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [generatedEpub, ocrText, setError, setGeneratedEpub]);
|
}, [croppedCoverImage, generatedEpub, needsCoverCrop, ocrText, setError, setGeneratedEpub]);
|
||||||
|
|
||||||
if (!ocrText?.trim()) {
|
if (!ocrText?.trim()) {
|
||||||
return (
|
return (
|
||||||
@@ -64,12 +67,40 @@ const EpubStep = () => {
|
|||||||
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 />}
|
{processing && <LinearProgress />}
|
||||||
{generatedEpub && (
|
{generatedEpub && (
|
||||||
<Alert severity="success">
|
<Alert severity="success">
|
||||||
EPUB hazır: {generatedEpub.filename}
|
EPUB hazır: {generatedEpub.filename}
|
||||||
</Alert>
|
</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">
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||||
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
||||||
@@ -77,7 +108,7 @@ const EpubStep = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!generatedEpub || processing}
|
disabled={!generatedEpub || processing || needsCoverCrop}
|
||||||
onClick={() => navigate('/download')}
|
onClick={() => navigate('/download')}
|
||||||
>
|
>
|
||||||
EPUB'i indir
|
EPUB'i indir
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const UploadStep = () => {
|
|||||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||||
const setUploadedImages = useAppStore((state) => state.setUploadedImages);
|
const setUploadedImages = useAppStore((state) => state.setUploadedImages);
|
||||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||||
|
const coverImageId = useAppStore((state) => state.coverImageId);
|
||||||
|
const setCoverImageId = useAppStore((state) => state.setCoverImageId);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles) => {
|
(acceptedFiles) => {
|
||||||
@@ -45,6 +47,11 @@ const UploadStep = () => {
|
|||||||
[uploadedImages, resetFromStep, setUploadedImages],
|
[uploadedImages, resetFromStep, setUploadedImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCoverToggle = (imageId) => {
|
||||||
|
const nextId = coverImageId === imageId ? null : imageId;
|
||||||
|
setCoverImageId(nextId);
|
||||||
|
};
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
@@ -98,6 +105,18 @@ const UploadStep = () => {
|
|||||||
<Typography variant="body2" noWrap>
|
<Typography variant="body2" noWrap>
|
||||||
{image.filename}
|
{image.filename}
|
||||||
</Typography>
|
</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>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
const emptyCropConfig = {
|
const createEmptyCropConfig = () => ({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
@@ -15,12 +15,16 @@ const emptyCropConfig = {
|
|||||||
imageWidth: 0,
|
imageWidth: 0,
|
||||||
imageHeight: 0,
|
imageHeight: 0,
|
||||||
referenceImageId: null,
|
referenceImageId: null,
|
||||||
};
|
selection: null,
|
||||||
|
});
|
||||||
|
|
||||||
export const useAppStore = create((set) => ({
|
export const useAppStore = create((set) => ({
|
||||||
uploadedImages: [],
|
uploadedImages: [],
|
||||||
cropConfig: emptyCropConfig,
|
cropConfig: createEmptyCropConfig(),
|
||||||
croppedImages: [],
|
croppedImages: [],
|
||||||
|
coverImageId: null,
|
||||||
|
coverCropConfig: createEmptyCropConfig(),
|
||||||
|
croppedCoverImage: null,
|
||||||
ocrText: '',
|
ocrText: '',
|
||||||
generatedEpub: null,
|
generatedEpub: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -28,6 +32,7 @@ export const useAppStore = create((set) => ({
|
|||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
setUploadedImages: (images) => set({ uploadedImages: images }),
|
setUploadedImages: (images) => set({ uploadedImages: images }),
|
||||||
updateCropConfig: (config) => set({ cropConfig: { ...config } }),
|
updateCropConfig: (config) => set({ cropConfig: { ...config } }),
|
||||||
|
updateCoverCropConfig: (config) => set({ coverCropConfig: { ...config } }),
|
||||||
setCroppedImages: (images) =>
|
setCroppedImages: (images) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.croppedImages.forEach((img) => {
|
state.croppedImages.forEach((img) => {
|
||||||
@@ -35,6 +40,13 @@ export const useAppStore = create((set) => ({
|
|||||||
});
|
});
|
||||||
return { croppedImages: images };
|
return { croppedImages: images };
|
||||||
}),
|
}),
|
||||||
|
setCroppedCoverImage: (image) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.croppedCoverImage?.url) {
|
||||||
|
URL.revokeObjectURL(state.croppedCoverImage.url);
|
||||||
|
}
|
||||||
|
return { croppedCoverImage: image };
|
||||||
|
}),
|
||||||
setOcrText: (text) => set({ ocrText: text }),
|
setOcrText: (text) => set({ ocrText: text }),
|
||||||
setGeneratedEpub: (epub) =>
|
setGeneratedEpub: (epub) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -43,13 +55,35 @@ export const useAppStore = create((set) => ({
|
|||||||
}
|
}
|
||||||
return { generatedEpub: epub };
|
return { generatedEpub: epub };
|
||||||
}),
|
}),
|
||||||
|
setCoverImageId: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const draft = {
|
||||||
|
coverImageId: id,
|
||||||
|
coverCropConfig: createEmptyCropConfig(),
|
||||||
|
};
|
||||||
|
if (state.croppedCoverImage?.url) {
|
||||||
|
URL.revokeObjectURL(state.croppedCoverImage.url);
|
||||||
|
}
|
||||||
|
draft.croppedCoverImage = null;
|
||||||
|
if (state.generatedEpub?.url) {
|
||||||
|
URL.revokeObjectURL(state.generatedEpub.url);
|
||||||
|
draft.generatedEpub = null;
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}),
|
||||||
resetFromStep: (step) =>
|
resetFromStep: (step) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const draft = {};
|
const draft = {};
|
||||||
if (step === 'upload') {
|
if (step === 'upload') {
|
||||||
draft.cropConfig = emptyCropConfig;
|
draft.cropConfig = createEmptyCropConfig();
|
||||||
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
||||||
draft.croppedImages = [];
|
draft.croppedImages = [];
|
||||||
|
if (state.croppedCoverImage?.url) {
|
||||||
|
URL.revokeObjectURL(state.croppedCoverImage.url);
|
||||||
|
}
|
||||||
|
draft.coverImageId = null;
|
||||||
|
draft.coverCropConfig = createEmptyCropConfig();
|
||||||
|
draft.croppedCoverImage = null;
|
||||||
draft.ocrText = '';
|
draft.ocrText = '';
|
||||||
if (state.generatedEpub?.url) {
|
if (state.generatedEpub?.url) {
|
||||||
URL.revokeObjectURL(state.generatedEpub.url);
|
URL.revokeObjectURL(state.generatedEpub.url);
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ const cropImage = async (file, normalizedConfig) => {
|
|||||||
return { blob, url };
|
return { blob, url };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildCropResult = (imageMeta, blob, url) => ({
|
||||||
|
id: imageMeta.id,
|
||||||
|
filename: imageMeta.file?.name || imageMeta.filename,
|
||||||
|
blob,
|
||||||
|
url,
|
||||||
|
order: imageMeta.order,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cropSingleImage = async (image, config) => {
|
||||||
|
if (!config || !config.imageWidth) {
|
||||||
|
throw new Error('Kapak için geçerli bir crop ayarı bulunamadı.');
|
||||||
|
}
|
||||||
|
const normalized = normalizeCropConfig(config);
|
||||||
|
const { blob, url } = await cropImage(image.file, normalized);
|
||||||
|
return buildCropResult(image, blob, url);
|
||||||
|
};
|
||||||
|
|
||||||
export const applyCropToImages = async (images, config) => {
|
export const applyCropToImages = async (images, config) => {
|
||||||
if (!images?.length) {
|
if (!images?.length) {
|
||||||
throw new Error('Önce görsel yüklemelisin.');
|
throw new Error('Önce görsel yüklemelisin.');
|
||||||
@@ -91,13 +108,7 @@ export const applyCropToImages = async (images, config) => {
|
|||||||
const results = [];
|
const results = [];
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
const { blob, url } = await cropImage(image.file, normalized);
|
const { blob, url } = await cropImage(image.file, normalized);
|
||||||
results.push({
|
results.push(buildCropResult(image, blob, url));
|
||||||
id: image.id,
|
|
||||||
filename: image.file.name,
|
|
||||||
blob,
|
|
||||||
url,
|
|
||||||
order: image.order,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,13 +8,38 @@ const base64ToBlob = (base64, mimeType) => {
|
|||||||
return new Blob([bytes], { type: mimeType });
|
return new Blob([bytes], { type: mimeType });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const blobToBase64 = (blob) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
const commaIndex = result.indexOf(',');
|
||||||
|
resolve(result.slice(commaIndex + 1));
|
||||||
|
} else {
|
||||||
|
reject(new Error('Kapak dosyası okunamadı.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Kapak dosyası okunamadı.'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
||||||
|
|
||||||
export const createEpubFromOcr = async (text) => {
|
export const createEpubFromOcr = async (text, coverImage) => {
|
||||||
if (!text?.trim()) {
|
if (!text?.trim()) {
|
||||||
throw new Error('Önce OCR adımını tamamlamalısın.');
|
throw new Error('Önce OCR adımını tamamlamalısın.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coverPayload = null;
|
||||||
|
if (coverImage?.blob) {
|
||||||
|
coverPayload = {
|
||||||
|
data: await blobToBase64(coverImage.blob),
|
||||||
|
mimeType: coverImage.blob.type || 'image/png',
|
||||||
|
filename: coverImage.filename || `cover-${Date.now()}.png`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/generate-epub`, {
|
const response = await fetch(`${API_BASE}/generate-epub`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -25,6 +50,7 @@ export const createEpubFromOcr = async (text) => {
|
|||||||
author: 'imgPub',
|
author: 'imgPub',
|
||||||
filename: `imgpub${Date.now()}.epub`,
|
filename: `imgpub${Date.now()}.epub`,
|
||||||
},
|
},
|
||||||
|
cover: coverPayload,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user