diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f045c93
--- /dev/null
+++ b/.dockerignore
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 43616b2..bb79f70 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,15 +5,16 @@ services:
target: dev
environment:
- VITE_API_BASE_URL=http://localhost:4000
+ - CHOKIDAR_USEPOLLING=1
ports:
- "5173:5173"
volumes:
- - .:/app
- - frontend_dev_node_modules:/app/node_modules
+ - .:/app:delegated
+ - /app/node_modules
depends_on:
- backend-dev
profiles:
- - dev
+ - imgpub-app-dev
backend-dev:
build:
@@ -25,10 +26,10 @@ services:
ports:
- "4000:4000"
volumes:
- - ./server:/app
- - backend_dev_node_modules:/app/node_modules
+ - ./server:/app:delegated
+ - /app/node_modules
profiles:
- - dev
+ - imgpub-app-dev
frontend-prod:
build:
@@ -43,7 +44,7 @@ services:
depends_on:
- backend-prod
profiles:
- - prod
+ - imgpub-app
backend-prod:
build:
@@ -55,8 +56,4 @@ services:
ports:
- "4000:4000"
profiles:
- - prod
-
-volumes:
- frontend_dev_node_modules:
- backend_dev_node_modules:
+ - imgpub-app
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..f3a0141
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,8 @@
+.git
+node_modules
+.env
+.env.*
+*.log
+tmp
+coverage
+.DS_Store
diff --git a/server/index.js b/server/index.js
index 229e435..2ead27d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -23,7 +23,7 @@ const sanitizeHtml = (text = '') =>
.replace(/\n/g, '
');
app.post('/generate-epub', async (req, res) => {
- const { text, meta } = req.body || {};
+ const { text, meta, cover } = req.body || {};
if (!text || !text.trim()) {
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`);
+ let coverPath;
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;
const buffer = await fs.readFile(outputPath);
await fs.unlink(outputPath).catch(() => {});
+ if (coverPath) {
+ await fs.unlink(coverPath).catch(() => {});
+ }
res.json({ filename, data: buffer.toString('base64') });
} catch (error) {
console.error('EPUB generation failed:', error);
diff --git a/src/components/BulkCropStep.jsx b/src/components/BulkCropStep.jsx
index b08973b..3540809 100644
--- a/src/components/BulkCropStep.jsx
+++ b/src/components/BulkCropStep.jsx
@@ -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 (
Crop ayarını kaydetmeden bu adıma geçemezsin.
@@ -70,6 +87,17 @@ const BulkCropStep = () => {
);
}
+ if (!targetImages.length) {
+ return (
+
+ Kapak dışında crop uygulanacak görsel bulunmuyor. Bu adımı geçebilirsin.
+
+
+ );
+ }
+
return (
diff --git a/src/components/CropStep.jsx b/src/components/CropStep.jsx
index 6dc9391..f9c0f5d 100644
--- a/src/components/CropStep.jsx
+++ b/src/components/CropStep.jsx
@@ -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');
+ }
}}
>
{
alt={image.filename}
style={{ width: '100%', display: 'block' }}
/>
+ {image.id === coverImageId && (
+
+ Kapak
+
+ )}
))}
+ {isCoverSelected ? (
+
+ Kapak görseli için ayrı bir crop ayarı oluşturuyorsun. Bu ayar OCR sırasını etkilemez.
+
+ ) : (
+
+ Seçtiğin referans, kapak dışındaki tüm görsellere uygulanacak.
+
+ )}
+
{selectedImage && (
{
{saved && (
- Crop ayarı kaydedildi.
+
+ {isCoverSelected ? 'Kapak crop ayarı kaydedildi.' : 'Crop ayarı kaydedildi.'}
+
)}
@@ -360,8 +426,12 @@ const CropStep = () => {
Geri dön
-
+ {needsCoverCrop && (
+
+ Kapak olarak işaretlediğin görseli croplamalısın. Crop adımında kapak görselini kaydet ve tekrar dene.
+
+ )}
{processing && }
{generatedEpub && (
EPUB hazır: {generatedEpub.filename}
)}
+ {croppedCoverImage ? (
+
+ Kapak önizlemesi
+
+
+ ) : (
+ coverImageId && (
+
+ Kapak seçili ancak crop işlemi tamamlanmadı. Crop adımına dönerek kapak kesimini belirle.
+
+ )
+ )}
navigate('/ocr')}>
@@ -77,7 +108,7 @@ const EpubStep = () => {
navigate('/download')}
>
EPUB'i indir
diff --git a/src/components/UploadStep.jsx b/src/components/UploadStep.jsx
index f035d0d..42bf326 100644
--- a/src/components/UploadStep.jsx
+++ b/src/components/UploadStep.jsx
@@ -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 = () => {
{image.filename}
+ {
+ event.stopPropagation();
+ handleCoverToggle(image.id);
+ }}
+ >
+ {image.id === coverImageId ? 'Kapak seçildi' : 'Kapak olarak işaretle'}
+
diff --git a/src/store/useAppStore.js b/src/store/useAppStore.js
index fdb8a93..692b822 100644
--- a/src/store/useAppStore.js
+++ b/src/store/useAppStore.js
@@ -1,6 +1,6 @@
import { create } from 'zustand';
-const emptyCropConfig = {
+const createEmptyCropConfig = () => ({
x: 0,
y: 0,
zoom: 1,
@@ -15,12 +15,16 @@ const emptyCropConfig = {
imageWidth: 0,
imageHeight: 0,
referenceImageId: null,
-};
+ selection: null,
+});
export const useAppStore = create((set) => ({
uploadedImages: [],
- cropConfig: emptyCropConfig,
+ cropConfig: createEmptyCropConfig(),
croppedImages: [],
+ coverImageId: null,
+ coverCropConfig: createEmptyCropConfig(),
+ croppedCoverImage: null,
ocrText: '',
generatedEpub: null,
error: null,
@@ -28,6 +32,7 @@ export const useAppStore = create((set) => ({
clearError: () => set({ error: null }),
setUploadedImages: (images) => set({ uploadedImages: images }),
updateCropConfig: (config) => set({ cropConfig: { ...config } }),
+ updateCoverCropConfig: (config) => set({ coverCropConfig: { ...config } }),
setCroppedImages: (images) =>
set((state) => {
state.croppedImages.forEach((img) => {
@@ -35,6 +40,13 @@ export const useAppStore = create((set) => ({
});
return { croppedImages: images };
}),
+ setCroppedCoverImage: (image) =>
+ set((state) => {
+ if (state.croppedCoverImage?.url) {
+ URL.revokeObjectURL(state.croppedCoverImage.url);
+ }
+ return { croppedCoverImage: image };
+ }),
setOcrText: (text) => set({ ocrText: text }),
setGeneratedEpub: (epub) =>
set((state) => {
@@ -43,13 +55,35 @@ export const useAppStore = create((set) => ({
}
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) =>
set((state) => {
const draft = {};
if (step === 'upload') {
- draft.cropConfig = emptyCropConfig;
+ draft.cropConfig = createEmptyCropConfig();
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
draft.croppedImages = [];
+ if (state.croppedCoverImage?.url) {
+ URL.revokeObjectURL(state.croppedCoverImage.url);
+ }
+ draft.coverImageId = null;
+ draft.coverCropConfig = createEmptyCropConfig();
+ draft.croppedCoverImage = null;
draft.ocrText = '';
if (state.generatedEpub?.url) {
URL.revokeObjectURL(state.generatedEpub.url);
diff --git a/src/utils/cropUtils.js b/src/utils/cropUtils.js
index e373d39..a1b40b9 100644
--- a/src/utils/cropUtils.js
+++ b/src/utils/cropUtils.js
@@ -80,6 +80,23 @@ const cropImage = async (file, normalizedConfig) => {
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) => {
if (!images?.length) {
throw new Error('Önce görsel yüklemelisin.');
@@ -91,13 +108,7 @@ export const applyCropToImages = async (images, config) => {
const results = [];
for (const image of images) {
const { blob, url } = await cropImage(image.file, normalized);
- results.push({
- id: image.id,
- filename: image.file.name,
- blob,
- url,
- order: image.order,
- });
+ results.push(buildCropResult(image, blob, url));
}
return results;
};
diff --git a/src/utils/epubUtils.js b/src/utils/epubUtils.js
index bebc5b0..eb898eb 100644
--- a/src/utils/epubUtils.js
+++ b/src/utils/epubUtils.js
@@ -8,13 +8,38 @@ const base64ToBlob = (base64, 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';
-export const createEpubFromOcr = async (text) => {
+export const createEpubFromOcr = async (text, coverImage) => {
if (!text?.trim()) {
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -25,6 +50,7 @@ export const createEpubFromOcr = async (text) => {
author: 'imgPub',
filename: `imgpub${Date.now()}.epub`,
},
+ cover: coverPayload,
}),
});