diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx
index 48e5bbd9..aa6f263c 100644
--- a/frontend/src/app/documents/page.tsx
+++ b/frontend/src/app/documents/page.tsx
@@ -62,6 +62,99 @@ function formatDate(value?: string): string {
});
}
+const DOCUMENT_PREVIEW_EXTENSIONS = new Set([
+ "pdf",
+ "jpg",
+ "jpeg",
+ "png",
+ "gif",
+ "webp",
+ "svg",
+ "bmp",
+ "ico",
+ "avif",
+ "docx",
+ "xls",
+ "xlsx",
+ "xlsm",
+ "xlsb",
+ "ods",
+ "csv",
+ "tsv",
+ "txt",
+ "text",
+ "md",
+ "markdown",
+ "json",
+ "xml",
+ "html",
+ "htm",
+ "css",
+ "scss",
+ "sass",
+ "less",
+ "js",
+ "jsx",
+ "ts",
+ "tsx",
+ "mjs",
+ "cjs",
+ "yml",
+ "yaml",
+ "ini",
+ "conf",
+ "cfg",
+ "log",
+ "sql",
+ "py",
+ "java",
+ "c",
+ "cpp",
+ "h",
+ "hpp",
+ "cs",
+ "go",
+ "rs",
+ "php",
+ "rb",
+ "kt",
+ "kts",
+ "swift",
+ "sh",
+ "bash",
+ "zsh",
+ "ps1",
+ "bat",
+ "cmd",
+ "properties",
+ "env",
+ "rtf",
+ "mp4",
+ "webm",
+ "ogg",
+ "ogv",
+ "mov",
+ "m4v",
+ "mp3",
+ "wav",
+ "oga",
+ "m4a",
+ "aac",
+ "flac",
+]);
+const DOCUMENT_PREVIEW_FILE_NAMES = new Set(["dockerfile", "makefile", "readme", "license", ".gitignore", ".env"]);
+
+function getDocumentFileExtension(fileName?: string | null): string {
+ const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0];
+ return cleanName.match(/\.([^.]+)$/)?.[1] || "";
+}
+
+function canPreviewDocument(fileName?: string | null): boolean {
+ const lowerName = (fileName || "").toLowerCase();
+ const extension = getDocumentFileExtension(fileName);
+ return DOCUMENT_PREVIEW_EXTENSIONS.has(extension) || DOCUMENT_PREVIEW_FILE_NAMES.has(lowerName);
+}
+
function formatFileSize(value?: number): string {
if (!value || value <= 0) return "";
if (value < 1024) return `${value} Б`;
@@ -549,12 +642,17 @@ function DocumentsPageContent() {
setDocumentMenuOpenId(null);
};
- const openDocumentPdfFromMenu = (documentItem: Document) => {
+ const openDocumentPreviewFromMenu = (documentItem: Document) => {
if (!documentItem.file_url) return;
- setPdfViewerFile({
+ const previewFile = {
url: documentItem.file_url,
name: documentItem.file_name || documentItem.title,
- });
+ };
+ if (getDocumentFileExtension(documentItem.file_name) === "pdf") {
+ setPdfViewerFile(previewFile);
+ } else {
+ setPreviewFile(previewFile);
+ }
setDocumentMenuOpenId(null);
};
@@ -1129,7 +1227,7 @@ function DocumentsPageContent() {
const fileSize = formatFileSize(doc.file_size);
const recipientsCount = doc.recipients?.length || 0;
const departmentsCount = doc.departments?.length || 0;
- const hasPreview = Boolean(doc.file_url && doc.file_name?.toLowerCase().endsWith(".pdf"));
+ const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title));
// DEBUG: Проверка данных документа
if (!authorName || !createdDate) {
@@ -1309,11 +1407,11 @@ function DocumentsPageContent() {
{hasPreview && (
)}
diff --git a/frontend/src/components/documents/DocumentDetailModal.tsx b/frontend/src/components/documents/DocumentDetailModal.tsx
index 8370dd33..606f69a7 100644
--- a/frontend/src/components/documents/DocumentDetailModal.tsx
+++ b/frontend/src/components/documents/DocumentDetailModal.tsx
@@ -28,13 +28,13 @@ import {
import { DocumentAcknowledgement } from "./DocumentAcknowledgement";
import { DocumentComments } from "./DocumentComments";
import { DocumentRelated } from "./DocumentRelated";
+import { DocumentPreviewPane } from "./DocumentPreview";
interface DocumentDetailModalProps {
document: Document | null;
isOpen: boolean;
onClose: () => void;
onUpdate?: () => void;
- onPreview?: (url: string, name: string) => void;
onEditMetadata?: () => void;
onViewReport?: () => void;
onNavigateToRelated?: (docId: number) => void;
@@ -47,7 +47,6 @@ export function DocumentDetailModal({
isOpen,
onClose,
onUpdate,
- onPreview,
onEditMetadata,
onViewReport,
onNavigateToRelated,
@@ -94,41 +93,11 @@ export function DocumentDetailModal({
);
}
- const fileExt = document.file_name?.toLowerCase().split(".").pop() || "";
- const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(fileExt);
- const isPDF = fileExt === "pdf";
-
- if (isImage) {
- return (
-
-

-
- );
- }
-
- if (isPDF) {
- return (
-
- );
- }
-
return (
-
-
-
-
{document.file_name}
-
{fileExt} файл
-
Предпросмотр недоступен
-
-
+
);
};
diff --git a/frontend/src/components/documents/DocumentPreview.tsx b/frontend/src/components/documents/DocumentPreview.tsx
index c1cef1f5..8f9e5f54 100644
--- a/frontend/src/components/documents/DocumentPreview.tsx
+++ b/frontend/src/components/documents/DocumentPreview.tsx
@@ -1,14 +1,15 @@
"use client";
-import { useState, useEffect } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
-import { ChevronLeft, ChevronRight, Download, X, FileText, Table } from "lucide-react";
+import { ChevronLeft, ChevronRight, Download, FileAudio, FileText, FileVideo, Table, X } from "lucide-react";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
import { Modal } from "@/components/ui";
-// Configure PDF.js worker
-pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
+if (typeof window !== "undefined") {
+ pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
+}
interface DocumentPreviewProps {
fileUrl: string;
@@ -16,409 +17,525 @@ interface DocumentPreviewProps {
onClose?: () => void;
}
+interface DocumentPreviewPaneProps {
+ fileUrl: string;
+ fileName: string;
+ className?: string;
+}
+
interface ExcelSheet {
name: string;
- data: any[][];
+ rows: unknown[][];
}
-function getFileType(fileName: string): 'pdf' | 'image' | 'word' | 'excel' | 'text' | 'unknown' {
- const ext = fileName.toLowerCase().split('.').pop() || '';
-
- if (ext === 'pdf') return 'pdf';
- if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
- if (['doc', 'docx'].includes(ext)) return 'word';
- if (['xls', 'xlsx'].includes(ext)) return 'excel';
- if (['txt', 'csv', 'json', 'xml', 'html', 'css', 'js', 'ts', 'md'].includes(ext)) return 'text';
-
- return 'unknown';
+type PreviewType = "pdf" | "image" | "word" | "spreadsheet" | "text" | "video" | "audio" | "unsupported";
+
+const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"]);
+const WORD_EXTENSIONS = new Set(["docx"]);
+const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "xlsm", "xlsb", "ods", "csv", "tsv"]);
+const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "m4v"]);
+const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "oga", "m4a", "aac", "flac", "webm"]);
+const TEXT_EXTENSIONS = new Set([
+ "txt",
+ "text",
+ "md",
+ "markdown",
+ "json",
+ "xml",
+ "html",
+ "htm",
+ "css",
+ "scss",
+ "sass",
+ "less",
+ "js",
+ "jsx",
+ "ts",
+ "tsx",
+ "mjs",
+ "cjs",
+ "yml",
+ "yaml",
+ "ini",
+ "conf",
+ "cfg",
+ "log",
+ "sql",
+ "py",
+ "java",
+ "c",
+ "cpp",
+ "h",
+ "hpp",
+ "cs",
+ "go",
+ "rs",
+ "php",
+ "rb",
+ "kt",
+ "kts",
+ "swift",
+ "sh",
+ "bash",
+ "zsh",
+ "ps1",
+ "bat",
+ "cmd",
+ "properties",
+ "env",
+ "rtf",
+]);
+const TEXT_FILE_NAMES = new Set(["dockerfile", "makefile", "readme", "license", ".gitignore", ".env"]);
+
+function getFileExtension(fileName: string): string {
+ const cleanName = fileName.toLowerCase().split("?")[0].split("#")[0];
+ const match = cleanName.match(/\.([^.]+)$/);
+ return match?.[1] || "";
}
-export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewProps) {
- const [numPages, setNumPages] = useState(0);
- const [pageNumber, setPageNumber] = useState(1);
- const [loading, setLoading] = useState(true);
+function getPreviewType(fileName: string): PreviewType {
+ const lowerName = fileName.toLowerCase();
+ const extension = getFileExtension(fileName);
+
+ if (extension === "pdf") return "pdf";
+ if (IMAGE_EXTENSIONS.has(extension)) return "image";
+ if (WORD_EXTENSIONS.has(extension)) return "word";
+ if (SPREADSHEET_EXTENSIONS.has(extension)) return "spreadsheet";
+ if (VIDEO_EXTENSIONS.has(extension)) return "video";
+ if (AUDIO_EXTENSIONS.has(extension)) return "audio";
+ if (TEXT_EXTENSIONS.has(extension) || TEXT_FILE_NAMES.has(lowerName)) return "text";
+
+ return "unsupported";
+}
+
+function getAuthHeaders(): Record {
+ if (typeof window === "undefined") return {};
+ const token = window.localStorage.getItem("access_token");
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
+async function fetchFile(fileUrl: string): Promise {
+ const response = await fetch(fileUrl, {
+ credentials: "include",
+ headers: getAuthHeaders(),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.arrayBuffer();
+}
+
+function decodeText(buffer: ArrayBuffer, extension: string): string {
+ const decoded = new TextDecoder("utf-8").decode(buffer);
+ if (extension === "json") {
+ try {
+ return JSON.stringify(JSON.parse(decoded), null, 2);
+ } catch {
+ return decoded;
+ }
+ }
+ return decoded;
+}
+
+function sanitizeDocxHtml(html: string): string {
+ if (typeof window === "undefined") return html;
+
+ const parsed = new DOMParser().parseFromString(html, "text/html");
+ parsed.querySelectorAll("script, iframe, object, embed, link, meta, style").forEach((node) => node.remove());
+ parsed.querySelectorAll("*").forEach((node) => {
+ Array.from(node.attributes).forEach((attribute) => {
+ const name = attribute.name.toLowerCase();
+ const value = attribute.value.trim().toLowerCase();
+ if (name.startsWith("on") || name === "style" || value.startsWith("javascript:")) {
+ node.removeAttribute(attribute.name);
+ }
+ });
+ });
+
+ return parsed.body.innerHTML;
+}
+
+function LoadingState({ label }: { label: string }) {
+ return (
+
+ );
+}
+
+function ErrorState({ message, fileUrl }: { message: string; fileUrl: string }) {
+ return (
+
+ );
+}
+
+function UnsupportedState({ fileUrl, fileName }: { fileUrl: string; fileName: string }) {
+ const extension = getFileExtension(fileName);
+ return (
+
+
+
+
{fileName}
+ {extension &&
{extension} файл
}
+
+ Для этого формата нет встроенного предпросмотра. Файл можно скачать и открыть локально.
+
+
+
+ Скачать файл
+
+
+
+ );
+}
+
+export function DocumentPreviewPane({ fileUrl, fileName, className = "" }: DocumentPreviewPaneProps) {
+ const previewType = getPreviewType(fileName);
+ const extension = getFileExtension(fileName);
+ const [numPages, setNumPages] = useState(0);
+ const [pageNumber, setPageNumber] = useState(1);
+ const [loading, setLoading] = useState(previewType !== "unsupported");
const [error, setError] = useState(null);
- const [docxContent, setDocxContent] = useState('');
+ const [docxContent, setDocxContent] = useState("");
const [excelSheets, setExcelSheets] = useState([]);
- const [activeSheet, setActiveSheet] = useState(0);
- const [textContent, setTextContent] = useState('');
+ const [activeSheet, setActiveSheet] = useState(0);
+ const [textContent, setTextContent] = useState("");
- const fileType = getFileType(fileName);
-
- // Конвертируем DOCX в HTML на клиенте
- useEffect(() => {
- if (fileType === 'word' && fileName.toLowerCase().endsWith('.docx')) {
- setLoading(true);
-
- fetch(fileUrl, {
- credentials: 'include',
- headers: {
- 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
- },
- })
- .then(response => {
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.arrayBuffer();
- })
- .then(async (arrayBuffer) => {
- try {
- const mammoth = (await import('mammoth')).default;
- const result = await mammoth.convertToHtml({ arrayBuffer });
- setDocxContent(result.value);
- setLoading(false);
- } catch (err) {
- console.error('Ошибка конвертации DOCX:', err);
- setError('Не удалось загрузить документ');
- setLoading(false);
- }
- })
- .catch(err => {
- console.error('Ошибка загрузки файла:', err);
- setError('Не удалось загрузить файл');
- setLoading(false);
- });
- }
- }, [fileUrl, fileName, fileType]);
-
- // Парсим Excel файлы локально
- useEffect(() => {
- if (fileType === 'excel') {
- setLoading(true);
-
- fetch(fileUrl, {
- credentials: 'include',
- headers: {
- 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
- },
- })
- .then(response => {
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.arrayBuffer();
- })
- .then(async (arrayBuffer) => {
- try {
- const XLSX = (await import('xlsx')).default;
- const workbook = XLSX.read(arrayBuffer, { type: 'array' });
-
- const sheets: ExcelSheet[] = workbook.SheetNames.map(sheetName => {
- const worksheet = workbook.Sheets[sheetName];
- const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
- return {
- name: sheetName,
- data
- };
- });
-
- setExcelSheets(sheets);
- setLoading(false);
- } catch (err) {
- console.error('Ошибка парсинга Excel:', err);
- setError('Не удалось загрузить таблицу');
- setLoading(false);
- }
- })
- .catch(err => {
- console.error('Ошибка загрузки файла:', err);
- setError('Не удалось загрузить файл');
- setLoading(false);
- });
- }
- }, [fileUrl, fileName, fileType]);
-
- // Загружаем текстовые файлы
- useEffect(() => {
- if (fileType === 'text') {
- setLoading(true);
-
- fetch(fileUrl, {
- credentials: 'include',
- headers: {
- 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
- },
- })
- .then(response => {
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
- return response.text();
- })
- .then(text => {
- setTextContent(text);
- setLoading(false);
- })
- .catch(err => {
- console.error('Ошибка загрузки текстового файла:', err);
- setError('Не удалось загрузить файл');
- setLoading(false);
- });
- }
- }, [fileUrl, fileName, fileType]);
+ const pdfSource = useMemo(() => ({
+ url: fileUrl,
+ httpHeaders: getAuthHeaders(),
+ withCredentials: true,
+ }), [fileUrl]);
- function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
- setNumPages(numPages);
- setLoading(false);
- }
+ useEffect(() => {
+ let cancelled = false;
- function onDocumentLoadError(error: Error) {
- console.error('Error loading PDF:', error);
- setError('Не удалось загрузить PDF файл');
- setLoading(false);
- }
+ setNumPages(0);
+ setPageNumber(1);
+ setError(null);
+ setDocxContent("");
+ setExcelSheets([]);
+ setActiveSheet(0);
+ setTextContent("");
+ setLoading(["pdf", "image", "word", "spreadsheet", "text"].includes(previewType));
+
+ async function loadDocx() {
+ try {
+ const arrayBuffer = await fetchFile(fileUrl);
+ const mammoth = (await import("mammoth")).default;
+ const result = await mammoth.convertToHtml({ arrayBuffer });
+ if (!cancelled) {
+ setDocxContent(sanitizeDocxHtml(result.value));
+ setLoading(false);
+ }
+ } catch (loadError) {
+ console.error("DOCX preview error:", loadError);
+ if (!cancelled) {
+ setError("Не удалось открыть DOCX для предпросмотра");
+ setLoading(false);
+ }
+ }
+ }
+
+ async function loadSpreadsheet() {
+ try {
+ const arrayBuffer = await fetchFile(fileUrl);
+ const XLSX = await import("xlsx");
+ const workbook = XLSX.read(arrayBuffer, { type: "array" });
+ const sheets = workbook.SheetNames.map((sheetName) => {
+ const worksheet = workbook.Sheets[sheetName];
+ const rows = XLSX.utils.sheet_to_json(worksheet, {
+ header: 1,
+ defval: "",
+ blankrows: false,
+ }) as unknown[][];
+ return { name: sheetName, rows };
+ });
+ if (!cancelled) {
+ setExcelSheets(sheets);
+ setLoading(false);
+ }
+ } catch (loadError) {
+ console.error("Spreadsheet preview error:", loadError);
+ if (!cancelled) {
+ setError("Не удалось открыть таблицу для предпросмотра");
+ setLoading(false);
+ }
+ }
+ }
+
+ async function loadText() {
+ try {
+ const arrayBuffer = await fetchFile(fileUrl);
+ if (!cancelled) {
+ setTextContent(decodeText(arrayBuffer, extension));
+ setLoading(false);
+ }
+ } catch (loadError) {
+ console.error("Text preview error:", loadError);
+ if (!cancelled) {
+ setError("Не удалось открыть текстовый файл");
+ setLoading(false);
+ }
+ }
+ }
+
+ if (previewType === "word") {
+ void loadDocx();
+ } else if (previewType === "spreadsheet") {
+ void loadSpreadsheet();
+ } else if (previewType === "text") {
+ void loadText();
+ } else if (previewType === "video" || previewType === "audio" || previewType === "unsupported") {
+ setLoading(false);
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [extension, fileUrl, previewType]);
const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages));
return (
- {})} noHeader noPadding size="xl">
- {/* Header */}
-
-
{fileName}
-
-
-
-
- {onClose && (
-
- )}
-
-
-
- {/* Content */}
-
- {fileType === 'pdf' ? (
-
- {loading && (
-
- )}
-
- {error && (
-
- {error}
-
- )}
-
+
+ {previewType === "pdf" && (
+ <>
+
+ {loading &&
}
+ {error &&
}
+
{
+ setNumPages(loadedPages);
+ setLoading(false);
+ }}
+ onLoadError={(loadError) => {
+ console.error("PDF preview error:", loadError);
+ setError("Не удалось загрузить PDF файл");
+ setLoading(false);
+ }}
loading={null}
>
- ) : fileType === 'image' ? (
-
-

setLoading(false)}
- onError={() => setError('Не удалось загрузить изображение')}
- />
-
- ) : fileType === 'word' ? (
-
- {loading && (
-
-
-
Загрузка документа...
-
- )}
-
- {error && (
-
- )}
-
- {!loading && !error && docxContent && (
-
- )}
-
- ) : fileType === 'excel' ? (
-
- {loading && (
-
-
-
Загрузка таблицы...
-
- )}
-
- {error && (
-
- )}
-
- {!loading && !error && excelSheets.length > 0 && (
-
- {/* Sheet Tabs */}
- {excelSheets.length > 1 && (
-
- {excelSheets.map((sheet, index) => (
-
- ))}
-
- )}
-
- {/* Excel Table */}
-
-
-
- {excelSheets[activeSheet]?.data.map((row, rowIndex) => (
-
- {row.map((cell, cellIndex) => (
- |
- {cell !== null && cell !== undefined ? String(cell) : ''}
- |
- ))}
-
- ))}
-
-
-
- {excelSheets[activeSheet]?.data.length === 0 && (
-
- Лист пуст
-
- )}
-
-
- )}
-
- ) : fileType === 'text' ? (
-
- {loading && (
-
- )}
-
- {error && (
-
- {error}
-
- )}
-
- {!loading && !error && (
-
- )}
-
- ) : (
-
-
-
-
- Неизвестный тип файла
-
-
- Предпросмотр недоступен для этого типа файла
-
-
-
- Скачать файл
-
-
+
+ {numPages > 0 && (
+
+
+
+ Страница {pageNumber} из {numPages}
+
+
)}
+ >
+ )}
+
+ {previewType === "image" && (
+
+ {loading &&
}
+ {error &&
}
+

setLoading(false)}
+ onError={() => {
+ setError("Не удалось загрузить изображение");
+ setLoading(false);
+ }}
+ />
+ )}
- {/* PDF Navigation */}
- {fileType === 'pdf' && numPages > 0 && (
-
-
+ {previewType === "word" && (
+
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && (
+
+ {docxContent ? (
+
+ ) : (
+ В документе нет текстового содержимого для предпросмотра.
+ )}
+
+ )}
+
+ )}
-
- Страница {pageNumber} из {numPages}
-
+ {previewType === "spreadsheet" && (
+
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && (
+ <>
+ {excelSheets.length > 1 && (
+
+ {excelSheets.map((sheet, index) => (
+
+ ))}
+
+ )}
+
+ {excelSheets[activeSheet]?.rows.length ? (
+
+
+ {excelSheets[activeSheet].rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+
+ {cell === null || cell === undefined ? "" : String(cell)}
+
+ |
+ ))}
+
+ ))}
+
+
+ ) : (
+
+ Таблица пуста
+
+ )}
+
+ >
+ )}
+
+ )}
-
+ {previewType === "text" && (
+
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && (
+
+ {textContent}
+
+ )}
+
+ )}
+
+ {previewType === "video" && (
+
+ )}
+
+ {previewType === "audio" && (
+
+
+
+
+
+
{fileName}
+
Аудиофайл
+
+
+
+
+
+ )}
+
+ {previewType === "unsupported" &&
}
+
+ );
+}
+
+export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewProps) {
+ return (
+
{})} noHeader noPadding size="xl" className="h-[90vh]">
+
+
{fileName}
+
+
+
+
+ {onClose && (
+
+ )}
+
+
+
);
}