plugin-ai-chat-file-preview 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
+ import { Modal, Button, Spin, Alert, message } from 'antd';
12
+ import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
13
+ import { useAPIClient } from '@nocobase/client';
14
+ import { SessionBlobCache } from './SessionBlobCache';
15
+ import { useTranslation } from './locale';
16
+
17
+ // ─── Supported MIME / extension lists ──────────────────────────────
18
+
19
+ const PDF_MIME = ['application/pdf'];
20
+ const IMAGE_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp'];
21
+ const TEXT_MIME = [
22
+ 'text/plain',
23
+ 'text/csv',
24
+ 'text/html',
25
+ 'text/css',
26
+ 'text/javascript',
27
+ 'application/json',
28
+ 'application/xml',
29
+ 'text/xml',
30
+ 'text/yaml',
31
+ 'application/x-yaml',
32
+ ];
33
+ const DOCX_MIME = ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
34
+ const XLSX_MIME = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
35
+
36
+ const PDF_EXT = ['pdf'];
37
+ const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
38
+ const TEXT_EXT = ['txt', 'csv', 'html', 'css', 'js', 'json', 'xml', 'log', 'md', 'yaml', 'yml', 'xaml'];
39
+ const DOCX_EXT = ['docx'];
40
+ const XLSX_EXT = ['xlsx', 'xls'];
41
+
42
+ // ─── Utilities ─────────────────────────────────────────────────────
43
+
44
+ export function getFileExt(file: any): string {
45
+ const value = typeof file === 'string' ? file : file?.extname || file?.name || file?.filename || file?.url || '';
46
+ const clean = value.split('?')[0].split('#')[0];
47
+ const idx = clean.lastIndexOf('.');
48
+ return idx !== -1
49
+ ? clean
50
+ .slice(idx + 1)
51
+ .toLowerCase()
52
+ .replace(/^\./, '')
53
+ : '';
54
+ }
55
+
56
+ export function resolveFileUrl(file: any): string {
57
+ const url = typeof file === 'string' ? file : file?.url;
58
+ if (!url) return '';
59
+ return url.startsWith('http://') || url.startsWith('https://') ? url : `${location.origin}/${url.replace(/^\//, '')}`;
60
+ }
61
+
62
+ function matchType(file: any, mimes: string[], exts: string[]): boolean {
63
+ if (file?.mimetype && mimes.includes(file.mimetype)) return true;
64
+ const ext = getFileExt(file);
65
+ return !!ext && exts.includes(ext);
66
+ }
67
+
68
+ export const isPdfFile = (f: any) => matchType(f, PDF_MIME, PDF_EXT);
69
+ export const isImageFile = (f: any) => matchType(f, IMAGE_MIME, IMAGE_EXT);
70
+ export const isTextFile = (f: any) => matchType(f, TEXT_MIME, TEXT_EXT);
71
+ export const isDocxFile = (f: any) => matchType(f, DOCX_MIME, DOCX_EXT);
72
+ export const isXlsxFile = (f: any) => matchType(f, XLSX_MIME, XLSX_EXT);
73
+ export const isPreviewableFile = (f: any) => isPdfFile(f) || isImageFile(f) || isTextFile(f) || isDocxFile(f) || isXlsxFile(f);
74
+
75
+ export function isPreviewableUrl(url: string): boolean {
76
+ return isPreviewableFile({ url });
77
+ }
78
+
79
+ // ─── Fetch helpers ─────────────────────────────────────────────────
80
+
81
+ async function fetchFileAsBlob(url: string, token: string): Promise<Blob> {
82
+ const headers: Record<string, string> = {};
83
+ if (token) headers['Authorization'] = `Bearer ${token}`;
84
+ const res = await fetch(url, { method: 'GET', headers, credentials: 'include' });
85
+ if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`);
86
+ return res.blob();
87
+ }
88
+
89
+ async function fetchFileAsText(url: string, token: string): Promise<string> {
90
+ const headers: Record<string, string> = {};
91
+ if (token) headers['Authorization'] = `Bearer ${token}`;
92
+ const res = await fetch(url, { method: 'GET', headers, credentials: 'include' });
93
+ if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`);
94
+ return res.text();
95
+ }
96
+
97
+ // ─── Download helper ───────────────────────────────────────────────
98
+
99
+ async function downloadFileWithAuth(file: any, token: string) {
100
+ const url = resolveFileUrl(file);
101
+ if (!url) return;
102
+ const blob = await fetchFileAsBlob(url, token);
103
+ const name = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'download';
104
+ const a = document.createElement('a');
105
+ const objectUrl = URL.createObjectURL(blob);
106
+ a.href = objectUrl;
107
+ a.download = name;
108
+ document.body.appendChild(a);
109
+ a.click();
110
+ document.body.removeChild(a);
111
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
112
+ }
113
+
114
+ // ─── Shared components ─────────────────────────────────────────────
115
+
116
+ function LoadingIndicator({ msg }: { msg: string }) {
117
+ return (
118
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', width: '100%' }}>
119
+ <Spin tip={msg} />
120
+ </div>
121
+ );
122
+ }
123
+
124
+ function ErrorMsg({ msg }: { msg: string }) {
125
+ return <div style={{ padding: 20, textAlign: 'center', color: '#ff4d4f' }}>{msg}</div>;
126
+ }
127
+
128
+ // ─── Hook: load blob with session cache ────────────────────────────
129
+
130
+ function useCachedBlobUrl(file: any, token: string, sessionId: string) {
131
+ const [blobUrl, setBlobUrl] = useState<string | null>(null);
132
+ const [loading, setLoading] = useState(true);
133
+ const [error, setError] = useState<string | null>(null);
134
+ const blobUrlRef = useRef<string | null>(null);
135
+
136
+ const fileId = file?.id || file?.uid || '';
137
+ const fileUrl = resolveFileUrl(file);
138
+
139
+ useEffect(() => {
140
+ let cancelled = false;
141
+ if (!fileUrl) {
142
+ setLoading(false);
143
+ setError('No file URL');
144
+ return;
145
+ }
146
+
147
+ setLoading(true);
148
+ setError(null);
149
+
150
+ (async () => {
151
+ try {
152
+ // Check IndexedDB cache first
153
+ if (sessionId && fileId) {
154
+ const cached = await SessionBlobCache.get(sessionId, String(fileId));
155
+ if (cached && !cancelled) {
156
+ const url = URL.createObjectURL(cached);
157
+ blobUrlRef.current = url;
158
+ setBlobUrl(url);
159
+ setLoading(false);
160
+ return;
161
+ }
162
+ }
163
+
164
+ // Fetch from server
165
+ const blob = await fetchFileAsBlob(fileUrl, token);
166
+ if (cancelled) return;
167
+
168
+ // Cache to IndexedDB
169
+ if (sessionId && fileId) {
170
+ SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
171
+ }
172
+
173
+ const url = URL.createObjectURL(blob);
174
+ blobUrlRef.current = url;
175
+ setBlobUrl(url);
176
+ setLoading(false);
177
+ } catch (err: any) {
178
+ if (cancelled) return;
179
+ setError(err.message || 'Failed to load');
180
+ setLoading(false);
181
+ }
182
+ })();
183
+
184
+ return () => {
185
+ cancelled = true;
186
+ if (blobUrlRef.current) {
187
+ URL.revokeObjectURL(blobUrlRef.current);
188
+ blobUrlRef.current = null;
189
+ }
190
+ };
191
+ }, [fileUrl, token, sessionId, fileId]);
192
+
193
+ return { blobUrl, loading, error };
194
+ }
195
+
196
+ function useCachedTextContent(file: any, token: string, sessionId: string) {
197
+ const [text, setText] = useState<string | null>(null);
198
+ const [loading, setLoading] = useState(true);
199
+ const [error, setError] = useState<string | null>(null);
200
+
201
+ const fileId = file?.id || file?.uid || '';
202
+ const fileUrl = resolveFileUrl(file);
203
+
204
+ useEffect(() => {
205
+ let cancelled = false;
206
+ if (!fileUrl) {
207
+ setLoading(false);
208
+ setError('No file URL');
209
+ return;
210
+ }
211
+
212
+ setLoading(true);
213
+ setError(null);
214
+
215
+ (async () => {
216
+ try {
217
+ // Check cache
218
+ if (sessionId && fileId) {
219
+ const cached = await SessionBlobCache.get(sessionId, String(fileId));
220
+ if (cached && !cancelled) {
221
+ const content = await cached.text();
222
+ setText(content);
223
+ setLoading(false);
224
+ return;
225
+ }
226
+ }
227
+
228
+ const content = await fetchFileAsText(fileUrl, token);
229
+ if (cancelled) return;
230
+
231
+ // Cache as blob
232
+ if (sessionId && fileId) {
233
+ const blob = new Blob([content], { type: 'text/plain' });
234
+ SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
235
+ }
236
+
237
+ setText(content);
238
+ setLoading(false);
239
+ } catch (err: any) {
240
+ if (cancelled) return;
241
+ setError(err.message || 'Failed to load');
242
+ setLoading(false);
243
+ }
244
+ })();
245
+
246
+ return () => {
247
+ cancelled = true;
248
+ };
249
+ }, [fileUrl, token, sessionId, fileId]);
250
+
251
+ return { text, loading, error };
252
+ }
253
+
254
+ // ─── Inline Previewers ─────────────────────────────────────────────
255
+
256
+ function PdfPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
257
+ const apiClient = useAPIClient();
258
+ const { t } = useTranslation();
259
+ const token = apiClient.auth?.token || '';
260
+ const { blobUrl, loading, error } = useCachedBlobUrl(file, token, sessionId);
261
+
262
+ if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
263
+ if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
264
+ return <iframe src={blobUrl} width="100%" height="100%" style={{ border: 'none' }} />;
265
+ }
266
+
267
+ function ImagePreviewer({ file, sessionId }: { file: any; sessionId: string }) {
268
+ const apiClient = useAPIClient();
269
+ const { t } = useTranslation();
270
+ const token = apiClient.auth?.token || '';
271
+ const { blobUrl, loading, error } = useCachedBlobUrl(file, token, sessionId);
272
+
273
+ if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
274
+ if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
275
+ return <img src={blobUrl} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} alt={file?.filename || ''} />;
276
+ }
277
+
278
+ function TextPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
279
+ const apiClient = useAPIClient();
280
+ const { t } = useTranslation();
281
+ const token = apiClient.auth?.token || '';
282
+ const { text, loading, error } = useCachedTextContent(file, token, sessionId);
283
+
284
+ if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
285
+ if (error || text === null) return <ErrorMsg msg={t('Failed to load file preview')} />;
286
+ return (
287
+ <pre
288
+ style={{
289
+ width: '100%',
290
+ height: '100%',
291
+ overflow: 'auto',
292
+ padding: 16,
293
+ margin: 0,
294
+ fontSize: 13,
295
+ lineHeight: 1.6,
296
+ whiteSpace: 'pre-wrap',
297
+ wordWrap: 'break-word',
298
+ background: '#f5f5f5',
299
+ border: 'none',
300
+ }}
301
+ >
302
+ {text}
303
+ </pre>
304
+ );
305
+ }
306
+
307
+ function DocxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
308
+ const apiClient = useAPIClient();
309
+ const { t } = useTranslation();
310
+ const token = apiClient.auth?.token || '';
311
+ const containerRef = useRef<HTMLDivElement>(null);
312
+ const [loading, setLoading] = useState(true);
313
+ const [error, setError] = useState<string | null>(null);
314
+ const fileUrl = resolveFileUrl(file);
315
+ const fileId = file?.id || file?.uid || '';
316
+
317
+ useEffect(() => {
318
+ let cancelled = false;
319
+ if (!fileUrl || !containerRef.current) {
320
+ setLoading(false);
321
+ setError('No file URL');
322
+ return;
323
+ }
324
+
325
+ setLoading(true);
326
+ setError(null);
327
+
328
+ (async () => {
329
+ try {
330
+ let blob: Blob;
331
+
332
+ if (sessionId && fileId) {
333
+ const cached = await SessionBlobCache.get(sessionId, String(fileId));
334
+ if (cached && !cancelled) {
335
+ blob = cached;
336
+ } else {
337
+ blob = await fetchFileAsBlob(fileUrl, token);
338
+ if (cancelled) return;
339
+ SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
340
+ }
341
+ } else {
342
+ blob = await fetchFileAsBlob(fileUrl, token);
343
+ }
344
+
345
+ if (cancelled || !containerRef.current) return;
346
+ const docxPreview = await import('docx-preview');
347
+ if (cancelled || !containerRef.current) return;
348
+ containerRef.current.innerHTML = '';
349
+ await docxPreview.renderAsync(blob, containerRef.current, undefined, {
350
+ className: 'docx-preview-wrapper',
351
+ inWrapper: true,
352
+ ignoreWidth: false,
353
+ ignoreHeight: false,
354
+ ignoreFonts: false,
355
+ breakPages: true,
356
+ ignoreLastRenderedPageBreak: true,
357
+ experimental: false,
358
+ trimXmlDeclaration: true,
359
+ useBase64URL: true,
360
+ renderHeaders: true,
361
+ renderFooters: true,
362
+ renderFootnotes: true,
363
+ renderEndnotes: true,
364
+ });
365
+ setLoading(false);
366
+ } catch (err: any) {
367
+ if (cancelled) return;
368
+ setError(err.message || 'Failed to render DOCX');
369
+ setLoading(false);
370
+ }
371
+ })();
372
+
373
+ return () => {
374
+ cancelled = true;
375
+ };
376
+ }, [fileUrl, token, sessionId, fileId]);
377
+
378
+ return (
379
+ <div style={{ width: '100%', height: '100%', position: 'relative' }}>
380
+ {loading && <LoadingIndicator msg={t('Loading preview...')} />}
381
+ {error && <ErrorMsg msg={t('Failed to load file preview')} />}
382
+ <div
383
+ ref={containerRef}
384
+ style={{
385
+ width: '100%',
386
+ height: '100%',
387
+ overflow: 'auto',
388
+ display: loading || error ? 'none' : 'block',
389
+ }}
390
+ />
391
+ </div>
392
+ );
393
+ }
394
+
395
+ function XlsxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
396
+ const apiClient = useAPIClient();
397
+ const { t } = useTranslation();
398
+ const token = apiClient.auth?.token || '';
399
+ const [loading, setLoading] = useState(true);
400
+ const [error, setError] = useState<string | null>(null);
401
+ const [sheetNames, setSheetNames] = useState<string[]>([]);
402
+ const [activeSheet, setActiveSheet] = useState('');
403
+ const [sheetsHtml, setSheetsHtml] = useState<Record<string, string>>({});
404
+ const fileUrl = resolveFileUrl(file);
405
+ const fileId = file?.id || file?.uid || '';
406
+
407
+ useEffect(() => {
408
+ let cancelled = false;
409
+ if (!fileUrl) {
410
+ setLoading(false);
411
+ setError('No file URL');
412
+ return;
413
+ }
414
+
415
+ setLoading(true);
416
+ setError(null);
417
+
418
+ (async () => {
419
+ try {
420
+ let blob: Blob;
421
+
422
+ if (sessionId && fileId) {
423
+ const cached = await SessionBlobCache.get(sessionId, String(fileId));
424
+ if (cached && !cancelled) {
425
+ blob = cached;
426
+ } else {
427
+ blob = await fetchFileAsBlob(fileUrl, token);
428
+ if (cancelled) return;
429
+ SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
430
+ }
431
+ } else {
432
+ blob = await fetchFileAsBlob(fileUrl, token);
433
+ }
434
+
435
+ if (cancelled) return;
436
+ const XLSX = await import('xlsx');
437
+ if (cancelled) return;
438
+ const arrayBuffer = await blob.arrayBuffer();
439
+ if (cancelled) return;
440
+ const workbook = XLSX.read(arrayBuffer, { type: 'array' });
441
+ const names = workbook.SheetNames as string[];
442
+ const htmlMap: Record<string, string> = {};
443
+ for (const name of names) {
444
+ htmlMap[name] = XLSX.utils.sheet_to_html(workbook.Sheets[name], { id: 'xlsx-preview-table' });
445
+ }
446
+ if (cancelled) return;
447
+ setSheetNames(names);
448
+ setActiveSheet(names[0] || '');
449
+ setSheetsHtml(htmlMap);
450
+ setLoading(false);
451
+ } catch (err: any) {
452
+ if (cancelled) return;
453
+ setError(err.message || 'Failed to render XLSX');
454
+ setLoading(false);
455
+ }
456
+ })();
457
+
458
+ return () => {
459
+ cancelled = true;
460
+ };
461
+ }, [fileUrl, token, sessionId, fileId]);
462
+
463
+ return (
464
+ <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
465
+ {loading && <LoadingIndicator msg={t('Loading preview...')} />}
466
+ {error && <ErrorMsg msg={t('Failed to load file preview')} />}
467
+ {!loading && !error && (
468
+ <>
469
+ {sheetNames.length > 1 && (
470
+ <div
471
+ style={{
472
+ display: 'flex',
473
+ gap: 0,
474
+ borderBottom: '1px solid #e8e8e8',
475
+ background: '#fafafa',
476
+ padding: '0 8px',
477
+ flexShrink: 0,
478
+ overflowX: 'auto',
479
+ }}
480
+ >
481
+ {sheetNames.map((name) => (
482
+ <button
483
+ key={name}
484
+ onClick={() => setActiveSheet(name)}
485
+ style={{
486
+ padding: '8px 16px',
487
+ border: 'none',
488
+ borderBottom: activeSheet === name ? '2px solid #1890ff' : '2px solid transparent',
489
+ background: activeSheet === name ? '#fff' : 'transparent',
490
+ color: activeSheet === name ? '#1890ff' : '#666',
491
+ fontWeight: activeSheet === name ? 600 : 400,
492
+ cursor: 'pointer',
493
+ fontSize: 13,
494
+ whiteSpace: 'nowrap',
495
+ transition: 'all 0.2s',
496
+ }}
497
+ >
498
+ {name}
499
+ </button>
500
+ ))}
501
+ </div>
502
+ )}
503
+ <div
504
+ style={{ flex: 1, overflow: 'auto', padding: 0 }}
505
+ dangerouslySetInnerHTML={{ __html: sheetsHtml[activeSheet] || '' }}
506
+ />
507
+ <style>{`
508
+ #xlsx-preview-table { border-collapse: collapse; width: 100%; font-size: 13px; }
509
+ #xlsx-preview-table td, #xlsx-preview-table th {
510
+ border: 1px solid #e8e8e8; padding: 6px 10px; text-align: left;
511
+ max-width: 33vw; white-space: normal; word-break: break-word;
512
+ }
513
+ #xlsx-preview-table tr:first-child td, #xlsx-preview-table tr:first-child th {
514
+ background: #fafafa; font-weight: 600; position: sticky; top: 0; z-index: 1;
515
+ }
516
+ #xlsx-preview-table tr:nth-child(even) { background: #fafafa; }
517
+ #xlsx-preview-table tr:hover { background: #f0f7ff; }
518
+ `}</style>
519
+ </>
520
+ )}
521
+ </div>
522
+ );
523
+ }
524
+
525
+ // ─── Main Preview Modal ────────────────────────────────────────────
526
+
527
+ export interface PreviewFile {
528
+ id?: string | number;
529
+ uid?: string;
530
+ url?: string;
531
+ filename?: string;
532
+ name?: string;
533
+ title?: string;
534
+ extname?: string;
535
+ mimetype?: string;
536
+ size?: number;
537
+ }
538
+
539
+ interface PreviewModalProps {
540
+ open: boolean;
541
+ file: PreviewFile | null;
542
+ sessionId: string;
543
+ onClose: () => void;
544
+ }
545
+
546
+ export const PreviewModal: React.FC<PreviewModalProps> = ({ open, file, sessionId, onClose }) => {
547
+ const apiClient = useAPIClient();
548
+ const { t } = useTranslation();
549
+ const [downloading, setDownloading] = useState(false);
550
+
551
+ const fileId = file?.id || file?.uid || '';
552
+
553
+ const onDownload = useCallback(async () => {
554
+ if (!file) return;
555
+ const token = apiClient.auth?.token || '';
556
+ setDownloading(true);
557
+ try {
558
+ await downloadFileWithAuth(file, token);
559
+ } catch {
560
+ message.error(t('Failed to download file'));
561
+ } finally {
562
+ setDownloading(false);
563
+ }
564
+ }, [file, apiClient, t]);
565
+
566
+ const onClearCache = useCallback(async () => {
567
+ if (!sessionId || !fileId) return;
568
+ await SessionBlobCache.delete(sessionId, String(fileId));
569
+ message.success(t('Cache cleared'));
570
+ }, [sessionId, fileId, t]);
571
+
572
+ const PreviewerComponent = useMemo(() => {
573
+ if (!file) return null;
574
+ if (isPdfFile(file)) return PdfPreviewer;
575
+ if (isImageFile(file)) return ImagePreviewer;
576
+ if (isTextFile(file)) return TextPreviewer;
577
+ if (isDocxFile(file)) return DocxPreviewer;
578
+ if (isXlsxFile(file)) return XlsxPreviewer;
579
+ return null;
580
+ }, [file]);
581
+
582
+ const canPreview = PreviewerComponent != null;
583
+ const title = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'File';
584
+
585
+ return (
586
+ <Modal
587
+ open={open}
588
+ title={title}
589
+ onCancel={onClose}
590
+ destroyOnClose
591
+ footer={[
592
+ sessionId && fileId ? (
593
+ <Button key="clear-cache" icon={<DeleteOutlined />} onClick={onClearCache}>
594
+ {t('Clear cache')}
595
+ </Button>
596
+ ) : null,
597
+ <Button key="download" icon={<DownloadOutlined />} onClick={onDownload} loading={downloading}>
598
+ {t('Download')}
599
+ </Button>,
600
+ <Button key="close" onClick={onClose}>
601
+ {t('Close')}
602
+ </Button>,
603
+ ].filter(Boolean)}
604
+ width={canPreview ? '85vw' : 520}
605
+ centered
606
+ >
607
+ <div
608
+ style={{
609
+ maxWidth: '100%',
610
+ maxHeight: canPreview ? 'calc(100vh - 256px)' : 'auto',
611
+ height: canPreview ? '70vh' : 'auto',
612
+ width: '100%',
613
+ background: 'white',
614
+ display: 'flex',
615
+ flexDirection: 'column',
616
+ justifyContent: 'center',
617
+ alignItems: 'center',
618
+ overflowY: 'auto',
619
+ }}
620
+ >
621
+ {canPreview && file ? (
622
+ <PreviewerComponent file={file} sessionId={sessionId} />
623
+ ) : (
624
+ <Alert
625
+ type="info"
626
+ style={{ width: '100%' }}
627
+ description={t('This file type cannot be previewed. Click Download to save the file.')}
628
+ showIcon
629
+ />
630
+ )}
631
+ </div>
632
+ </Modal>
633
+ );
634
+ };