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

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/>');
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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