PDFPreview.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { isFormatSupportedForPreview } from '../constants/fileSupport';
  3. import { PDFStatus } from '../types';
  4. import { pdfPreviewService } from '../services/pdfPreviewService';
  5. import { useToast } from '../contexts/ToastContext';
  6. import { useConfirm } from '../contexts/ConfirmContext';
  7. import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
  8. import { PDFSelectionTool } from './PDFSelectionTool';
  9. import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
  10. import { noteService } from '../services/noteService';
  11. import { useLanguage } from '../contexts/LanguageContext';
  12. import { knowledgeBaseService } from '../services/knowledgeBaseService';
  13. import * as pdfjs from 'pdfjs-dist';
  14. // Set worker path for PDF.js
  15. pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
  16. interface PDFPreviewProps {
  17. fileId: string;
  18. fileName: string;
  19. authToken: string;
  20. groupId?: string;
  21. onClose: () => void;
  22. }
  23. export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, onClose }) => {
  24. const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
  25. const [loading, setLoading] = useState(true);
  26. const [isFullscreen, setIsFullscreen] = useState(false);
  27. const [pdfUrl, setPdfUrl] = useState<string>('');
  28. const [iframeError, setIframeError] = useState(false);
  29. const [isSelectionMode, setIsSelectionMode] = useState(false);
  30. const [currentPage, setCurrentPage] = useState(1);
  31. const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
  32. const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
  33. const [numPages, setNumPages] = useState<number>(0);
  34. const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
  35. const [zoomLevel, setZoomLevel] = useState<number>(1.0); // Add zoom level state
  36. const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // Save current rendering task
  37. const scrollContainerRef = useRef<HTMLDivElement>(null);
  38. const flipDirection = useRef<'next' | 'prev' | null>(null);
  39. const lastFlipTime = useRef<number>(0);
  40. const { showToast } = useToast();
  41. const { confirm } = useConfirm();
  42. const { t, language } = useLanguage();
  43. const containerRef = React.useRef<HTMLDivElement>(null);
  44. const canvasRef = useRef<HTMLCanvasElement>(null);
  45. useEffect(() => {
  46. if (status.status === 'ready') {
  47. pdfPreviewService.getPDFUrl(fileId)
  48. .then(result => {
  49. setPdfUrl(result.url); // Set pdfUrl for download
  50. // Fetch PDF data and create blob URL
  51. fetch(result.url)
  52. .then(async response => {
  53. if (!response.ok) {
  54. const errorData = await response.json().catch(() => ({}));
  55. throw new Error(errorData.message || 'Failed to fetch PDF data');
  56. }
  57. return response.blob();
  58. })
  59. .then(blob => {
  60. setPdfBlob(blob);
  61. // Start fetching and rendering PDF document
  62. loadAndRenderPDF(blob);
  63. })
  64. .catch((err) => {
  65. console.error('PDF fetch error:', err);
  66. setIframeError(true);
  67. setStatus({ status: 'failed', error: err.message });
  68. });
  69. })
  70. .catch((err) => {
  71. console.error('getPDFUrl error:', err);
  72. setIframeError(true);
  73. setStatus({ status: 'failed', error: err.message });
  74. });
  75. }
  76. }, [status.status, fileId]);
  77. useEffect(() => {
  78. if (pdfDoc && currentPage) {
  79. // Re-render on page change or zoom level change
  80. renderCurrentPage(pdfDoc, currentPage);
  81. }
  82. }, [currentPage, pdfDoc, zoomLevel]);
  83. const isSupported = isFormatSupportedForPreview(fileName);
  84. useEffect(() => {
  85. if (isSupported) {
  86. checkPDFStatus();
  87. const interval = setInterval(checkPDFStatus, 3000);
  88. return () => clearInterval(interval);
  89. } else {
  90. setLoading(false);
  91. setStatus({ status: 'failed', error: t('previewNotSupported') });
  92. }
  93. }, [fileId, isSupported]);
  94. const checkPDFStatus = async () => {
  95. try {
  96. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  97. setStatus(pdfStatus);
  98. // Actively trigger conversion if status is pending
  99. if (pdfStatus.status === 'pending') {
  100. setStatus({ status: 'converting' });
  101. try {
  102. // Access PDF URL to trigger conversion
  103. await pdfPreviewService.preloadPDF(fileId);
  104. } catch (error) {
  105. console.log('Preload triggered, conversion should start');
  106. }
  107. }
  108. if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
  109. setLoading(false);
  110. }
  111. } catch (error: any) {
  112. setLoading(false);
  113. const errorMessage = error.message || t('checkPDFStatusFailed');
  114. setStatus({ status: 'failed', error: errorMessage });
  115. showToast(errorMessage, 'error');
  116. }
  117. };
  118. const loadAndRenderPDF = async (blob: Blob) => {
  119. try {
  120. const pdfData = await blob.arrayBuffer();
  121. const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
  122. setPdfDoc(pdf);
  123. setNumPages(pdf.numPages);
  124. if (currentPage > pdf.numPages) {
  125. setCurrentPage(pdf.numPages);
  126. }
  127. renderCurrentPage(pdf, currentPage);
  128. } catch (error) {
  129. console.error('Failed to load PDF:', error);
  130. setIframeError(true);
  131. }
  132. };
  133. const handleZoomIn = () => {
  134. setZoomLevel(prev => Math.min(3.0, prev + 0.1));
  135. };
  136. const handleZoomOut = () => {
  137. setZoomLevel(prev => Math.max(0.5, prev - 0.1));
  138. };
  139. const handleResetZoom = () => {
  140. setZoomLevel(1.0);
  141. };
  142. const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
  143. if (!canvasRef.current) return;
  144. try {
  145. // Cancel rendering task if one is in progress
  146. if (currentRenderTask.current) {
  147. currentRenderTask.current.cancel();
  148. }
  149. const page = await pdf.getPage(pageNum);
  150. const canvas = canvasRef.current;
  151. const context = canvas.getContext('2d');
  152. if (!context) return;
  153. // Get container dimensions
  154. if (!containerRef.current) return;
  155. const container = containerRef.current;
  156. const containerWidth = container.clientWidth;
  157. const containerHeight = container.clientHeight;
  158. // Handle high DPI displays
  159. const devicePixelRatio = window.devicePixelRatio || 1;
  160. // Calculate scale to fit page in container width while maintaining aspect ratio
  161. const viewport = page.getViewport({ scale: 1 });
  162. const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width
  163. // Apply zoom level to base scale
  164. const scale = baseScale * zoomLevel;
  165. const finalScale = scale * devicePixelRatio;
  166. const scaledViewport = page.getViewport({ scale: finalScale });
  167. const cssViewport = page.getViewport({ scale: scale });
  168. // Set canvas dimensions with device pixel ratio
  169. canvas.width = scaledViewport.width;
  170. canvas.height = scaledViewport.height;
  171. // Set CSS dimensions explicitly for high-DPI
  172. canvas.style.width = `${cssViewport.width}px`;
  173. canvas.style.height = `${cssViewport.height}px`;
  174. // Reset any previous transforms
  175. context.setTransform(1, 0, 0, 1, 0, 0);
  176. // Clear canvas
  177. context.clearRect(0, 0, canvas.width, canvas.height);
  178. // Fill with white background
  179. context.fillStyle = 'white';
  180. context.fillRect(0, 0, canvas.width, canvas.height);
  181. // Set proper transform for high DPI
  182. context.scale(devicePixelRatio, devicePixelRatio);
  183. // Create and save the new render task
  184. const renderContext = {
  185. canvasContext: context,
  186. viewport: page.getViewport({ scale: scale }),
  187. };
  188. // Save the render task to allow for cancellation
  189. currentRenderTask.current = page.render(renderContext);
  190. // Wait for rendering to complete
  191. await currentRenderTask.current.promise;
  192. // Clear the current render task
  193. currentRenderTask.current = null;
  194. // Adjust scroll position after page turn
  195. if (flipDirection.current && scrollContainerRef.current) {
  196. const container = scrollContainerRef.current;
  197. if (flipDirection.current === 'next') {
  198. container.scrollTop = 0;
  199. } else if (flipDirection.current === 'prev') {
  200. container.scrollTop = container.scrollHeight;
  201. }
  202. flipDirection.current = null;
  203. }
  204. } catch (error) {
  205. if (error instanceof Error && error.name !== 'RenderingCancelledException') {
  206. console.error('Failed to render PDF page:', error);
  207. }
  208. // Clear the current render task even if there's an error
  209. currentRenderTask.current = null;
  210. }
  211. };
  212. const handleFullscreen = () => {
  213. setIsFullscreen(!isFullscreen);
  214. };
  215. const handleDownload = () => {
  216. if (pdfUrl) {
  217. // Directly download if pdfUrl already exists
  218. const link = document.createElement('a');
  219. link.href = pdfUrl;
  220. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  221. document.body.appendChild(link);
  222. link.click();
  223. document.body.removeChild(link);
  224. } else {
  225. // Try fetching and downloading if pdfUrl does not exist
  226. pdfPreviewService.getPDFUrl(fileId)
  227. .then(result => {
  228. const link = document.createElement('a');
  229. link.href = result.url;
  230. link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
  231. document.body.appendChild(link);
  232. link.click();
  233. document.body.removeChild(link);
  234. })
  235. .catch(error => {
  236. console.error('Failed to download PDF:', error);
  237. showToast('error', t('downloadPDFFailed'));
  238. });
  239. }
  240. };
  241. const handleOpenInNewTab = () => {
  242. if (pdfUrl) {
  243. window.open(pdfUrl, '_blank');
  244. } else {
  245. // Try fetching and opening if pdfUrl does not exist
  246. pdfPreviewService.getPDFUrl(fileId)
  247. .then(result => {
  248. window.open(result.url, '_blank');
  249. })
  250. .catch(error => {
  251. console.error('Failed to open PDF in new tab:', error);
  252. showToast('error', t('openPDFInNewTabFailed'));
  253. });
  254. }
  255. };
  256. const handleRegenerate = async () => {
  257. if (await confirm(t('confirmRegeneratePDF'))) {
  258. setStatus({ status: 'converting' });
  259. setLoading(true);
  260. try {
  261. await pdfPreviewService.preloadPDF(fileId, true);
  262. // Reset state and trigger reload
  263. setPdfUrl('');
  264. setIframeError(false);
  265. setPdfDoc(null);
  266. setPdfBlob(null);
  267. setNumPages(0);
  268. } catch (error) {
  269. showToast('error', t('requestRegenerationFailed'));
  270. setStatus({ status: 'failed', error: t('requestRegenerationFailed') });
  271. }
  272. }
  273. };
  274. const handleIframeError = () => {
  275. setIframeError(true);
  276. };
  277. const handleWheel = (e: React.WheelEvent) => {
  278. if (!scrollContainerRef.current || isSelectionMode) return;
  279. const container = scrollContainerRef.current;
  280. const { scrollTop, scrollHeight, clientHeight } = container;
  281. const now = Date.now();
  282. const throttleMs = 600; // Prevent rapid page turning
  283. // Scroll down for next page
  284. if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) {
  285. if (currentPage < numPages && now - lastFlipTime.current > throttleMs) {
  286. flipDirection.current = 'next';
  287. lastFlipTime.current = now;
  288. setCurrentPage(prev => prev + 1);
  289. }
  290. }
  291. // Scroll up for previous page
  292. else if (e.deltaY < 0 && scrollTop <= 1) {
  293. if (currentPage > 1 && now - lastFlipTime.current > throttleMs) {
  294. flipDirection.current = 'prev';
  295. lastFlipTime.current = now;
  296. setCurrentPage(prev => prev - 1);
  297. }
  298. }
  299. };
  300. const handleSelectionComplete = (screenshot: Blob, text: string) => {
  301. // Set preliminary data and open dialog
  302. setSelectionData({ screenshot, text });
  303. setIsSelectionMode(false);
  304. };
  305. const handleSaveNote = async (title: string, content: string, selectedCategoryId?: string) => {
  306. if (!authToken || !selectionData) return;
  307. try {
  308. await noteService.createFromPDFSelection(
  309. authToken,
  310. fileId,
  311. selectionData.screenshot,
  312. undefined, // groupId is no longer used for notes from PDF
  313. selectedCategoryId,
  314. currentPage
  315. );
  316. showToast('success', t('noteCreatedSuccess'));
  317. setSelectionData(null);
  318. } catch (error) {
  319. console.error('Failed to create note:', error);
  320. showToast('error', t('noteCreatedFailed'));
  321. }
  322. };
  323. const renderContent = () => {
  324. switch (status.status) {
  325. case 'pending':
  326. return (
  327. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  328. <Loader size={48} className="animate-spin mb-4" />
  329. <div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
  330. <div className="text-sm">{t('pleaseWait')}</div>
  331. </div>
  332. );
  333. case 'converting':
  334. return (
  335. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  336. <Loader size={48} className="animate-spin mb-4" />
  337. <div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
  338. <div className="text-sm">{t('pleaseWait')}</div>
  339. </div>
  340. );
  341. case 'failed':
  342. return (
  343. <div className="flex flex-col items-center justify-center h-full text-red-500">
  344. <AlertCircle size={48} className="mb-4" />
  345. <div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
  346. <div className="text-sm text-gray-500 text-center max-w-md">
  347. {status.error || t('pdfConversionError')}
  348. </div>
  349. <button
  350. onClick={checkPDFStatus}
  351. className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  352. >
  353. {t('retry')}
  354. </button>
  355. </div>
  356. );
  357. case 'ready':
  358. if (iframeError) {
  359. return (
  360. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  361. <AlertCircle size={48} className="mb-4" />
  362. <div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
  363. <div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
  364. <div className="flex gap-2">
  365. <button
  366. onClick={handleDownload}
  367. className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
  368. >
  369. <Download size={16} />
  370. {t('downloadPDF')}
  371. </button>
  372. <button
  373. onClick={handleOpenInNewTab}
  374. className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
  375. >
  376. <ExternalLink size={16} />
  377. {t('openInNewWindow')}
  378. </button>
  379. </div>
  380. </div>
  381. );
  382. }
  383. if (!pdfDoc) {
  384. return (
  385. <div className="flex flex-col items-center justify-center h-full text-gray-500">
  386. <Loader size={48} className="animate-spin mb-4" />
  387. <div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
  388. </div>
  389. );
  390. }
  391. return (
  392. <div className="relative w-full h-full flex flex-col" ref={containerRef}>
  393. <div
  394. ref={scrollContainerRef}
  395. onWheel={handleWheel}
  396. className="flex-grow overflow-auto pdf-canvas-container bg-gray-100"
  397. >
  398. <div className="flex flex-col items-center py-12 pb-32 min-h-full">
  399. <canvas
  400. ref={canvasRef}
  401. className="bg-white shadow-xl max-w-full"
  402. />
  403. </div>
  404. </div>
  405. {isSelectionMode && (
  406. <PDFSelectionTool
  407. containerRef={containerRef}
  408. canvasRef={canvasRef}
  409. pdfBlob={pdfBlob}
  410. pageNumber={currentPage}
  411. authToken={authToken}
  412. zoomLevel={zoomLevel}
  413. onSelectionComplete={handleSelectionComplete}
  414. onCancel={() => setIsSelectionMode(false)}
  415. />
  416. )}
  417. <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-white/90 border border-slate-200 px-3 py-1.5 rounded-full shadow-lg z-40">
  418. <div className="flex items-center border-r border-slate-200 pr-2 mr-2">
  419. <button
  420. onClick={handleZoomOut}
  421. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  422. title={t('zoomOut')}
  423. >
  424. <span className="text-lg">−</span>
  425. </button>
  426. <span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
  427. <button
  428. onClick={handleZoomIn}
  429. className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
  430. title={t('zoomIn')}
  431. >
  432. <span className="text-lg">+</span>
  433. </button>
  434. <button
  435. onClick={handleResetZoom}
  436. className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
  437. title={t('resetZoom')}
  438. >
  439. 100%
  440. </button>
  441. </div>
  442. <button
  443. onClick={() => {
  444. const newPage = Math.max(1, currentPage - 1);
  445. setCurrentPage(newPage);
  446. }}
  447. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  448. >
  449. <ChevronLeft size={16} />
  450. </button>
  451. <div className="flex items-center gap-1">
  452. <input
  453. type="number"
  454. value={currentPage}
  455. onChange={(e) => {
  456. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  457. setCurrentPage(val);
  458. }}
  459. className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium"
  460. />
  461. <span className="text-sm text-slate-500">/ {numPages}</span>
  462. </div>
  463. <button
  464. onClick={() => {
  465. const newPage = Math.min(numPages, currentPage + 1);
  466. setCurrentPage(newPage);
  467. }}
  468. className="p-1 hover:bg-slate-100 rounded text-slate-600"
  469. >
  470. <ChevronRight size={16} />
  471. </button>
  472. </div>
  473. {selectionData && (
  474. <CreateNoteFromPDFDialog
  475. screenshot={selectionData.screenshot}
  476. extractedText={selectionData.text}
  477. authToken={authToken}
  478. initialCategoryId={undefined} // Notes don't inherit KB group
  479. initialPageNumber={currentPage}
  480. onSave={handleSaveNote}
  481. onCancel={() => setSelectionData(null)}
  482. />
  483. )}
  484. </div>
  485. );
  486. default:
  487. return null;
  488. }
  489. };
  490. return (
  491. <div className={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[999] ${isFullscreen ? 'p-0' : 'p-4'
  492. }`}>
  493. <div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
  494. }`}>
  495. {/* Header */}
  496. <div className="flex items-center justify-between p-4 border-b bg-gray-50">
  497. <div className="flex items-center space-x-3">
  498. <FileText size={20} className="text-gray-600" />
  499. <div>
  500. <div className="font-medium text-gray-900">{fileName}</div>
  501. <div className="text-sm text-gray-500">{t('pdfPreview')}</div>
  502. </div>
  503. </div>
  504. <div className="flex items-center space-x-2">
  505. {status.status === 'ready' && !iframeError && (
  506. <>
  507. <div className="flex items-center gap-2 mr-2 border-r pr-2">
  508. <span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
  509. <input
  510. type="number"
  511. min={1}
  512. max={numPages}
  513. value={currentPage}
  514. onChange={(e) => {
  515. const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
  516. setCurrentPage(val);
  517. }}
  518. className="w-16 px-2 py-1 border rounded text-sm"
  519. title={t('enterPageNumber')}
  520. />
  521. </div>
  522. <button
  523. onClick={() => setIsSelectionMode(!isSelectionMode)}
  524. className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
  525. title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
  526. >
  527. <Scissors size={18} />
  528. </button>
  529. <button
  530. onClick={handleRegenerate}
  531. className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
  532. title={t('regeneratePDF')}
  533. >
  534. <RefreshCw size={18} />
  535. </button>
  536. <button
  537. onClick={handleDownload}
  538. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  539. title={t('downloadPDF')}
  540. >
  541. <Download size={18} />
  542. </button>
  543. <button
  544. onClick={handleOpenInNewTab}
  545. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  546. title={t('openInNewWindow')}
  547. >
  548. <ExternalLink size={18} />
  549. </button>
  550. <button
  551. onClick={handleFullscreen}
  552. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  553. title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
  554. >
  555. <Maximize2 size={18} />
  556. </button>
  557. </>
  558. )}
  559. <button
  560. onClick={onClose}
  561. className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
  562. >
  563. <X size={18} />
  564. </button>
  565. </div>
  566. </div>
  567. {/* Content Area */}
  568. <div className="flex-1 h-full">
  569. {renderContent()}
  570. </div>
  571. </div>
  572. </div>
  573. );
  574. };
  575. interface PDFPreviewButtonProps {
  576. fileId: string;
  577. fileName: string;
  578. onPreview: () => void;
  579. }
  580. export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
  581. fileId,
  582. fileName,
  583. onPreview
  584. }) => {
  585. const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
  586. const [loading, setLoading] = useState(true);
  587. const { t } = useLanguage();
  588. const isSupported = isFormatSupportedForPreview(fileName);
  589. useEffect(() => {
  590. if (isSupported) {
  591. checkStatus();
  592. } else {
  593. setLoading(false);
  594. }
  595. }, [fileId, isSupported]);
  596. const checkStatus = async () => {
  597. try {
  598. const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
  599. setStatus(pdfStatus);
  600. } catch (error) {
  601. // Ignore error and use default state
  602. } finally {
  603. setLoading(false);
  604. }
  605. };
  606. const getIcon = () => {
  607. if (!isSupported) {
  608. return <Eye className="w-3 h-3 text-slate-200" />;
  609. }
  610. if (loading || status.status === 'converting') {
  611. return <Loader className="w-3 h-3 animate-spin" />;
  612. }
  613. if (status.status === 'failed') {
  614. return <AlertCircle className="w-3 h-3" />;
  615. }
  616. return <Eye className="w-3 h-3" />;
  617. };
  618. const getTitle = () => {
  619. if (!isSupported) return t('previewNotSupported');
  620. switch (status.status) {
  621. case 'ready': return t('pdfPreviewReady');
  622. case 'converting': return t('convertingInProgress');
  623. case 'failed': return t('conversionFailed');
  624. default: return t('generatePDFPreviewButton');
  625. }
  626. };
  627. return (
  628. <button
  629. onClick={onPreview}
  630. disabled={loading || status.status === 'converting' || !isSupported}
  631. className={`p-1 rounded transition-colors ${!isSupported
  632. ? 'text-slate-200 cursor-not-allowed'
  633. : status.status === 'failed'
  634. ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
  635. : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
  636. } disabled:opacity-50 disabled:cursor-not-allowed`}
  637. title={getTitle()}
  638. >
  639. {getIcon()}
  640. </button>
  641. );
  642. };