587 lines
21 KiB
JavaScript
587 lines
21 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useDropzone } from 'react-dropzone';
|
||
import {
|
||
Box,
|
||
Button,
|
||
Card,
|
||
CardActionArea,
|
||
CardContent,
|
||
CardMedia,
|
||
ClickAwayListener,
|
||
Divider,
|
||
Grid,
|
||
LinearProgress,
|
||
Paper,
|
||
Stack,
|
||
TextField,
|
||
Typography,
|
||
} from '@mui/material';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAppStore } from '../store/useAppStore';
|
||
import { extractTextFromEpub } from '../utils/epubImport';
|
||
|
||
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 coverImageId = useAppStore((state) => state.coverImageId);
|
||
const setCoverImageId = useAppStore((state) => state.setCoverImageId);
|
||
const bookTitle = useAppStore((state) => state.bookTitle);
|
||
const setBookTitle = useAppStore((state) => state.setBookTitle);
|
||
const bookMetadata = useAppStore((state) => state.bookMetadata);
|
||
const setBookMetadata = useAppStore((state) => state.setBookMetadata);
|
||
const epubImports = useAppStore((state) => state.epubImports);
|
||
const setEpubImports = useAppStore((state) => state.setEpubImports);
|
||
const setOcrText = useAppStore((state) => state.setOcrText);
|
||
const clearTranslation = useAppStore((state) => state.clearTranslation);
|
||
const setError = useAppStore((state) => state.setError);
|
||
const [searchResults, setSearchResults] = useState([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchError, setSearchError] = useState(null);
|
||
const [selectedBookId, setSelectedBookId] = useState(bookMetadata?.id || null);
|
||
const skipSearchRef = useRef(false);
|
||
const [showResults, setShowResults] = useState(false);
|
||
const [epubProcessing, setEpubProcessing] = useState(false);
|
||
|
||
const onDrop = useCallback(
|
||
async (acceptedFiles) => {
|
||
if (!acceptedFiles.length) return;
|
||
setEpubProcessing(true);
|
||
const preservedMetadata = bookMetadata;
|
||
const preservedTitle = bookTitle;
|
||
resetFromStep('upload');
|
||
if (preservedMetadata) {
|
||
skipSearchRef.current = true;
|
||
setBookMetadata(preservedMetadata);
|
||
setBookTitle(preservedTitle || preservedMetadata.title || '');
|
||
} else if (preservedTitle?.trim()) {
|
||
skipSearchRef.current = true;
|
||
setBookTitle(preservedTitle);
|
||
}
|
||
|
||
const imageFiles = [];
|
||
const epubFiles = [];
|
||
acceptedFiles.forEach((file) => {
|
||
const isEpub =
|
||
file.type === 'application/epub+zip' || file.name?.toLowerCase().endsWith('.epub');
|
||
if (isEpub) {
|
||
epubFiles.push(file);
|
||
} else {
|
||
imageFiles.push(file);
|
||
}
|
||
});
|
||
|
||
if (imageFiles.length) {
|
||
const mapped = imageFiles.map((file, index) => ({
|
||
id: crypto.randomUUID(),
|
||
file,
|
||
previewUrl: URL.createObjectURL(file),
|
||
order: uploadedImages.length + index,
|
||
filename: file.name,
|
||
}));
|
||
setUploadedImages([...uploadedImages, ...mapped]);
|
||
}
|
||
|
||
const importedEntries = [];
|
||
if (epubFiles.length) {
|
||
for (const file of epubFiles) {
|
||
try {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const parsed = await extractTextFromEpub(file);
|
||
importedEntries.push({
|
||
id: crypto.randomUUID(),
|
||
filename: file.name,
|
||
size: file.size,
|
||
text: parsed.text,
|
||
metadata: parsed.metadata,
|
||
});
|
||
} catch (error) {
|
||
setError(error.message || `${file.name} okunamadı.`);
|
||
}
|
||
}
|
||
setEpubImports(importedEntries);
|
||
clearTranslation();
|
||
const combinedText = importedEntries.map((entry) => entry.text).filter(Boolean).join('\n\n');
|
||
if (combinedText) {
|
||
setOcrText(combinedText);
|
||
}
|
||
if (!preservedMetadata && importedEntries[0]?.metadata) {
|
||
const meta = importedEntries[0].metadata;
|
||
setBookMetadata({
|
||
id: `epub-${crypto.randomUUID()}`,
|
||
title: meta.title || bookTitle || 'İsimsiz EPUB',
|
||
subtitle: '',
|
||
authors: meta.authors || [],
|
||
publisher: meta.publisher || '',
|
||
publishedDate: meta.publishedDate || '',
|
||
description: meta.description || '',
|
||
pageCount: null,
|
||
categories: meta.categories || [],
|
||
averageRating: null,
|
||
ratingsCount: null,
|
||
language: meta.language || '',
|
||
infoLink: '',
|
||
identifiers: meta.identifiers || [],
|
||
thumbnail: null,
|
||
});
|
||
if (!preservedTitle?.trim() && meta.title) {
|
||
skipSearchRef.current = true;
|
||
setBookTitle(meta.title);
|
||
}
|
||
}
|
||
} else {
|
||
setEpubImports([]);
|
||
}
|
||
setEpubProcessing(false);
|
||
},
|
||
[
|
||
bookMetadata,
|
||
bookTitle,
|
||
clearTranslation,
|
||
resetFromStep,
|
||
setBookMetadata,
|
||
setBookTitle,
|
||
setEpubImports,
|
||
setError,
|
||
setOcrText,
|
||
setUploadedImages,
|
||
uploadedImages,
|
||
],
|
||
);
|
||
|
||
const handleCoverToggle = (imageId) => {
|
||
const nextId = coverImageId === imageId ? null : imageId;
|
||
setCoverImageId(nextId);
|
||
};
|
||
|
||
useEffect(() => {
|
||
setSelectedBookId(bookMetadata?.id || null);
|
||
}, [bookMetadata]);
|
||
|
||
const normalizeVolume = useCallback((volume) => {
|
||
const info = volume?.volumeInfo || {};
|
||
const identifiers = Array.isArray(info.industryIdentifiers)
|
||
? info.industryIdentifiers.map((identifier) => ({
|
||
type: identifier?.type,
|
||
identifier: identifier?.identifier,
|
||
}))
|
||
: [];
|
||
const thumbnail =
|
||
info.imageLinks?.thumbnail?.replace('http://', 'https://') ||
|
||
info.imageLinks?.smallThumbnail?.replace('http://', 'https://') ||
|
||
null;
|
||
return {
|
||
id: volume.id,
|
||
title: info.title || 'İsimsiz kitap',
|
||
subtitle: info.subtitle || '',
|
||
authors: info.authors || [],
|
||
publisher: info.publisher || '',
|
||
publishedDate: info.publishedDate || '',
|
||
description: info.description || '',
|
||
pageCount: info.pageCount || null,
|
||
categories: info.categories || [],
|
||
averageRating: info.averageRating || null,
|
||
ratingsCount: info.ratingsCount || null,
|
||
language: info.language || '',
|
||
infoLink: info.infoLink || info.previewLink || '',
|
||
identifiers,
|
||
thumbnail,
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (skipSearchRef.current) {
|
||
skipSearchRef.current = false;
|
||
return;
|
||
}
|
||
const query = bookTitle?.trim();
|
||
if (!query) {
|
||
setSearchResults([]);
|
||
setSearchError(null);
|
||
setSearching(false);
|
||
setShowResults(false);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(async () => {
|
||
setSearching(true);
|
||
setSearchError(null);
|
||
try {
|
||
const response = await fetch(
|
||
`https://www.googleapis.com/books/v1/volumes?q=intitle:${encodeURIComponent(query)}&maxResults=5&printType=books`,
|
||
{ signal: controller.signal },
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error('Google Books sonuçları alınamadı.');
|
||
}
|
||
const payload = await response.json();
|
||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||
const normalized = items.map((item) => normalizeVolume(item));
|
||
setSearchResults(normalized);
|
||
setShowResults(Boolean(normalized.length));
|
||
if (!normalized.length) {
|
||
setSearchError('Bu başlıkla eşleşen bir kayıt bulunamadı.');
|
||
}
|
||
} catch (error) {
|
||
if (controller.signal.aborted) return;
|
||
setSearchResults([]);
|
||
setSearchError(error.message || 'Google Books araması başarısız oldu.');
|
||
setShowResults(false);
|
||
} finally {
|
||
if (!controller.signal.aborted) {
|
||
setSearching(false);
|
||
}
|
||
}
|
||
}, 500);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [bookTitle, normalizeVolume]);
|
||
|
||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||
onDrop,
|
||
accept: {
|
||
'image/png': ['.png'],
|
||
'image/jpeg': ['.jpg', '.jpeg'],
|
||
'image/webp': ['.webp'],
|
||
'application/epub+zip': ['.epub'],
|
||
'application/zip': ['.epub'],
|
||
'application/x-zip-compressed': ['.epub'],
|
||
'application/octet-stream': ['.epub'],
|
||
},
|
||
multiple: true,
|
||
});
|
||
|
||
const handleTitleChange = (event) => {
|
||
const value = event.target.value;
|
||
setBookTitle(value);
|
||
if (!value?.trim()) {
|
||
setBookMetadata(null);
|
||
setSelectedBookId(null);
|
||
setSearchResults([]);
|
||
setSearchError(null);
|
||
setShowResults(false);
|
||
} else if (bookMetadata && bookMetadata.title !== value) {
|
||
setBookMetadata(null);
|
||
setSelectedBookId(null);
|
||
}
|
||
setShowResults(Boolean(value?.trim()));
|
||
};
|
||
|
||
const handleSelectBook = (book) => {
|
||
skipSearchRef.current = true;
|
||
setSelectedBookId(book.id);
|
||
setBookMetadata(book);
|
||
setBookTitle(book.title || '');
|
||
setSearchResults([]);
|
||
setSearchError(null);
|
||
setShowResults(false);
|
||
};
|
||
|
||
const selectedBookSummary = useMemo(() => {
|
||
if (!bookMetadata) return null;
|
||
const authorsLine = bookMetadata.authors?.length ? bookMetadata.authors.join(', ') : null;
|
||
const details = [
|
||
bookMetadata.publisher,
|
||
bookMetadata.publishedDate,
|
||
bookMetadata.pageCount ? `${bookMetadata.pageCount} sayfa` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' • ');
|
||
return { authorsLine, details };
|
||
}, [bookMetadata]);
|
||
|
||
const hasImages = uploadedImages.length > 0;
|
||
const hasEpubImports = epubImports.length > 0;
|
||
const canProceed = hasImages || hasEpubImports;
|
||
const nextPath = hasImages ? '/crop' : '/ocr';
|
||
|
||
return (
|
||
<Stack spacing={4}>
|
||
{bookMetadata && (
|
||
<Typography variant="body2" color="success.main">
|
||
Seçilen kitap: <strong>{bookMetadata.title}</strong>
|
||
{bookMetadata.authors?.length ? ` • ${bookMetadata.authors.join(', ')}` : ''}
|
||
</Typography>
|
||
)}
|
||
<Box sx={{ position: 'relative', zIndex: 1 }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Kitap adı
|
||
</Typography>
|
||
<ClickAwayListener onClickAway={() => setShowResults(false)}>
|
||
<Box sx={{ position: 'relative' }}>
|
||
<TextField
|
||
fullWidth
|
||
placeholder="Örn. Yapay Zeka İmparatorluğu"
|
||
value={bookTitle}
|
||
onChange={handleTitleChange}
|
||
onFocus={() => searchResults.length && setShowResults(true)}
|
||
InputProps={{ sx: { borderRadius: 2 } }}
|
||
/>
|
||
{showResults && searchResults.length > 0 && bookTitle?.trim() && (
|
||
<Paper
|
||
variant="outlined"
|
||
sx={{
|
||
position: 'absolute',
|
||
top: 'calc(100% + 8px)',
|
||
left: 0,
|
||
right: 0,
|
||
borderRadius: 1.2,
|
||
zIndex: 10,
|
||
maxHeight: 420,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
boxShadow: (theme) => theme.shadows[6],
|
||
}}
|
||
>
|
||
<Typography variant="subtitle2" sx={{ px: 2, pt: 2, pb: 1, color: 'text.secondary' }}>
|
||
Google Books sonuçları
|
||
</Typography>
|
||
<Divider />
|
||
<Box sx={{ flex: 1, overflowY: 'auto', pr: 1.5 }}>
|
||
<Stack divider={<Divider />} spacing={0}>
|
||
{searchResults.map((book) => {
|
||
const detailLine = [
|
||
book.publisher,
|
||
book.publishedDate,
|
||
book.pageCount ? `${book.pageCount} sayfa` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' • ');
|
||
const ratingLine = [
|
||
book.averageRating ? `Puan ${book.averageRating}/5` : null,
|
||
book.ratingsCount ? `${book.ratingsCount} oy` : null,
|
||
book.language ? book.language.toUpperCase() : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' • ');
|
||
const isSelected = selectedBookId === book.id;
|
||
return (
|
||
<Box
|
||
key={book.id}
|
||
role="button"
|
||
onClick={() => handleSelectBook(book)}
|
||
sx={{
|
||
px: 2,
|
||
py: 2,
|
||
display: 'flex',
|
||
gap: 2,
|
||
alignItems: 'flex-start',
|
||
cursor: 'pointer',
|
||
bgcolor: isSelected ? 'rgba(231,193,121,0.15)' : 'transparent',
|
||
transition: 'background-color 0.2s ease',
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
width: 64,
|
||
height: 96,
|
||
borderRadius: 0.3,
|
||
overflow: 'hidden',
|
||
bgcolor: '#f0ece4',
|
||
flexShrink: 0,
|
||
border: '1px solid',
|
||
borderColor: 'divider',
|
||
}}
|
||
>
|
||
{book.thumbnail ? (
|
||
<Box
|
||
component="img"
|
||
src={book.thumbnail}
|
||
alt={`${book.title} kapak görseli`}
|
||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<Stack alignItems="center" justifyContent="center" sx={{ height: '100%' }}>
|
||
<Typography variant="caption" color="text.secondary">
|
||
Kapak yok
|
||
</Typography>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
<Box>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||
{book.title}
|
||
</Typography>
|
||
{book.subtitle && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
{book.subtitle}
|
||
</Typography>
|
||
)}
|
||
<Typography
|
||
variant="body2"
|
||
color="text.secondary"
|
||
sx={{ fontStyle: 'italic', mt: 0.5 }}
|
||
>
|
||
{book.authors?.length ? book.authors.join(', ') : 'Yazar bilgisi bulunamadı'}
|
||
</Typography>
|
||
{detailLine && (
|
||
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
||
{detailLine}
|
||
</Typography>
|
||
)}
|
||
{ratingLine && (
|
||
<Typography variant="caption" color="text.secondary" display="block">
|
||
{ratingLine}
|
||
</Typography>
|
||
)}
|
||
{book.categories?.length > 0 && (
|
||
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
||
{book.categories.join(', ')}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Box>
|
||
</Paper>
|
||
)}
|
||
</Box>
|
||
</ClickAwayListener>
|
||
<Typography variant="body2" color="text.secondary" mt={1}>
|
||
Google Books veritabanında arama yapmak için kitap adını yaz. Seçtiğin kaydın tüm meta bilgileri EPUB'a işlenecek.
|
||
</Typography>
|
||
{searching && <LinearProgress sx={{ mt: 2, borderRadius: 999 }} />}
|
||
{searchError && bookTitle.trim() && !searching && (
|
||
<Typography variant="body2" color="error.main" mt={1}>
|
||
{searchError}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
<Box {...getRootProps()} sx={dropzoneStyle}>
|
||
<input {...getInputProps()} />
|
||
<Typography variant="h5" gutterBottom>
|
||
Görselleri veya EPUB dosyasını sürükleyip bırak ya da tıkla
|
||
</Typography>
|
||
<Typography color="text.secondary" gutterBottom>
|
||
.png, .jpg, .jpeg formatlarında çoklu görsel ya da .epub dosyaları yükleyebilirsin.
|
||
</Typography>
|
||
<Button variant="contained" color="primary">
|
||
Dosya seç
|
||
</Button>
|
||
{isDragActive && (
|
||
<Typography mt={2} fontWeight={600}>
|
||
Bırak ve yükleyelim!
|
||
</Typography>
|
||
)}
|
||
{epubProcessing && (
|
||
<Typography mt={2} color="text.secondary">
|
||
EPUB içeriği ayrıştırılıyor...
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{epubImports.length > 0 && (
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom>
|
||
Yüklenen EPUB dosyaları ({epubImports.length})
|
||
</Typography>
|
||
<Stack spacing={1.5}>
|
||
{epubImports.map((item) => (
|
||
<Paper key={item.id} variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||
{item.metadata?.title || item.filename}
|
||
</Typography>
|
||
{item.metadata?.authors?.length ? (
|
||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||
{item.metadata.authors.join(', ')}
|
||
</Typography>
|
||
) : (
|
||
<Typography variant="body2" color="text.secondary">
|
||
Yazar bilgisi bulunamadı
|
||
</Typography>
|
||
)}
|
||
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
||
{[
|
||
item.metadata?.publisher,
|
||
item.metadata?.language ? item.metadata.language.toUpperCase() : null,
|
||
item.metadata?.publishedDate,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' • ')}
|
||
</Typography>
|
||
<Typography variant="caption" color="text.secondary" display="block">
|
||
{item.filename} • {(item.size / (1024 * 1024)).toFixed(2)} MB
|
||
</Typography>
|
||
</Paper>
|
||
))}
|
||
</Stack>
|
||
</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 component="div">
|
||
<CardMedia
|
||
component="img"
|
||
height="160"
|
||
image={image.previewUrl}
|
||
alt={image.filename}
|
||
/>
|
||
<CardContent>
|
||
<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>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
)}
|
||
</Box>
|
||
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="flex-end">
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
disabled={!canProceed}
|
||
onClick={() => navigate(nextPath)}
|
||
>
|
||
{hasImages ? 'Crop adımına geç' : 'OCR adımına geç'}
|
||
</Button>
|
||
</Stack>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
export default UploadStep;
|