Files
imgPub/src/components/UploadStep.jsx

587 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&apos;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;