add document viewer

This commit is contained in:
smanylov
2026-05-01 15:12:00 +07:00
parent 881f3295c0
commit a3cbaaa2e7
10 changed files with 814 additions and 92 deletions
+9
View File
@@ -0,0 +1,9 @@
import { PdfProvider } from '@/app/contexts/PdfContext';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<PdfProvider>
{children}
</PdfProvider>
);
}
+45 -90
View File
@@ -1,135 +1,90 @@
'use server'
'use server';
import Link from 'next/link';
import PdfViewer from '@/app/components/PdfViewer';
import { DownloadButton } from '@/app/components/document-viewer/DownloadButton';
import { CopyLinkButton } from '@/app/components/document-viewer/CopyLinkButton';
interface PageProps {
searchParams: Promise<{ docid?: string }>;
searchParams: Promise<{ docid: string; title?: string }>;
}
export default async function Page({
searchParams
}: PageProps) {
const { docid } = await searchParams;
export default async function Page({ searchParams }: PageProps) {
const { docid, title } = await searchParams;
const documentTitle = title || 'FileName';
const ownerName = "Name Name Name";
const companyName = "companyName";
return (
<div
className="watch-doc-wrapper"
>
<div
className="watch-doc-page"
>
<div className="watch-doc-wrapper">
<div className="watch-doc-page">
<header className="watch-doc-header">
<Link href="#" className="header-logo">NoCopy</Link>
<Link href="#" className="header-user">Name</Link>
</header>
<div
className="watch-doc-status"
>
<div className="watch-doc-status">
<div className="st-title">Защита подтверждена</div>
<div className="st-sub">Документ <strong>Название документа</strong> зарегистрирован и защищён платформой NoCopy.</div>
<div className="st-sub">
Документ <strong>{documentTitle}</strong> зарегистрирован и защищён платформой NoCopy.
</div>
</div>
<div
className="watch-doc-grid"
>
<div className="watch-doc-grid">
<div>
<div
className="watch-doc-card"
>
<div
className="card-head"
>
Правообладатель
</div>
<div
className="card-body"
>
<div className="watch-doc-card">
<div className="card-head">Правообладатель</div>
<div className="card-body">
<div className="owner-row">
<div className="owner-av">N</div>
<div className="owner-av">Н</div>
<div>
<div className="owner-name">Сидоров Станислав Андреевич</div>
<div className="owner-meta">ООО "ПРОКОНТРОЛ"</div>
<div className="owner-name">{ownerName}</div>
{/* <div className="owner-meta">{companyName}</div> */}
<div className="owner-meta">Зарегистрирован в NoCopy</div>
</div>
</div>
</div>
</div>
<div
className="watch-doc-card"
>
<div
className="card-head"
>
Скачать документ
</div>
<div
className="card-body"
>
{/* <div className="watch-doc-card">
<div className="card-head">Скачать документ</div>
<div className="card-body">
<DownloadButton />
<div className="download-description">
Вы вошли как <strong>Сидоров Станислав Андреевич</strong>.
Вы вошли как <strong>{ownerName}</strong>.
<br />
Скачивание будет зарегистрировано.
</div>
<button className="btn btn-acc">
Скачать защищённый PDF
</button>
</div>
</div>
</div> */}
<div className="watch-doc-actions">
<button className="btn btn-out">
Скопировать ссылку
</button>
<Link href="#" className="btn btn-out" target="_blank">
О NoCopy
</Link>
<DownloadButton />
<CopyLinkButton />
<Link href="#" className="btn btn-out" target="_blank">О NoCopy</Link>
</div>
</div>
<div>
<div
className="watch-doc-card"
>
<div
className="card-head"
>
Предосмотр
</div>
<div
className="card-body"
>
<div
className="prev-wrap"
>
документ
</div>
<div className="document-controls">
<div className="document-controls__nav">
<button className="document-controls__prev"> Пред.</button>
<span className="document-controls__current-page">1 / 11</span>
<button className="document-controls__next">След. </button>
</div>
<button className="document-controls__fullscreen">На весь экран</button>
</div>
<div className="watch-doc-card">
<div className="card-head">Предпросмотр</div>
<div className="card-body">
<PdfViewer
fileId={docid}
fileName={documentTitle}
/>
</div>
</div>
</div>
</div>
<div
className="watch-doc-footer"
>
<a href="#">NoCopy</a>
<a href="#">Условия</a>
<a href="#">Конфиденциальность</a>
<div className="watch-doc-footer">
<Link href="#">NoCopy</Link>
<Link href="#">Условия</Link>
<Link href="#">Конфиденциальность</Link>
</div>
</div>
</div>
)
);
}
+90
View File
@@ -0,0 +1,90 @@
'use server';
import { getSessionData } from '@/app/actions/session';
import { API_BASE_URL } from '@/app/actions/definitions';
import { headers } from 'next/headers';
export async function requestFileWithTracking(fileId: string) {
const token = await getSessionData('token');
const headersList = await headers();
const forwardedFor = headersList.get('x-forwarded-for');
const realIp = headersList.get('x-real-ip');
const ip = forwardedFor?.split(',')[0] || realIp || 'unknown';
const userAgent = headersList.get('user-agent') || 'unknown';
try {
const response = await fetch(`${API_BASE_URL}/api/file/link`, {
method: 'POST',
body: JSON.stringify({
file_id: fileId,
token: token,
ip: ip,
user_agent: userAgent
}),
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const data = await response.json();
return data.message_body;
} else {
const blob = await response.blob();
return URL.createObjectURL(blob);
}
} else {
throw new Error(`${response.status}`);
}
} catch (error) {
return null;
}
}
export async function requestFileAsBuffer(fileId: string): Promise<ArrayBuffer | null> {
const token = await getSessionData('token');
const headersList = await headers();
const forwardedFor = headersList.get('x-forwarded-for');
const realIp = headersList.get('x-real-ip');
const ip = forwardedFor?.split(',')[0] || realIp || 'unknown';
const userAgent = headersList.get('user-agent') || 'unknown';
try {
const response = await fetch(`${API_BASE_URL}/api/file/link`, {
method: 'POST',
body: JSON.stringify({
file_id: fileId,
token: token,
ip: ip,
user_agent: userAgent
}),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`${response.status}`);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const data = await response.json();
const fileUrl = data.message_body;
const fileResponse = await fetch(fileUrl);
if (!fileResponse.ok) {
throw new Error(`Failed to fetch file: ${fileResponse.status}`);
}
return await fileResponse.arrayBuffer();
}
else {
return await response.arrayBuffer();
}
} catch (error) {
return null;
}
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { usePdf } from '@/app/contexts/PdfContext';
interface PdfViewerProps {
fileId: string;
fileName?: string;
}
export default function PdfViewer({ fileId, fileName = 'document.pdf' }: PdfViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const pdfDocumentRef = useRef<any>(null);
const { setPdfData } = usePdf();
useEffect(() => {
let isMounted = true;
let objectUrl: string | null = null;
const loadAndDisplayPdf = async () => {
try {
setLoading(true);
// 1. Получаем данные документа как ArrayBuffer (а не blob URL)
const { requestFileAsBuffer } = await import('@/app/actions/trackingActions');
const pdfData = await requestFileAsBuffer(fileId);
if (!pdfData) {
throw new Error('Failed to load document');
}
setPdfData(pdfData, fileName);
if (!isMounted) return;
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
const loadingTask = pdfjs.getDocument({
data: pdfData,
useSystemFonts: true,
disableFontFace: false,
});
const pdfDocument = await loadingTask.promise;
if (!isMounted) return;
pdfDocumentRef.current = pdfDocument;
setTotalPages(pdfDocument.numPages);
setCurrentPage(1);
await renderPage(pdfDocument, 1);
} catch (err) {
console.error('Error loading PDF:', err);
if (isMounted) {
setError('Не удалось загрузить документ');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadAndDisplayPdf();
return () => {
isMounted = false;
if (pdfDocumentRef.current) {
pdfDocumentRef.current.destroy();
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [fileId, setPdfData]);
const renderPage = async (pdfDocument: any, pageNum: number) => {
if (!canvasRef.current) return;
try {
const page = await pdfDocument.getPage(pageNum);
const container = canvasRef.current.parentElement;
if (!container) return;
const containerWidth = container.clientWidth || 800;
// Если ширина контейнера 0, откладываем рендер
if (containerWidth === 0) {
console.log('Container width is 0, waiting...');
setTimeout(() => renderPage(pdfDocument, pageNum), 100);
return;
}
const originalViewport = page.getViewport({ scale: 1 });
const scale = containerWidth / originalViewport.width;
const viewport = page.getViewport({ scale: Math.min(scale, 1.5) });
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context!,
viewport: viewport,
};
await page.render(renderContext).promise;
} catch (err) {
console.log('Error rendering page:', err);
}
};
useEffect(() => {
if (!pdfDocumentRef.current || loading) return;
const checkContainer = () => {
const container = canvasRef.current?.parentElement;
if (container && container.clientWidth > 0) {
renderPage(pdfDocumentRef.current, currentPage);
} else {
setTimeout(checkContainer, 100);
}
};
checkContainer();
}, [pdfDocumentRef.current, loading]);
const goToPrevPage = async () => {
if (pdfDocumentRef.current && currentPage > 1) {
const newPage = currentPage - 1;
setCurrentPage(newPage);
await renderPage(pdfDocumentRef.current, newPage);
}
};
const goToNextPage = async () => {
if (pdfDocumentRef.current && currentPage < totalPages) {
const newPage = currentPage + 1;
setCurrentPage(newPage);
await renderPage(pdfDocumentRef.current, newPage);
}
};
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen();
setFullscreen(true);
} else {
document.exitFullscreen();
setFullscreen(false);
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setFullscreen(!!document.fullscreenElement);
if (pdfDocumentRef.current && !document.fullscreenElement) {
setTimeout(() => renderPage(pdfDocumentRef.current, currentPage), 100);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [currentPage]);
useEffect(() => {
const handleResize = () => {
if (pdfDocumentRef.current && !loading) {
renderPage(pdfDocumentRef.current, currentPage);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [currentPage, loading]);
if (loading) {
return (
<div className="prev-wrap">
<div className="pdf-loading">Загрузка документа...</div>
</div>
);
}
if (error) {
return (
<div className="prev-wrap">
<div className="pdf-error">{error}</div>
</div>
);
}
return (
<div ref={containerRef} className="pdf-container">
<div className="prev-wrap">
<canvas ref={canvasRef} className="pdf-canvas" />
</div>
{totalPages > 0 && (
<div className="document-controls">
<div className="document-controls__nav">
<button
className="document-controls__prev"
onClick={goToPrevPage}
disabled={currentPage === 1}
>
Пред.
</button>
<span className="document-controls__current-page">
{currentPage} / {totalPages}
</span>
<button
className="document-controls__next"
onClick={goToNextPage}
disabled={currentPage === totalPages}
>
След.
</button>
</div>
<button
className="document-controls__fullscreen"
onClick={toggleFullscreen}
>
{fullscreen ? 'Выйти' : 'На весь экран'}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { toast } from 'sonner';
interface CopyLinkButtonProps {
className?: string;
children?: React.ReactNode;
}
export function CopyLinkButton({ className = 'btn btn-out', children = 'Скопировать ссылку' }: CopyLinkButtonProps) {
const copyCurrentPageLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success('Ссылка скопирована');
} catch (err) {
toast.error('Не удалось скопировать ссылку');
}
};
return (
<button
className={className}
onClick={copyCurrentPageLink}
type="button"
>
{children}
</button>
);
}
@@ -0,0 +1,13 @@
'use client';
import { usePdf } from '@/app/contexts/PdfContext';
export function DownloadButton() {
const { downloadPdf } = usePdf();
return (
<button className="btn btn-out" onClick={downloadPdf}>
Скачать документ
</button>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { createContext, useContext, useRef, ReactNode } from 'react';
interface PdfContextType {
downloadPdf: () => Promise<void>;
setPdfData: (data: ArrayBuffer | null, fileName: string) => void;
}
const PdfContext = createContext<PdfContextType | null>(null);
export function PdfProvider({ children }: { children: ReactNode }) {
const pdfDataRef = useRef<ArrayBuffer | null>(null);
const fileNameRef = useRef<string>('');
const setPdfData = (data: ArrayBuffer | null, fileName: string) => {
pdfDataRef.current = data;
fileNameRef.current = fileName;
};
const downloadPdf = async () => {
if (!pdfDataRef.current) {
console.log('No PDF data available');
return;
}
try {
const blob = new Blob([pdfDataRef.current], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileNameRef.current || 'document.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
console.log('Error downloading PDF:', err);
}
};
return (
<PdfContext.Provider value={{ downloadPdf, setPdfData }}>
{children}
</PdfContext.Provider>
);
}
export function usePdf() {
const context = useContext(PdfContext);
if (!context) {
throw new Error('usePdf must be used within PdfProvider');
}
return context;
}
+58
View File
@@ -59,6 +59,7 @@
align-items: center;
justify-content: space-between;
margin-top: 8px;
gap: 10px;
&__nav {
display: flex;
@@ -106,6 +107,63 @@
}
}
}
.prev-wrap {
min-height: 400px;
min-width: 300px;
position: relative;
display: flex;
justify-content: center;
}
.pdf-canvas {
display: block;
max-width: 100%;
height: auto;
}
.pdf-container {
width: 100%;
overflow-x: auto;
}
.pdf-container:fullscreen {
background: #f5f5f5;
padding: 20px;
gap: 20px;
.prev-wrap {
flex: 1;
min-height: auto;
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
}
.pdf-canvas {
max-width: 100%;
max-height: calc(100vh - 120px);
width: auto;
height: auto;
object-fit: contain;
}
.document-controls {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
padding: 0.75rem 1.5rem;
z-index: 1000;
min-width: 300px;
justify-content: center;
}
}
}
&-page {