first commit

This commit is contained in:
2025-11-10 23:35:59 +03:00
commit 68165014ad
33 changed files with 2084 additions and 0 deletions

View 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&apos;ye geç
</Button>
</Stack>
</Stack>
);
};
export default BulkCropStep;

432
src/components/CropStep.jsx Normal file
View 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;

View 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&apos;i indir
</Button>
<Button variant="text" onClick={() => navigate('/')}
>
Baştan başla
</Button>
</Stack>
);
};
export default DownloadStep;

View 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&apos;i indir
</Button>
</Stack>
</Stack>
);
};
export default EpubStep;

239
src/components/OcrStep.jsx Normal file
View 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;

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