/
viktorphp
/
front-socket
Обзор
Документация
Войти
/
viktorphp
/
front-socket
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/features/diary/entryEditor/ui/EntryEditor.tsx
475 строк
16 KB
Иванов Виктор Евгеньевич
[feature-diary]: поправить баги и верстку, добавить README.md
12 апр 2026, 20:01
12 апр 2026, 20:01
0d63759
Код
Авторство
О чём код?
'use client'; import { useEffect, useRef, useState } from 'react'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import AttachFileIcon from '@mui/icons-material/AttachFile'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DocumentScannerIcon from '@mui/icons-material/DocumentScanner'; import SaveIcon from '@mui/icons-material/Save'; import { Box, Button, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, Stack, TextField, Tooltip, Typography, } from '@mui/material'; import { useRouter } from 'next/navigation'; import { useDiaryStore } from '@entities/diary'; import { diaryApi } from '@shared/api/rest'; import { EAppRoutes } from '@shared/config'; import { IDiaryFile } from '@shared/types'; import { DiaryFileIcon } from './DiaryFileIcon'; import { FileViewerModal } from './FileViewerModal'; /** MIME-типы, поддерживаемые Yandex Vision OCR */ const OCR_SUPPORTED_MIMES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/tiff', 'application/pdf', ]); interface IEntryEditorProps { /** ID существующей записи (для режима редактирования). Undefined — создание новой. */ entryId?: string; } /** * Компонент редактора записи дневника. * Работает как для создания новой записи, так и для редактирования существующей. */ export const EntryEditor = ({ entryId }: IEntryEditorProps) => { const router = useRouter(); const { currentEntry, isLoadingEntry, setCurrentEntry, setIsLoadingEntry, removeEntry, removeFileFromCurrentEntry, setFilesInCurrentEntry, } = useDiaryStore((state) => state); // Текст записи const [text, setText] = useState(''); // Файлы, ожидающие загрузки (до сохранения записи) const [pendingFiles, setPendingFiles] = useState<File[]>([]); // Флаг: запись уже сохранена на бэкенде (есть ID) const [isSaved, setIsSaved] = useState(false); // Флаг: идёт загрузка файла const [isUploadingFile, setIsUploadingFile] = useState(false); // Открытый файл в просмотрщике const [viewingFile, setViewingFile] = useState<IDiaryFile | null>(null); // Диалог подтверждения удаления записи const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); // Диалог подтверждения удаления файла const [deletingFileId, setDeletingFileId] = useState<string | null>(null); // Скрытый input для выбора файлов const fileInputRef = useRef<HTMLInputElement>(null); // Текущая дата для новой записи (в формате YYYY-MM-DD) const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; /** Форматирует дату YYYY-MM-DD в читаемый вид */ const formatDisplayDate = (dateStr: string): string => { const [y, m, d] = dateStr.split('-'); return `${d}.${m}.${y}`; }; // Загрузка существующей записи при открытии редактора useEffect(() => { if (!entryId) { // Режим создания: сбрасываем стор setCurrentEntry(null); setText(''); setIsSaved(false); setIsLoadingEntry(false); return; } const load = async () => { setIsLoadingEntry(true); try { const entry = await diaryApi.getEntry(entryId); setCurrentEntry(entry); setText(entry.text); setIsSaved(true); } catch (error) { console.error('Ошибка загрузки записи:', error); } finally { setIsLoadingEntry(false); } }; load(); return () => setCurrentEntry(null); }, [entryId]); /** Сохранить запись: создать новую или обновить существующую */ const handleSave = async () => { setIsLoadingEntry(true); try { if (currentEntry) { // Обновление существующей записи const updated = await diaryApi.updateEntry(currentEntry.id, { text }); setCurrentEntry(updated); } else { // Создание новой записи const created = await diaryApi.createEntry({ date: todayStr, text }); setCurrentEntry(created); setIsSaved(true); // Загружаем ожидающие файлы, если они были прикреплены if (pendingFiles.length > 0) { const uploadedFiles = await diaryApi.addFiles( created.id, pendingFiles ); setFilesInCurrentEntry(uploadedFiles); setPendingFiles([]); } // Обновляем URL без перезагрузки страницы window.history.replaceState(null, '', `/diary/${created.id}`); } } catch (error) { console.error('Ошибка сохранения записи:', error); } finally { setIsLoadingEntry(false); } }; /** Обработка выбора файлов через input */ const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const files = Array.from(e.target.files ?? []); if (files.length === 0) return; if (currentEntry) { // Запись уже сохранена — сразу загружаем файлы setIsUploadingFile(true); try { const uploaded = await diaryApi.addFiles(currentEntry.id, files); // Добавляем новые файлы к уже существующим setFilesInCurrentEntry([...(currentEntry.files ?? []), ...uploaded]); } catch (error) { console.error('Ошибка загрузки файлов:', error); } finally { setIsUploadingFile(false); } } else { // Запись ещё не создана — накапливаем файлы в состоянии setPendingFiles((prev) => [...prev, ...files]); } // Сброс input, чтобы можно было выбрать те же файлы повторно if (fileInputRef.current) fileInputRef.current.value = ''; }; /** Запустить OCR для первого подходящего файла */ const handleStartOcr = async () => { if (!currentEntry) return; // Ищем первый файл с поддерживаемым MIME-типом, у которого OCR ещё не запускался const targetFile = currentEntry.files?.find((f) => OCR_SUPPORTED_MIMES.has(f.mimeType) ); if (!targetFile) return; try { await diaryApi.startOcr(targetFile.id); // Статус обновится через WebSocket (ocrComplete) → DiarySocketProvider → updateFileOcrResult } catch (error) { console.error('Ошибка запуска OCR:', error); } }; /** Подтвердить удаление з��писи */ const handleDeleteEntry = async () => { if (!currentEntry) return; setDeleteDialogOpen(false); setIsLoadingEntry(true); try { await diaryApi.deleteEntry(currentEntry.id); removeEntry(currentEntry.id); setIsLoadingEntry(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(EAppRoutes.DIARY as any); } catch (error) { console.error('Ошибка удаления записи:', error); setIsLoadingEntry(false); } }; /** Подтвердить удаление файла */ const handleDeleteFile = async (fileId: string) => { setDeletingFileId(null); try { await diaryApi.deleteFile(fileId); removeFileFromCurrentEntry(fileId); // Если просматривали этот файл — закрыть модалку if (viewingFile?.id === fileId) setViewingFile(null); } catch (error) { console.error('Ошибка удаления файла:', error); } }; // Проверяем, есть ли файлы, подходящие для OCR (для активации кнопки) const hasSupportedFileForOcr = currentEntry?.files?.some((f) => OCR_SUPPORTED_MIMES.has(f.mimeType)) ?? false; // Список файлов для отображения: сохранённые + ещё не загруженные (ожидающие) const savedFiles = currentEntry?.files ?? []; const entryDate = currentEntry?.date ?? todayStr; if (isLoadingEntry && !currentEntry && entryId) { return ( <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}> <CircularProgress color="primary" /> </Box> ); } return ( <Box sx={{ width: '100%', maxWidth: 1100, mx: 'auto', px: 3, py: 4, display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 220px)', }} > {/* Шапка: кнопка назад + дата */} <Stack direction="row" alignItems="center" spacing={2} mb={3}> <Button startIcon={<ArrowBackIcon />} // eslint-disable-next-line @typescript-eslint/no-explicit-any onClick={() => router.push(EAppRoutes.DIARY as any)} variant="text" color="inherit" sx={{ textTransform: 'none' }} > Назад </Button> <Typography variant="h6" color="text.secondary"> {formatDisplayDate(entryDate)} </Typography> </Stack> {/* Поле ввода текста */} <TextField fullWidth multiline placeholder="Что произошло сегодня..." value={text} onChange={(e) => setText(e.target.value)} variant="outlined" sx={{ mb: 2, flex: 1, '& .MuiInputBase-root': { height: '100%', alignItems: 'flex-start' }, '& .MuiInputBase-input': { height: '100% !important', overflowY: 'auto !important', }, }} /> {/* Прикреплённые файлы (сохранённые на сервере) */} {savedFiles.length > 0 && ( <Stack direction="row" flexWrap="wrap" gap={1} mb={2}> {savedFiles.map((file) => ( <DiaryFileIcon key={file.id} file={file} onClick={setViewingFile} onDelete={(fileId) => setDeletingFileId(fileId)} /> ))} </Stack> )} {/* Ожидающие загрузки файлы (только имена, пока запись не сохранена) */} {pendingFiles.length > 0 && ( <Stack direction="row" flexWrap="wrap" gap={1} mb={2}> {pendingFiles.map((file, idx) => ( <Chip key={idx} icon={<AttachFileIcon />} label={file.name} variant="outlined" onDelete={() => setPendingFiles((prev) => prev.filter((_, i) => i !== idx)) } /> ))} </Stack> )} <Divider sx={{ mb: 2 }} /> {/* Панель действий */} <Stack direction="row" spacing={1} flexWrap="wrap" gap={1}> {/* Прикрепить файл */} <Button startIcon={ isUploadingFile ? ( <CircularProgress size={22} /> ) : ( <AttachFileIcon /> ) } variant="outlined" color="inherit" size="large" sx={{ textTransform: 'none' }} onClick={() => !isUploadingFile && fileInputRef.current?.click()} disabled={isUploadingFile} > {isUploadingFile ? 'Загрузка...' : 'Прикрепить файл'} </Button> <input ref={fileInputRef} type="file" multiple hidden onChange={handleFileChange} /> {/* Сохранить */} <Button startIcon={ isLoadingEntry ? <CircularProgress size={22} /> : <SaveIcon /> } variant="contained" color="primary" size="large" sx={{ textTransform: 'none' }} onClick={handleSave} disabled={isLoadingEntry} > Сохранить </Button> {/* Распознать текст (OCR) */} <Tooltip title={ !isSaved && !currentEntry ? 'Сохраните запись перед распознаванием. Поддерживаются: изображения (JPEG, PNG, GIF, WebP, BMP, TIFF) и PDF.' : !hasSupportedFileForOcr ? 'Нет подходящих файлов. Поддерживаются: изображения (JPEG, PNG, GIF, WebP, BMP, TIFF) и PDF.' : 'Запустить распознавание текста' } slotProps={{ tooltip: { sx: { fontSize: '0.9rem', px: 1.5, py: 1 } }, }} > <span> <Button startIcon={<DocumentScannerIcon />} variant="outlined" color="secondary" size="large" sx={{ textTransform: 'none' }} onClick={handleStartOcr} disabled={!currentEntry || !hasSupportedFileForOcr} > Распознать текст </Button> </span> </Tooltip> {/* Удалить запись (только если запись существует) */} {currentEntry && ( <Button startIcon={<DeleteOutlinedIcon />} variant="outlined" color="error" size="large" sx={{ textTransform: 'none', ml: 'auto' }} onClick={() => setDeleteDialogOpen(true)} > Удалить запись </Button> )} </Stack> {/* Диалог подтверждения удаления записи */} <Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} > <DialogTitle>Удалить запись?</DialogTitle> <DialogContent> <DialogContentText> Запись и все прикреплённые файлы будут удалены безвозвратно. </DialogContentText> </DialogContent> <DialogActions> <Button onClick={() => setDeleteDialogOpen(false)} color="inherit"> Отмена </Button> <Button onClick={handleDeleteEntry} color="error" variant="contained"> Удалить </Button> </DialogActions> </Dialog> {/* Диалог подтверждения удаления файла */} <Dialog open={Boolean(deletingFileId)} onClose={() => setDeletingFileId(null)} PaperProps={{ sx: { borderRadius: 3 } }} > <DialogTitle sx={{ px: 2.5, pt: 2.5 }}>Удалить файл?</DialogTitle> <DialogContent sx={{ px: 2.5 }}> <DialogContentText>Файл будет удалён безвозвратно.</DialogContentText> </DialogContent> <DialogActions sx={{ px: 2.5, pb: 2, gap: 1 }}> <Button onClick={() => setDeletingFileId(null)} color="inherit"> Отмена </Button> <Button onClick={() => deletingFileId && handleDeleteFile(deletingFileId)} color="error" variant="contained" > Удалить </Button> </DialogActions> </Dialog> {/* Модальное окно просмотра файла */} {viewingFile && ( <FileViewerModal file={viewingFile} open={Boolean(viewingFile)} onClose={() => setViewingFile(null)} onDeleteFile={(fileId) => setDeletingFileId(fileId)} /> )} </Box> ); };