first commit
This commit is contained in:
72
src/App.jsx
Normal file
72
src/App.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Snackbar,
|
||||
Step,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
|
||||
export const wizardSteps = [
|
||||
{ label: 'Yükle', path: '/' },
|
||||
{ label: 'Crop', path: '/crop' },
|
||||
{ label: 'Toplu Crop', path: '/bulk-crop' },
|
||||
{ label: 'OCR', path: '/ocr' },
|
||||
{ label: 'EPUB Oluştur', path: '/epub' },
|
||||
{ label: 'İndir', path: '/download' },
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const error = useAppStore((state) => state.error);
|
||||
const clearError = useAppStore((state) => state.clearError);
|
||||
|
||||
const handleSnackbarClose = (_, reason) => {
|
||||
if (reason === 'clickaway') return;
|
||||
clearError();
|
||||
};
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
const foundIndex = wizardSteps.findIndex((step) => step.path === location.pathname);
|
||||
return foundIndex === -1 ? 0 : foundIndex;
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box mb={4}>
|
||||
<Typography variant="h4" gutterBottom sx={{ color: '#29615D', fontWeight: 700 }}>
|
||||
imgPub EPUB Sihirbazı
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Görselleri sırayla işle, OCR ile metne dönüştür ve EPUB formatında indir.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }} elevation={0}>
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{wizardSteps.map((step) => (
|
||||
<Step key={step.path} onClick={() => navigate(step.path)} sx={{ cursor: 'pointer' }}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
<Paper sx={{ p: { xs: 2, md: 4 }, minHeight: 400 }} elevation={0}>
|
||||
<Outlet />
|
||||
</Paper>
|
||||
<Snackbar open={Boolean(error)} autoHideDuration={4000} onClose={handleSnackbarClose}>
|
||||
<Alert onClose={handleSnackbarClose} severity="error" sx={{ width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
111
src/components/BulkCropStep.jsx
Normal file
111
src/components/BulkCropStep.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { applyCropToImages } from '../utils/cropUtils';
|
||||
|
||||
const BulkCropStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
||||
const setCroppedImages = useAppStore((state) => state.setCroppedImages);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const croppedImages = useAppStore((state) => state.croppedImages);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const runCrop = async () => {
|
||||
if (!uploadedImages.length || !cropConfig?.imageWidth) return;
|
||||
setProcessing(true);
|
||||
try {
|
||||
const results = await applyCropToImages(uploadedImages, cropConfig);
|
||||
if (!cancelled) {
|
||||
setCroppedImages(results);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setError(error.message);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!croppedImages.length && uploadedImages.length && cropConfig?.imageWidth) {
|
||||
runCrop();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cropConfig, croppedImages.length, setCroppedImages, setError, uploadedImages]);
|
||||
|
||||
if (!uploadedImages.length) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Önce görselleri yükle ve crop alanını belirle.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/')}>
|
||||
Başlangıca dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cropConfig?.imageWidth) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="warning">Crop ayarını kaydetmeden bu adıma geçemezsin.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/crop')}>
|
||||
Crop adımına dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Toplu crop işlemi
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Tüm görsellere referans crop ayarı uygulanıyor.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<LinearProgress
|
||||
variant={processing ? 'indeterminate' : 'determinate'}
|
||||
value={processing ? 0 : 100}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
/>
|
||||
<Typography mt={2} align="center">
|
||||
{processing
|
||||
? 'İşlem yapılıyor...'
|
||||
: `${croppedImages.length} görsel hazır.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Button variant="contained" onClick={() => navigate('/crop')}>
|
||||
Geri dön
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/ocr')}
|
||||
disabled={!croppedImages.length || processing}
|
||||
>
|
||||
OCR'ye geç
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkCropStep;
|
||||
432
src/components/CropStep.jsx
Normal file
432
src/components/CropStep.jsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Slider,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
const offsetFields = ['top', 'bottom', 'left', 'right'];
|
||||
const MIN_SELECTION = 5;
|
||||
const DEFAULT_SELECTION = { top: 10, left: 10, width: 80, height: 80 };
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const selectionFromConfig = (config) => {
|
||||
if (!config?.imageWidth || !config?.imageHeight) return DEFAULT_SELECTION;
|
||||
const fallback = {
|
||||
left: (config.cropAreaX / config.imageWidth) * 100,
|
||||
top: (config.cropAreaY / config.imageHeight) * 100,
|
||||
width: (config.width / config.imageWidth) * 100,
|
||||
height: (config.height / config.imageHeight) * 100,
|
||||
};
|
||||
return {
|
||||
top: config.selection?.top ?? fallback.top,
|
||||
left: config.selection?.left ?? fallback.left,
|
||||
width: config.selection?.width ?? fallback.width,
|
||||
height: config.selection?.height ?? fallback.height,
|
||||
};
|
||||
};
|
||||
|
||||
const CropStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const cropConfig = useAppStore((state) => state.cropConfig);
|
||||
const updateCropConfig = useAppStore((state) => state.updateCropConfig);
|
||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||
const [selectedImageId, setSelectedImageId] = useState(
|
||||
cropConfig?.referenceImageId || uploadedImages[0]?.id || null,
|
||||
);
|
||||
const [offsetValues, setOffsetValues] = useState({
|
||||
top: cropConfig?.top || 0,
|
||||
bottom: cropConfig?.bottom || 0,
|
||||
left: cropConfig?.left || 0,
|
||||
right: cropConfig?.right || 0,
|
||||
});
|
||||
const [imageSize, setImageSize] = useState({
|
||||
width: cropConfig?.imageWidth || 0,
|
||||
height: cropConfig?.imageHeight || 0,
|
||||
});
|
||||
const [selection, setSelection] = useState(
|
||||
cropConfig?.selection ? cropConfig.selection : DEFAULT_SELECTION,
|
||||
);
|
||||
const [previewScale, setPreviewScale] = useState(1);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const dragInfoRef = useRef(null);
|
||||
const canProceed =
|
||||
Boolean(cropConfig?.imageWidth) &&
|
||||
cropConfig?.referenceImageId === selectedImageId;
|
||||
|
||||
const selectedImage = useMemo(
|
||||
() => uploadedImages.find((img) => img.id === selectedImageId),
|
||||
[selectedImageId, uploadedImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uploadedImages.length) return;
|
||||
if (!selectedImage && uploadedImages[0]) {
|
||||
setSelectedImageId(uploadedImages[0].id);
|
||||
}
|
||||
}, [selectedImage, uploadedImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImage) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageSize({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = selectedImage.previewUrl;
|
||||
}, [selectedImage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cropConfig?.referenceImageId === selectedImageId &&
|
||||
cropConfig?.imageWidth
|
||||
) {
|
||||
setSelection(selectionFromConfig(cropConfig));
|
||||
setOffsetValues({
|
||||
top: cropConfig.top || 0,
|
||||
bottom: cropConfig.bottom || 0,
|
||||
left: cropConfig.left || 0,
|
||||
right: cropConfig.right || 0,
|
||||
});
|
||||
setSaved(true);
|
||||
} else {
|
||||
setSelection(DEFAULT_SELECTION);
|
||||
setSaved(false);
|
||||
}
|
||||
}, [cropConfig, selectedImageId]);
|
||||
|
||||
const applyMove = (base, deltaX, deltaY) => {
|
||||
const maxLeft = 100 - base.width;
|
||||
const maxTop = 100 - base.height;
|
||||
return {
|
||||
...base,
|
||||
left: clamp(base.left + deltaX, 0, maxLeft),
|
||||
top: clamp(base.top + deltaY, 0, maxTop),
|
||||
};
|
||||
};
|
||||
|
||||
const applyNorth = (base, deltaY) => {
|
||||
const limit = base.top + base.height - MIN_SELECTION;
|
||||
const newTop = clamp(base.top + deltaY, 0, limit);
|
||||
const newHeight = clamp(base.height + (base.top - newTop), MIN_SELECTION, 100 - newTop);
|
||||
return { ...base, top: newTop, height: newHeight };
|
||||
};
|
||||
|
||||
const applySouth = (base, deltaY) => {
|
||||
const newHeight = clamp(
|
||||
base.height + deltaY,
|
||||
MIN_SELECTION,
|
||||
100 - base.top,
|
||||
);
|
||||
return { ...base, height: newHeight };
|
||||
};
|
||||
|
||||
const applyWest = (base, deltaX) => {
|
||||
const limit = base.left + base.width - MIN_SELECTION;
|
||||
const newLeft = clamp(base.left + deltaX, 0, limit);
|
||||
const newWidth = clamp(
|
||||
base.width + (base.left - newLeft),
|
||||
MIN_SELECTION,
|
||||
100 - newLeft,
|
||||
);
|
||||
return { ...base, left: newLeft, width: newWidth };
|
||||
};
|
||||
|
||||
const applyEast = (base, deltaX) => {
|
||||
const newWidth = clamp(
|
||||
base.width + deltaX,
|
||||
MIN_SELECTION,
|
||||
100 - base.left,
|
||||
);
|
||||
return { ...base, width: newWidth };
|
||||
};
|
||||
|
||||
const resizeSelection = (base, type, deltaX, deltaY) => {
|
||||
let draft = { ...base };
|
||||
if (type.includes('n')) {
|
||||
draft = applyNorth(draft, deltaY);
|
||||
}
|
||||
if (type.includes('s')) {
|
||||
draft = applySouth(draft, deltaY);
|
||||
}
|
||||
if (type.includes('w')) {
|
||||
draft = applyWest(draft, deltaX);
|
||||
}
|
||||
if (type.includes('e')) {
|
||||
draft = applyEast(draft, deltaX);
|
||||
}
|
||||
return draft;
|
||||
};
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
const dragInfo = dragInfoRef.current;
|
||||
if (!dragInfo || !containerRef.current) return;
|
||||
const bounds = containerRef.current.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
const deltaX = ((event.clientX - dragInfo.startX) / bounds.width) * 100;
|
||||
const deltaY = ((event.clientY - dragInfo.startY) / bounds.height) * 100;
|
||||
let nextSelection = dragInfo.selection;
|
||||
if (dragInfo.type === 'move') {
|
||||
nextSelection = applyMove(dragInfo.selection, deltaX, deltaY);
|
||||
} else {
|
||||
nextSelection = resizeSelection(dragInfo.selection, dragInfo.type, deltaX, deltaY);
|
||||
}
|
||||
setSelection(nextSelection);
|
||||
setSaved(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
dragInfoRef.current = null;
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', stopDragging);
|
||||
}, [handlePointerMove]);
|
||||
|
||||
const startDrag = (type) => (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!containerRef.current) return;
|
||||
dragInfoRef.current = {
|
||||
type,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
selection,
|
||||
};
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', stopDragging);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopDragging();
|
||||
},
|
||||
[stopDragging],
|
||||
);
|
||||
|
||||
const handleOffsetChange = (field) => (event) => {
|
||||
const value = Number(event.target.value) || 0;
|
||||
setOffsetValues((prev) => ({ ...prev, [field]: value }));
|
||||
setSaved(false);
|
||||
resetFromStep('crop');
|
||||
};
|
||||
|
||||
const currentCropArea = useMemo(() => {
|
||||
if (!imageSize.width || !imageSize.height) return null;
|
||||
return {
|
||||
width: (selection.width / 100) * imageSize.width,
|
||||
height: (selection.height / 100) * imageSize.height,
|
||||
x: (selection.left / 100) * imageSize.width,
|
||||
y: (selection.top / 100) * imageSize.height,
|
||||
};
|
||||
}, [imageSize.height, imageSize.width, selection]);
|
||||
|
||||
const handleSaveCrop = () => {
|
||||
if (!currentCropArea || !selectedImage) return;
|
||||
const config = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: previewScale,
|
||||
width: currentCropArea.width,
|
||||
height: currentCropArea.height,
|
||||
top: offsetValues.top,
|
||||
bottom: offsetValues.bottom,
|
||||
left: offsetValues.left,
|
||||
right: offsetValues.right,
|
||||
cropAreaX: currentCropArea.x,
|
||||
cropAreaY: currentCropArea.y,
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
referenceImageId: selectedImage.id,
|
||||
selection,
|
||||
};
|
||||
updateCropConfig(config);
|
||||
resetFromStep('crop');
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
if (!uploadedImages.length) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Önce görsel yüklemelisin.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/')}>
|
||||
Yüklemeye dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleConfigs = [
|
||||
{ key: 'n', type: 'n', style: { top: -6, left: '50%', transform: 'translate(-50%, -50%)', cursor: 'ns-resize' } },
|
||||
{ key: 's', type: 's', style: { bottom: -6, left: '50%', transform: 'translate(-50%, 50%)', cursor: 'ns-resize' } },
|
||||
{ key: 'w', type: 'w', style: { left: -6, top: '50%', transform: 'translate(-50%, -50%)', cursor: 'ew-resize' } },
|
||||
{ key: 'e', type: 'e', style: { right: -6, top: '50%', transform: 'translate(50%, -50%)', cursor: 'ew-resize' } },
|
||||
{ key: 'nw', type: 'nw', style: { top: -6, left: -6, cursor: 'nwse-resize' } },
|
||||
{ key: 'ne', type: 'ne', style: { top: -6, right: -6, cursor: 'nesw-resize' } },
|
||||
{ key: 'sw', type: 'sw', style: { bottom: -6, left: -6, cursor: 'nesw-resize' } },
|
||||
{ key: 'se', type: 'se', style: { bottom: -6, right: -6, cursor: 'nwse-resize' } },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Typography variant="h6">Referans görseli seç</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{uploadedImages.map((image) => (
|
||||
<Grid item xs={6} sm={4} md={3} key={image.id}>
|
||||
<Box
|
||||
sx={{
|
||||
border:
|
||||
image.id === selectedImageId
|
||||
? '3px solid #000'
|
||||
: '2px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedImageId(image.id);
|
||||
setSaved(false);
|
||||
resetFromStep('crop');
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={image.filename}
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{selectedImage && (
|
||||
<Box sx={{ width: '100%', overflow: 'hidden', borderRadius: 2, backgroundColor: '#0000000a' }}>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: 410,
|
||||
mx: 'auto',
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: 'center top',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={selectedImage.previewUrl}
|
||||
alt={selectedImage.filename}
|
||||
style={{ width: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="presentation"
|
||||
onPointerDown={startDrag('move')}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: `${selection.top}%`,
|
||||
left: `${selection.left}%`,
|
||||
width: `${selection.width}%`,
|
||||
height: `${selection.height}%`,
|
||||
border: '3px solid #000',
|
||||
boxShadow: '0 0 0 9999px rgba(0,0,0,0.45)',
|
||||
cursor: 'move',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{handleConfigs.map((handle) => (
|
||||
<Box
|
||||
key={handle.key}
|
||||
role="presentation"
|
||||
onPointerDown={startDrag(handle.type)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#000',
|
||||
border: '2px solid #fff',
|
||||
...handle.style,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography gutterBottom>Önizleme yakınlaştırması</Typography>
|
||||
<Slider
|
||||
min={1}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={previewScale}
|
||||
onChange={(_, value) => {
|
||||
const newValue = Array.isArray(value) ? value[0] : value;
|
||||
setPreviewScale(newValue);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{offsetFields.map((field) => (
|
||||
<Grid key={field} item xs={6} md={3}>
|
||||
<TextField
|
||||
label={field.toUpperCase()}
|
||||
type="number"
|
||||
fullWidth
|
||||
value={offsetValues[field]}
|
||||
onChange={handleOffsetChange(field)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{saved && (
|
||||
<Stack direction="row" spacing={1} alignItems="center" color="success.main">
|
||||
<CheckCircleIcon color="success" />
|
||||
<Typography>Crop ayarı kaydedildi.</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Button variant="contained" onClick={() => navigate('/')}>
|
||||
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>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/bulk-crop')}
|
||||
disabled={!canProceed}
|
||||
>
|
||||
Devam et
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropStep;
|
||||
44
src/components/DownloadStep.jsx
Normal file
44
src/components/DownloadStep.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Alert, Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { downloadBlob } from '../utils/fileUtils';
|
||||
|
||||
const DownloadStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
||||
|
||||
if (!generatedEpub) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Önce EPUB dosyasını oluştur.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/epub')}>
|
||||
EPUB adımına dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5">EPUB hazır</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Tüm OCR metinleri tek bir EPUB dosyasında toplandı.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => downloadBlob(generatedEpub.url, generatedEpub.filename)}
|
||||
>
|
||||
EPUB'i indir
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => navigate('/')}
|
||||
>
|
||||
Baştan başla
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadStep;
|
||||
90
src/components/EpubStep.jsx
Normal file
90
src/components/EpubStep.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { createEpubFromOcr } from '../utils/epubUtils';
|
||||
|
||||
const EpubStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const ocrText = useAppStore((state) => state.ocrText);
|
||||
const generatedEpub = useAppStore((state) => state.generatedEpub);
|
||||
const setGeneratedEpub = useAppStore((state) => state.setGeneratedEpub);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
if (!ocrText?.trim() || generatedEpub) return;
|
||||
setProcessing(true);
|
||||
try {
|
||||
const epub = await createEpubFromOcr(ocrText);
|
||||
if (!cancelled) {
|
||||
setGeneratedEpub(epub);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setError(error.message || 'EPUB oluşturulamadı.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [generatedEpub, ocrText, setError, setGeneratedEpub]);
|
||||
|
||||
if (!ocrText?.trim()) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Önce OCR adımını tamamla.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
||||
OCR adımına dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5">EPUB çıktısı</Typography>
|
||||
<Typography color="text.secondary">
|
||||
OCR sonucundaki tüm metinleri tek bir EPUB dosyasında topluyoruz.
|
||||
</Typography>
|
||||
</Box>
|
||||
{processing && <LinearProgress />}
|
||||
{generatedEpub && (
|
||||
<Alert severity="success">
|
||||
EPUB hazır: {generatedEpub.filename}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Button variant="contained" onClick={() => navigate('/ocr')}>
|
||||
Geri dön
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!generatedEpub || processing}
|
||||
onClick={() => navigate('/download')}
|
||||
>
|
||||
EPUB'i indir
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpubStep;
|
||||
239
src/components/OcrStep.jsx
Normal file
239
src/components/OcrStep.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { correctTurkishCharacters } from '../utils/ocrUtils';
|
||||
|
||||
const OcrStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const croppedImages = useAppStore((state) => state.croppedImages);
|
||||
const ocrText = useAppStore((state) => state.ocrText);
|
||||
const setOcrText = useAppStore((state) => state.setOcrText);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const total = croppedImages.length;
|
||||
const hasResults = useMemo(() => Boolean(ocrText?.length), [ocrText]);
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const assetBase = useMemo(() => {
|
||||
const base = import.meta.env.BASE_URL ?? '/';
|
||||
return base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
}, []);
|
||||
const workerRef = useRef(null);
|
||||
const [workerReady, setWorkerReady] = useState(false);
|
||||
const previewRef = useRef(null);
|
||||
|
||||
const orderedImages = useMemo(
|
||||
() => [...croppedImages].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
[croppedImages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedImages.length) return undefined;
|
||||
let cancelled = false;
|
||||
const origin =
|
||||
typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const prefix = `${origin}${assetBase}`;
|
||||
const paths = {
|
||||
workerPath: `${prefix}/tesseract/worker.min.js`,
|
||||
corePath: `${prefix}/tesseract/tesseract-core-simd-lstm.wasm.js`,
|
||||
langPath: `${prefix}/tesseract`,
|
||||
};
|
||||
|
||||
const initWorker = async () => {
|
||||
setWorkerReady(false);
|
||||
try {
|
||||
const worker = await Tesseract.createWorker(
|
||||
'tur', // Dil doğrudan belirt
|
||||
1, // OEM level (LSTM)
|
||||
{
|
||||
workerPath: paths.workerPath,
|
||||
corePath: paths.corePath,
|
||||
langPath: paths.langPath,
|
||||
logger: m => console.log('Tesseract:', m), // Debug için log
|
||||
},
|
||||
);
|
||||
|
||||
// Türkçe karakter tanımını iyileştir
|
||||
await worker.setParameters({
|
||||
tessedit_char_whitelist: 'abcçdefgğhıijklmnoöprsştuüvyzâîûABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZÂÎÛ0123456789 .,;:!?\'"-_',
|
||||
tessedit_pageseg_mode: '6', // Tek bir metin bloğu varsay
|
||||
preserve_interword_spaces: '1',
|
||||
});
|
||||
if (cancelled) {
|
||||
await worker.terminate();
|
||||
return;
|
||||
}
|
||||
// Dil ve worker zaten createWorker sırasında yüklendi
|
||||
console.log('Tesseract worker başarıyla oluşturuldu');
|
||||
workerRef.current = worker;
|
||||
setWorkerReady(true);
|
||||
} catch (error) {
|
||||
console.error('Tesseract başlatma hatası:', error);
|
||||
let errorMessage;
|
||||
|
||||
if (error.message.includes('traineddata')) {
|
||||
errorMessage = 'Tesseract dil dosyaları bulunamadı. Lütfen tarayıcı cache\'ini temizleyip sayfayı yenileyin.';
|
||||
} else if (error.message.includes('TESSDATA_PREFIX')) {
|
||||
errorMessage = 'Tesseract yapılandırma hatası: Lütfen sayfayı yenileyin.';
|
||||
} else {
|
||||
errorMessage = `Tesseract başlatılamadı: ${error.message}`;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setWorkerReady(false);
|
||||
}
|
||||
};
|
||||
|
||||
initWorker();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
setWorkerReady(false);
|
||||
}
|
||||
};
|
||||
}, [assetBase, orderedImages.length, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('idle');
|
||||
setCurrentIndex(0);
|
||||
setPreviewText('');
|
||||
setOcrText('');
|
||||
}, [orderedImages, setOcrText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewRef.current) {
|
||||
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
||||
}
|
||||
}, [previewText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!total || status === 'done' || !workerReady) return;
|
||||
abortRef.current = false;
|
||||
const run = async () => {
|
||||
setStatus('running');
|
||||
setCurrentIndex(0);
|
||||
const worker = workerRef.current;
|
||||
if (!worker) return;
|
||||
try {
|
||||
let combinedText = '';
|
||||
setOcrText('');
|
||||
setPreviewText('');
|
||||
for (let index = 0; index < orderedImages.length; index += 1) {
|
||||
if (abortRef.current) break;
|
||||
const image = orderedImages[index];
|
||||
setCurrentIndex(index + 1);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { data } = await worker.recognize(image.blob);
|
||||
const correctedText = correctTurkishCharacters(data.text || '');
|
||||
if (correctedText) {
|
||||
combinedText = combinedText
|
||||
? `${combinedText}\n\n${correctedText}`
|
||||
: correctedText;
|
||||
setPreviewText(combinedText);
|
||||
}
|
||||
}
|
||||
if (!abortRef.current) {
|
||||
setOcrText(combinedText);
|
||||
setStatus('done');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abortRef.current) {
|
||||
setError(error.message);
|
||||
setStatus('idle');
|
||||
}
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
abortRef.current = true;
|
||||
};
|
||||
}, [orderedImages, setError, setOcrText, status, total, workerReady]);
|
||||
|
||||
if (!orderedImages.length) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info">Önce görselleri cropla.</Alert>
|
||||
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
|
||||
Toplu Crop adımına dön
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const progressValue =
|
||||
workerReady && total ? (currentIndex / total) * 100 : 0;
|
||||
const progressVariant = workerReady ? 'determinate' : 'indeterminate';
|
||||
const progressText = !workerReady
|
||||
? 'OCR işçisi hazırlanıyor...'
|
||||
: status === 'done'
|
||||
? 'OCR işlemi tamamlandı.'
|
||||
: `Şu an ${currentIndex}/${total} resim işleniyor`;
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5">OCR işlemi</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Tüm görseller sırayla işleniyor. Bu adım biraz sürebilir.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<LinearProgress
|
||||
variant={progressVariant}
|
||||
value={progressVariant === 'determinate' ? progressValue : undefined}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
/>
|
||||
<Typography mt={2} align="center">
|
||||
{progressText}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ p: 2, borderRadius: 2, bgcolor: 'background.default' }}>
|
||||
<Typography variant="subtitle1">Ön izleme</Typography>
|
||||
<Box
|
||||
ref={previewRef}
|
||||
sx={{
|
||||
mt: 1,
|
||||
maxHeight: '8.5em',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.5,
|
||||
fontSize: '0.95rem',
|
||||
color: 'text.secondary',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
{previewText || 'Metin bekleniyor'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between">
|
||||
<Button variant="contained" onClick={() => navigate('/bulk-crop')}>
|
||||
Geri dön
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/epub')}
|
||||
disabled={!hasResults}
|
||||
>
|
||||
EPUB oluştur
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OcrStep;
|
||||
124
src/components/UploadStep.jsx
Normal file
124
src/components/UploadStep.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
const dropzoneStyle = {
|
||||
border: '2px dashed rgba(108, 155, 207, 0.7)',
|
||||
borderRadius: 12,
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'rgba(108, 155, 207, 0.08)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const UploadStep = () => {
|
||||
const navigate = useNavigate();
|
||||
const uploadedImages = useAppStore((state) => state.uploadedImages);
|
||||
const setUploadedImages = useAppStore((state) => state.setUploadedImages);
|
||||
const resetFromStep = useAppStore((state) => state.resetFromStep);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles) => {
|
||||
if (!acceptedFiles.length) return;
|
||||
resetFromStep('upload');
|
||||
const mapped = acceptedFiles.map((file, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
order: uploadedImages.length + index,
|
||||
filename: file.name,
|
||||
}));
|
||||
setUploadedImages([...uploadedImages, ...mapped]);
|
||||
},
|
||||
[uploadedImages, resetFromStep, setUploadedImages],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
},
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Box {...getRootProps()} sx={dropzoneStyle}>
|
||||
<input {...getInputProps()} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Görselleri sürükleyip bırak veya tıkla
|
||||
</Typography>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
.png, .jpg, .jpeg formatlarında çoklu dosya yükleyebilirsin.
|
||||
</Typography>
|
||||
<Button variant="contained" color="primary">
|
||||
Dosya seç
|
||||
</Button>
|
||||
{isDragActive && (
|
||||
<Typography mt={2} fontWeight={600}>
|
||||
Bırak ve yükleyelim!
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Yüklenen görseller ({uploadedImages.length})
|
||||
</Typography>
|
||||
{uploadedImages.length === 0 ? (
|
||||
<Typography color="text.secondary">
|
||||
Henüz görsel yüklenmedi.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{uploadedImages.map((image) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={image.id}>
|
||||
<Card>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="160"
|
||||
image={image.previewUrl}
|
||||
alt={image.filename}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="body2" noWrap>
|
||||
{image.filename}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!uploadedImages.length}
|
||||
onClick={() => navigate('/crop')}
|
||||
>
|
||||
Devam et
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadStep;
|
||||
127
src/main.jsx
Normal file
127
src/main.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import App, { wizardSteps } from './App';
|
||||
import UploadStep from './components/UploadStep';
|
||||
import CropStep from './components/CropStep';
|
||||
import BulkCropStep from './components/BulkCropStep';
|
||||
import OcrStep from './components/OcrStep';
|
||||
import EpubStep from './components/EpubStep';
|
||||
import DownloadStep from './components/DownloadStep';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#E7C179',
|
||||
contrastText: '#30281B',
|
||||
},
|
||||
secondary: {
|
||||
main: '#29615D',
|
||||
},
|
||||
background: {
|
||||
default: '#F9F7F4',
|
||||
paper: '#FAF8F6',
|
||||
},
|
||||
text: {
|
||||
primary: '#30281B',
|
||||
secondary: '#666057',
|
||||
},
|
||||
success: {
|
||||
main: '#80A19F',
|
||||
contrastText: '#30281B',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
components: {
|
||||
MuiBox: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#80A19F',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
boxShadow: '0 6px 20px rgba(231, 193, 121, 0.35)',
|
||||
'&:hover': {
|
||||
backgroundColor: '#d7b16a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: '#FAF8F6',
|
||||
boxShadow: '0 20px 45px rgba(0,0,0,0.08)',
|
||||
borderRadius: 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: '#80A19F',
|
||||
color: '#30281B',
|
||||
borderRadius: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSvgIcon: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#B5AD9A',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiStepIcon: {
|
||||
styleOverrides: {
|
||||
text: {
|
||||
fill: '#F9F7F3',
|
||||
fontWeight: 700,
|
||||
},
|
||||
root: {
|
||||
color: '#B5AD9A',
|
||||
'&.Mui-active': {
|
||||
color: '#E7C179',
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
color: '#E7C179',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <App />,
|
||||
children: [
|
||||
{ index: true, element: <UploadStep /> },
|
||||
{ path: wizardSteps[1].path, element: <CropStep /> },
|
||||
{ path: wizardSteps[2].path, element: <BulkCropStep /> },
|
||||
{ path: wizardSteps[3].path, element: <OcrStep /> },
|
||||
{ path: wizardSteps[4].path, element: <EpubStep /> },
|
||||
{ path: wizardSteps[5].path, element: <DownloadStep /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
83
src/store/useAppStore.js
Normal file
83
src/store/useAppStore.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const emptyCropConfig = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
cropAreaX: 0,
|
||||
cropAreaY: 0,
|
||||
imageWidth: 0,
|
||||
imageHeight: 0,
|
||||
referenceImageId: null,
|
||||
};
|
||||
|
||||
export const useAppStore = create((set) => ({
|
||||
uploadedImages: [],
|
||||
cropConfig: emptyCropConfig,
|
||||
croppedImages: [],
|
||||
ocrText: '',
|
||||
generatedEpub: null,
|
||||
error: null,
|
||||
setError: (message) => set({ error: message }),
|
||||
clearError: () => set({ error: null }),
|
||||
setUploadedImages: (images) => set({ uploadedImages: images }),
|
||||
updateCropConfig: (config) => set({ cropConfig: { ...config } }),
|
||||
setCroppedImages: (images) =>
|
||||
set((state) => {
|
||||
state.croppedImages.forEach((img) => {
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
});
|
||||
return { croppedImages: images };
|
||||
}),
|
||||
setOcrText: (text) => set({ ocrText: text }),
|
||||
setGeneratedEpub: (epub) =>
|
||||
set((state) => {
|
||||
if (state.generatedEpub?.url) {
|
||||
URL.revokeObjectURL(state.generatedEpub.url);
|
||||
}
|
||||
return { generatedEpub: epub };
|
||||
}),
|
||||
resetFromStep: (step) =>
|
||||
set((state) => {
|
||||
const draft = {};
|
||||
if (step === 'upload') {
|
||||
draft.cropConfig = emptyCropConfig;
|
||||
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
||||
draft.croppedImages = [];
|
||||
draft.ocrText = '';
|
||||
if (state.generatedEpub?.url) {
|
||||
URL.revokeObjectURL(state.generatedEpub.url);
|
||||
}
|
||||
draft.generatedEpub = null;
|
||||
}
|
||||
if (step === 'crop') {
|
||||
state.croppedImages.forEach((img) => img.url && URL.revokeObjectURL(img.url));
|
||||
draft.croppedImages = [];
|
||||
draft.ocrText = '';
|
||||
if (state.generatedEpub?.url) {
|
||||
URL.revokeObjectURL(state.generatedEpub.url);
|
||||
}
|
||||
draft.generatedEpub = null;
|
||||
}
|
||||
if (step === 'ocr') {
|
||||
draft.ocrText = '';
|
||||
if (state.generatedEpub?.url) {
|
||||
URL.revokeObjectURL(state.generatedEpub.url);
|
||||
}
|
||||
draft.generatedEpub = null;
|
||||
}
|
||||
if (step === 'epub' || step === 'download') {
|
||||
if (state.generatedEpub?.url) {
|
||||
URL.revokeObjectURL(state.generatedEpub.url);
|
||||
}
|
||||
draft.generatedEpub = null;
|
||||
}
|
||||
return draft;
|
||||
}),
|
||||
}));
|
||||
103
src/utils/cropUtils.js
Normal file
103
src/utils/cropUtils.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const loadImage = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(image.src);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
URL.revokeObjectURL(image.src);
|
||||
reject(error);
|
||||
};
|
||||
image.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const normalizeCropConfig = (config) => {
|
||||
if (!config?.imageWidth || !config?.imageHeight) {
|
||||
throw new Error('Geçerli bir crop referansı bulunamadı.');
|
||||
}
|
||||
const safeWidth = Math.max(
|
||||
1,
|
||||
config.width - (config.left ?? 0) - (config.right ?? 0),
|
||||
);
|
||||
const safeHeight = Math.max(
|
||||
1,
|
||||
config.height - (config.top ?? 0) - (config.bottom ?? 0),
|
||||
);
|
||||
const xStart = Math.max(0, config.cropAreaX + (config.left ?? 0));
|
||||
const yStart = Math.max(0, config.cropAreaY + (config.top ?? 0));
|
||||
return {
|
||||
xRatio: xStart / config.imageWidth,
|
||||
yRatio: yStart / config.imageHeight,
|
||||
widthRatio: safeWidth / config.imageWidth,
|
||||
heightRatio: safeHeight / config.imageHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const cropImage = async (file, normalizedConfig) => {
|
||||
const image = await loadImage(file);
|
||||
const { width: imgWidth, height: imgHeight } = image;
|
||||
const cropWidth = clamp(
|
||||
Math.round(normalizedConfig.widthRatio * imgWidth),
|
||||
1,
|
||||
imgWidth,
|
||||
);
|
||||
const cropHeight = clamp(
|
||||
Math.round(normalizedConfig.heightRatio * imgHeight),
|
||||
1,
|
||||
imgHeight,
|
||||
);
|
||||
const startX = clamp(
|
||||
Math.round(normalizedConfig.xRatio * imgWidth),
|
||||
0,
|
||||
imgWidth - cropWidth,
|
||||
);
|
||||
const startY = clamp(
|
||||
Math.round(normalizedConfig.yRatio * imgHeight),
|
||||
0,
|
||||
imgHeight - cropHeight,
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = cropWidth;
|
||||
canvas.height = cropHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(image, startX, startY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
|
||||
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Canvas blob oluşturulamadı.'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { blob, url };
|
||||
};
|
||||
|
||||
export const applyCropToImages = async (images, config) => {
|
||||
if (!images?.length) {
|
||||
throw new Error('Önce görsel yüklemelisin.');
|
||||
}
|
||||
if (!config || !config.imageWidth) {
|
||||
throw new Error('Crop ayarı bulunamadı.');
|
||||
}
|
||||
const normalized = normalizeCropConfig(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,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
46
src/utils/epubUtils.js
Normal file
46
src/utils/epubUtils.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const base64ToBlob = (base64, mimeType) => {
|
||||
const binary = atob(base64);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
};
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000';
|
||||
|
||||
export const createEpubFromOcr = async (text) => {
|
||||
if (!text?.trim()) {
|
||||
throw new Error('Önce OCR adımını tamamlamalısın.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/generate-epub`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
meta: {
|
||||
title: 'imgPub OCR Export',
|
||||
author: 'imgPub',
|
||||
filename: `imgpub${Date.now()}.epub`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || 'EPUB oluşturma isteği başarısız.');
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const blob = base64ToBlob(payload.data, 'application/epub+zip');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return {
|
||||
filename: payload.filename,
|
||||
blob,
|
||||
url,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
9
src/utils/fileUtils.js
Normal file
9
src/utils/fileUtils.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const downloadBlob = (blobUrl, filename) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = blobUrl;
|
||||
anchor.download = filename;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
};
|
||||
6
src/utils/ocrUtils.js
Normal file
6
src/utils/ocrUtils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const correctTurkishCharacters = (text = '') =>
|
||||
text
|
||||
.replace(/İ/g, 'İ')
|
||||
.replace(/i̇/g, 'i')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
Reference in New Issue
Block a user