Add document preview support
This commit is contained in:
parent
d0f1fcacf3
commit
9319eb8171
@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocumentPdfFromMenu(doc)}
|
||||
onClick={() => openDocumentPreviewFromMenu(doc)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Открыть PDF
|
||||
Предпросмотр
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="flex h-full items-center justify-center bg-[var(--background)] p-4">
|
||||
<img
|
||||
src={document.file_url}
|
||||
alt={document.file_name || "Preview"}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPDF) {
|
||||
return (
|
||||
<iframe
|
||||
src={`${document.file_url}#page=1&view=FitH`}
|
||||
className="h-full w-full border-0"
|
||||
title="PDF Preview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-surface-muted flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText size={64} className="app-text-muted mx-auto" />
|
||||
<p className="mt-4 text-sm font-medium text-[var(--foreground)]">{document.file_name}</p>
|
||||
<p className="app-text-muted mt-1 text-xs uppercase">{fileExt} файл</p>
|
||||
<p className="app-text-muted mt-3 text-xs">Предпросмотр недоступен</p>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentPreviewPane
|
||||
fileUrl={document.file_url}
|
||||
fileName={document.file_name || document.title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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<number>(0);
|
||||
const [pageNumber, setPageNumber] = useState<number>(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<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
const token = window.localStorage.getItem("access_token");
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function fetchFile(fileUrl: string): Promise<ArrayBuffer> {
|
||||
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 (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
<span className="app-text-muted ml-3 text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message, fileUrl }: { message: string; fileUrl: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center p-6">
|
||||
<div className="app-feedback-danger max-w-md rounded-lg p-4 text-sm">
|
||||
<p>{message}</p>
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-danger mt-3 inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnsupportedState({ fileUrl, fileName }: { fileUrl: string; fileName: string }) {
|
||||
const extension = getFileExtension(fileName);
|
||||
return (
|
||||
<div className="app-surface-muted flex h-full min-h-[240px] items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<FileText size={56} className="app-text-muted mx-auto mb-4" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">{fileName}</p>
|
||||
{extension && <p className="app-text-muted mt-1 text-xs uppercase">{extension} файл</p>}
|
||||
<p className="app-text-muted mt-3 max-w-sm text-xs">
|
||||
Для этого формата нет встроенного предпросмотра. Файл можно скачать и открыть локально.
|
||||
</p>
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-primary mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Download size={16} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [docxContent, setDocxContent] = useState<string>('');
|
||||
const [docxContent, setDocxContent] = useState("");
|
||||
const [excelSheets, setExcelSheets] = useState<ExcelSheet[]>([]);
|
||||
const [activeSheet, setActiveSheet] = useState<number>(0);
|
||||
const [textContent, setTextContent] = useState<string>('');
|
||||
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<unknown[]>(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 (
|
||||
<Modal isOpen onClose={onClose ?? (() => {})} noHeader noPadding size="xl">
|
||||
{/* Header */}
|
||||
<div className="app-divider flex items-center justify-between border-b px-3 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--foreground)] sm:text-lg">{fileName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-secondary rounded-lg p-2"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="app-action-secondary rounded-lg p-2"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{fileType === 'pdf' ? (
|
||||
<div className="flex flex-col items-center">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="app-feedback-danger rounded-lg p-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex h-full min-h-0 flex-col bg-[var(--surface-secondary)] ${className}`}>
|
||||
{previewType === "pdf" && (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{loading && <LoadingState label="Загрузка PDF..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
<div className="flex justify-center">
|
||||
<PDFDocument
|
||||
file={fileUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
file={pdfSource}
|
||||
onLoadSuccess={({ numPages: loadedPages }) => {
|
||||
setNumPages(loadedPages);
|
||||
setLoading(false);
|
||||
}}
|
||||
onLoadError={(loadError) => {
|
||||
console.error("PDF preview error:", loadError);
|
||||
setError("Не удалось загрузить PDF файл");
|
||||
setLoading(false);
|
||||
}}
|
||||
loading={null}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="shadow-lg"
|
||||
renderAnnotationLayer
|
||||
renderTextLayer
|
||||
className="overflow-hidden rounded-lg shadow-lg"
|
||||
/>
|
||||
</PDFDocument>
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
className="max-h-full max-w-full rounded-lg shadow-lg"
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => setError('Не удалось загрузить изображение')}
|
||||
/>
|
||||
</div>
|
||||
) : fileType === 'word' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
<span className="app-text-muted ml-3 text-sm">Загрузка документа...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="app-feedback-danger rounded-lg p-4 text-sm">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && docxContent && (
|
||||
<div className="app-surface rounded-lg p-8">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: docxContent }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : fileType === 'excel' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
<span className="app-text-muted ml-3 text-sm">Загрузка таблицы...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="app-feedback-danger rounded-lg p-4 text-sm">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && excelSheets.length > 0 && (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sheet Tabs */}
|
||||
{excelSheets.length > 1 && (
|
||||
<div className="app-divider mb-4 flex gap-2 overflow-x-auto border-b pb-2">
|
||||
{excelSheets.map((sheet, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveSheet(index)}
|
||||
className={`flex items-center gap-2 whitespace-nowrap px-4 py-2 text-sm ${
|
||||
activeSheet === index
|
||||
? 'app-pill-active'
|
||||
: 'app-pill'
|
||||
}`}
|
||||
>
|
||||
<Table size={16} />
|
||||
{sheet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Excel Table */}
|
||||
<div className="app-surface-elevated flex-1 overflow-auto rounded-lg">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
{excelSheets[activeSheet]?.data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="border-r border-[var(--border-subtle)] px-3 py-2 text-[var(--foreground)] last:border-r-0"
|
||||
>
|
||||
{cell !== null && cell !== undefined ? String(cell) : ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{excelSheets[activeSheet]?.data.length === 0 && (
|
||||
<div className="app-text-muted flex items-center justify-center py-12 text-sm">
|
||||
Лист пуст
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : fileType === 'text' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
<span className="app-text-muted ml-3 text-sm">Загрузка файла...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="app-feedback-danger rounded-lg p-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="app-surface-muted rounded-lg p-4">
|
||||
<pre className="overflow-auto text-xs text-[var(--foreground)]">
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted flex items-center justify-center rounded-xl p-12">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="app-text-muted mx-auto mb-4" />
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground)]">
|
||||
Неизвестный тип файла
|
||||
</p>
|
||||
<p className="app-text-muted mb-4 text-sm">
|
||||
Предпросмотр недоступен для этого типа файла
|
||||
</p>
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Download size={16} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{numPages > 0 && (
|
||||
<div className="app-divider flex shrink-0 items-center justify-between border-t px-4 py-3">
|
||||
<button
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
className="app-action-secondary rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Предыдущая страница"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="app-text-muted text-sm">
|
||||
Страница {pageNumber} из {numPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages}
|
||||
className="app-action-secondary rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Следующая страница"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{previewType === "image" && (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-auto p-4">
|
||||
{loading && <LoadingState label="Загрузка изображения..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
className={`max-h-full max-w-full rounded-lg object-contain shadow-lg ${loading || error ? "hidden" : ""}`}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => {
|
||||
setError("Не удалось загрузить изображение");
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Navigation */}
|
||||
{fileType === 'pdf' && numPages > 0 && (
|
||||
<div className="app-divider flex items-center justify-between border-t px-6 py-4">
|
||||
<button
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
className="app-action-secondary rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
{previewType === "word" && (
|
||||
<div className="h-full min-h-0 overflow-auto p-4 sm:p-6">
|
||||
{loading && <LoadingState label="Загрузка DOCX..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
{!loading && !error && (
|
||||
<article className="mx-auto min-h-full max-w-4xl rounded-lg bg-white p-6 text-sm leading-relaxed text-slate-900 shadow-lg sm:p-8">
|
||||
{docxContent ? (
|
||||
<div
|
||||
className="max-w-none [&_a]:text-sky-700 [&_img]:max-w-full [&_li]:my-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-3 [&_strong]:font-semibold [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-300 [&_td]:p-2 [&_th]:border [&_th]:border-slate-300 [&_th]:bg-slate-100 [&_th]:p-2 [&_ul]:list-disc [&_ul]:pl-6"
|
||||
dangerouslySetInnerHTML={{ __html: docxContent }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-slate-500">В документе нет текстового содержимого для предпросмотра.</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="app-text-muted text-sm">
|
||||
Страница {pageNumber} из {numPages}
|
||||
</span>
|
||||
{previewType === "spreadsheet" && (
|
||||
<div className="flex h-full min-h-0 flex-col p-4">
|
||||
{loading && <LoadingState label="Загрузка таблицы..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{excelSheets.length > 1 && (
|
||||
<div className="app-divider mb-3 flex shrink-0 gap-2 overflow-x-auto border-b pb-2">
|
||||
{excelSheets.map((sheet, index) => (
|
||||
<button
|
||||
key={sheet.name}
|
||||
onClick={() => setActiveSheet(index)}
|
||||
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm ${
|
||||
activeSheet === index ? "app-pill-active" : "app-pill"
|
||||
}`}
|
||||
>
|
||||
<Table size={16} />
|
||||
{sheet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="app-surface-elevated min-h-0 flex-1 overflow-auto rounded-lg">
|
||||
{excelSheets[activeSheet]?.rows.length ? (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
{excelSheets[activeSheet].rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-[var(--border-subtle)]">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="max-w-[360px] border-r border-[var(--border-subtle)] px-3 py-2 text-[var(--foreground)] last:border-r-0"
|
||||
>
|
||||
<span className="block whitespace-pre-wrap break-words">
|
||||
{cell === null || cell === undefined ? "" : String(cell)}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="app-text-muted flex h-full min-h-[220px] items-center justify-center text-sm">
|
||||
Таблица пуста
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages}
|
||||
className="app-action-secondary rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
{previewType === "text" && (
|
||||
<div className="h-full min-h-0 overflow-auto p-4">
|
||||
{loading && <LoadingState label="Загрузка файла..." />}
|
||||
{error && <ErrorState message={error} fileUrl={fileUrl} />}
|
||||
{!loading && !error && (
|
||||
<pre className="app-surface-elevated min-h-full overflow-auto rounded-lg p-4 text-xs leading-relaxed text-[var(--foreground)]">
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewType === "video" && (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center p-4">
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-[var(--foreground)]">
|
||||
<FileVideo size={18} className="app-text-muted" />
|
||||
{fileName}
|
||||
</div>
|
||||
<video src={fileUrl} controls className="max-h-[70vh] w-full rounded-lg bg-black" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewType === "audio" && (
|
||||
<div className="flex h-full min-h-[240px] items-center justify-center p-8">
|
||||
<div className="app-surface-elevated w-full max-w-xl rounded-xl p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<FileAudio size={28} className="app-text-muted" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground)]">{fileName}</p>
|
||||
<p className="app-text-muted text-xs">Аудиофайл</p>
|
||||
</div>
|
||||
</div>
|
||||
<audio src={fileUrl} controls className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewType === "unsupported" && <UnsupportedState fileUrl={fileUrl} fileName={fileName} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewProps) {
|
||||
return (
|
||||
<Modal isOpen onClose={onClose ?? (() => {})} noHeader noPadding size="xl" className="h-[90vh]">
|
||||
<div className="app-divider flex shrink-0 items-center justify-between border-b px-3 py-3 sm:px-6 sm:py-4">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--foreground)] sm:text-lg">{fileName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-action-secondary rounded-lg p-2"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="app-action-secondary rounded-lg p-2"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentPreviewPane fileUrl={fileUrl} fileName={fileName} className="flex-1" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user