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
|
||||
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
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/>');
|
||||
|
||||
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);
|
||||
|
||||
@@ -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'ye geç
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'i indir
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user