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
+265 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "no-copy-frontend", "name": "no-copy-frontend",
"version": "0.58.0", "version": "0.104.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "no-copy-frontend", "name": "no-copy-frontend",
"version": "0.58.0", "version": "0.104.0",
"dependencies": { "dependencies": {
"@a2seven/yoo-checkout": "^1.1.4", "@a2seven/yoo-checkout": "^1.1.4",
"@react-input/mask": "^2.0.4", "@react-input/mask": "^2.0.4",
@@ -21,6 +21,7 @@
"next": "^16.0.7", "next": "^16.0.7",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"pdfjs-dist": "^5.7.284",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-slick": "^0.31.0", "react-slick": "^0.31.0",
@@ -1129,6 +1130,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -6573,6 +6824,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfjs-dist": {
"version": "5.7.284",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.100"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -22,6 +22,7 @@
"next": "^16.0.7", "next": "^16.0.7",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"pdfjs-dist": "^5.7.284",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-slick": "^0.31.0", "react-slick": "^0.31.0",
+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 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 { interface PageProps {
searchParams: Promise<{ docid?: string }>; searchParams: Promise<{ docid: string; title?: string }>;
} }
export default async function Page({ export default async function Page({ searchParams }: PageProps) {
searchParams const { docid, title } = await searchParams;
}: PageProps) {
const { docid } = await searchParams; const documentTitle = title || 'FileName';
const ownerName = "Name Name Name";
const companyName = "companyName";
return ( return (
<div <div className="watch-doc-wrapper">
className="watch-doc-wrapper" <div className="watch-doc-page">
>
<div
className="watch-doc-page"
>
<header className="watch-doc-header"> <header className="watch-doc-header">
<Link href="#" className="header-logo">NoCopy</Link> <Link href="#" className="header-logo">NoCopy</Link>
<Link href="#" className="header-user">Name</Link> <Link href="#" className="header-user">Name</Link>
</header> </header>
<div <div className="watch-doc-status">
className="watch-doc-status"
>
<div className="st-title">Защита подтверждена</div> <div className="st-title">Защита подтверждена</div>
<div className="st-sub">Документ <strong>Название документа</strong> зарегистрирован и защищён платформой NoCopy.</div> <div className="st-sub">
Документ <strong>{documentTitle}</strong> зарегистрирован и защищён платформой NoCopy.
</div>
</div> </div>
<div <div className="watch-doc-grid">
className="watch-doc-grid"
>
<div> <div>
<div <div className="watch-doc-card">
className="watch-doc-card" <div className="card-head">Правообладатель</div>
> <div className="card-body">
<div
className="card-head"
>
Правообладатель
</div>
<div
className="card-body"
>
<div className="owner-row"> <div className="owner-row">
<div className="owner-av">N</div> <div className="owner-av">Н</div>
<div> <div>
<div className="owner-name">Сидоров Станислав Андреевич</div> <div className="owner-name">{ownerName}</div>
<div className="owner-meta">ООО "ПРОКОНТРОЛ"</div> {/* <div className="owner-meta">{companyName}</div> */}
<div className="owner-meta">Зарегистрирован в NoCopy</div> <div className="owner-meta">Зарегистрирован в NoCopy</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div {/* <div className="watch-doc-card">
className="watch-doc-card" <div className="card-head">Скачать документ</div>
> <div className="card-body">
<div <DownloadButton />
className="card-head"
>
Скачать документ
</div>
<div
className="card-body"
>
<div className="download-description"> <div className="download-description">
Вы вошли как <strong>Сидоров Станислав Андреевич</strong>. Вы вошли как <strong>{ownerName}</strong>.
<br /> <br />
Скачивание будет зарегистрировано. Скачивание будет зарегистрировано.
</div> </div>
<button className="btn btn-acc">
Скачать защищённый PDF
</button>
</div> </div>
</div> </div> */}
<div className="watch-doc-actions"> <div className="watch-doc-actions">
<button className="btn btn-out"> <DownloadButton />
Скопировать ссылку <CopyLinkButton />
</button> <Link href="#" className="btn btn-out" target="_blank">О NoCopy</Link>
<Link href="#" className="btn btn-out" target="_blank">
О NoCopy
</Link>
</div> </div>
</div> </div>
<div> <div>
<div <div className="watch-doc-card">
className="watch-doc-card" <div className="card-head">Предпросмотр</div>
> <div className="card-body">
<div <PdfViewer
className="card-head" fileId={docid}
> fileName={documentTitle}
Предосмотр />
</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> </div>
</div> </div>
</div> </div>
</div> </div>
<div <div className="watch-doc-footer">
className="watch-doc-footer" <Link href="#">NoCopy</Link>
> <Link href="#">Условия</Link>
<a href="#">NoCopy</a> <Link href="#">Конфиденциальность</Link>
<a href="#">Условия</a>
<a href="#">Конфиденциальность</a>
</div> </div>
</div> </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; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 8px; margin-top: 8px;
gap: 10px;
&__nav { &__nav {
display: flex; 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 { &-page {