From 98746fab39c2473589ce77537e83dfeb138c160e Mon Sep 17 00:00:00 2001 From: szbk Date: Tue, 11 Nov 2025 01:49:54 +0300 Subject: [PATCH] feat: add cover selection workflow and docker profiles --- .dockerignore | 16 +++++ docker-compose.yml | 21 +++---- server/.dockerignore | 8 +++ server/index.js | 21 ++++++- src/components/BulkCropStep.jsx | 40 ++++++++++-- src/components/CropStep.jsx | 104 ++++++++++++++++++++++++++------ src/components/EpubStep.jsx | 39 ++++++++++-- src/components/UploadStep.jsx | 19 ++++++ src/store/useAppStore.js | 42 +++++++++++-- src/utils/cropUtils.js | 25 +++++--- src/utils/epubUtils.js | 28 ++++++++- 11 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 .dockerignore create mode 100644 server/.dockerignore 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 - 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, }), });