feat: add cover selection workflow and docker profiles

This commit is contained in:
2025-11-11 01:49:54 +03:00
parent db7de897a0
commit 98746fab39
11 changed files with 310 additions and 53 deletions

16
.dockerignore Normal file
View 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

View File

@@ -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
View File

@@ -0,0 +1,8 @@
.git
node_modules
.env
.env.*
*.log
tmp
coverage
.DS_Store

View File

@@ -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);

View File

@@ -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&apos;ye geç
</Button>
</Stack>
);
}
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<Box textAlign="center"> <Box textAlign="center">

View File

@@ -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"

View File

@@ -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&apos;i indir EPUB&apos;i indir

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
}; };

View File

@@ -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,
}), }),
}); });