mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
@@ -4856,7 +4856,7 @@ enableSepiaFilter = "Enable Sepia Filter"
|
||||
firstPage = "First Page"
|
||||
lastPage = "Last Page"
|
||||
nextPage = "Next Page"
|
||||
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
|
||||
onlyPdfSupported = "This file format is not supported for preview."
|
||||
previousPage = "Previous Page"
|
||||
singlePageView = "Single Page View"
|
||||
unknownFile = "Unknown file"
|
||||
@@ -4864,6 +4864,21 @@ zoomIn = "Zoom In"
|
||||
zoomOut = "Zoom Out"
|
||||
resetZoom = "Reset zoom"
|
||||
|
||||
[viewer.nonPdf]
|
||||
fileTypeBadge = "{{type}} File"
|
||||
convertToPdf = "Convert to PDF"
|
||||
loading = "Loading..."
|
||||
emptyFile = "Empty file"
|
||||
csvStats = "{{rows}} rows · {{columns}} columns · {{size}}"
|
||||
sortedBy = "Sorted by: {{column}}"
|
||||
columnDefault = "Column {{index}}"
|
||||
htmlPreviewWarning = "HTML preview — external resources may not load · {{size}}"
|
||||
htmlPreview = "HTML preview"
|
||||
invalidJson = "Invalid JSON — showing raw content"
|
||||
textStats = "{{lines}} lines · {{size}}"
|
||||
lineNumbers = "Line numbers"
|
||||
renderMarkdown = "Render markdown"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Close Selected Files"
|
||||
selectAll = "Select All"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||
@@ -51,6 +52,9 @@ const FileEditor = ({
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
// Get viewer context for setting active file index
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
@@ -345,13 +349,14 @@ const FileEditor = ({
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
// Set the file as selected in context, sync the viewer index, and switch to viewer
|
||||
setSelectedFiles([fileId]);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
113
frontend/src/core/components/viewer/NonPdfViewer.tsx
Normal file
113
frontend/src/core/components/viewer/NonPdfViewer.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Box, Button, Center, Stack, Text } from '@mantine/core';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { detectFileExtension, detectNonPdfFileType } from '@app/utils/fileUtils';
|
||||
import { CONVERSION_MATRIX } from '@app/constants/convertConstants';
|
||||
|
||||
import { NonPdfBanner } from '@app/components/viewer/nonpdf/NonPdfBanner';
|
||||
import { getFileTypeMeta } from '@app/components/viewer/nonpdf/types';
|
||||
import { ImageViewer } from '@app/components/viewer/nonpdf/ImageViewer';
|
||||
import { CsvViewer } from '@app/components/viewer/nonpdf/CsvViewer';
|
||||
import { JsonViewer } from '@app/components/viewer/nonpdf/JsonViewer';
|
||||
import { TextViewer } from '@app/components/viewer/nonpdf/TextViewer';
|
||||
import { HtmlViewer } from '@app/components/viewer/nonpdf/HtmlViewer';
|
||||
|
||||
export interface ViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface NonPdfViewerProps extends ViewerProps {
|
||||
file: File;
|
||||
}
|
||||
|
||||
export function NonPdfViewer({ file }: NonPdfViewerProps) {
|
||||
const fileType = useMemo(() => detectNonPdfFileType(file), [file]);
|
||||
const meta = useMemo(() => getFileTypeMeta(fileType, file.name), [fileType, file.name]);
|
||||
|
||||
const { handleToolSelect, toolAvailability } = useToolWorkflow();
|
||||
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
// Only show convert when the extension has an explicit entry in the conversion matrix
|
||||
// (skip the 'any'/'image' wildcard fallbacks that would match everything)
|
||||
const isConvertAvailable = toolAvailability['convert']?.available === true
|
||||
&& fileExtension !== ''
|
||||
&& fileExtension in CONVERSION_MATRIX;
|
||||
|
||||
const handleConvertToPdf = useCallback(() => {
|
||||
handleToolSelect('convert');
|
||||
}, [handleToolSelect]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return <ImageViewer file={file} fileName={file.name} />;
|
||||
case 'csv':
|
||||
return <CsvViewer file={file} isTsv={file.name.toLowerCase().endsWith('.tsv')} />;
|
||||
case 'json':
|
||||
return <JsonViewer file={file} />;
|
||||
case 'markdown':
|
||||
return <TextViewer file={file} isMarkdown />;
|
||||
case 'text':
|
||||
return <TextViewer file={file} isMarkdown={false} />;
|
||||
case 'html':
|
||||
return <HtmlViewer file={file} />;
|
||||
default:
|
||||
return (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<ArticleIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-4)' }} />
|
||||
<Text c="dimmed" size="sm">Preview not available for this file type</Text>
|
||||
{isConvertAvailable && (
|
||||
<Button variant="light" color="orange" leftSection={<PictureAsPdfIcon />} onClick={handleConvertToPdf}>
|
||||
Convert to PDF
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ height: '100%', flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
<NonPdfBanner
|
||||
meta={meta}
|
||||
onConvertToPdf={isConvertAvailable ? handleConvertToPdf : undefined}
|
||||
/>
|
||||
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{renderContent()}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wrapper that resolves the active file from FileContext ───────────────────
|
||||
|
||||
export function NonPdfViewerWrapper(props: ViewerProps) {
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIndex = props.activeFileIndex ?? 0;
|
||||
|
||||
const file = props.previewFile ?? activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="dimmed" size="sm">No file selected</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <NonPdfViewer {...props} file={file} />;
|
||||
}
|
||||
|
||||
export default NonPdfViewerWrapper;
|
||||
@@ -1,16 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import EmbedPdfViewer from '@app/components/viewer/EmbedPdfViewer';
|
||||
import { NonPdfViewerWrapper, type ViewerProps } from '@app/components/viewer/NonPdfViewer';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { isPdfFile } from '@app/utils/fileUtils';
|
||||
|
||||
export interface ViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
export type { ViewerProps };
|
||||
|
||||
const Viewer = (props: ViewerProps) => {
|
||||
// Default to EmbedPDF viewer
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIndex = props.activeFileIndex ?? 0;
|
||||
|
||||
// Determine the active file — previewFile takes priority
|
||||
const activeFile = useMemo(() => {
|
||||
if (props.previewFile) return props.previewFile;
|
||||
return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null;
|
||||
}, [props.previewFile, activeFiles, activeFileIndex]);
|
||||
|
||||
// Route to the appropriate viewer based on file type
|
||||
if (activeFile && !isPdfFile(activeFile)) {
|
||||
return <NonPdfViewerWrapper {...props} />;
|
||||
}
|
||||
|
||||
return <EmbedPdfViewer {...props} />;
|
||||
};
|
||||
|
||||
|
||||
191
frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx
Normal file
191
frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Center, Group, Paper, ScrollArea, Stack, Table, Text } from '@mantine/core';
|
||||
import SortIcon from '@mui/icons-material/Sort';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
|
||||
// ─── CSV parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCsv(text: string, delimiter: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let row: string[] = [];
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch === '"' && next === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (ch === '"') {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\r' && next === '\n') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
rows.push(row);
|
||||
row = [];
|
||||
i++;
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
rows.push(row);
|
||||
row = [];
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
}
|
||||
if (field || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
// Remove trailing empty row
|
||||
if (rows.length > 0 && rows[rows.length - 1].every(f => f === '')) {
|
||||
rows.pop();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── CSV viewer ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface CsvViewerProps {
|
||||
file: File;
|
||||
isTsv: boolean;
|
||||
}
|
||||
|
||||
export function CsvViewer({ file, isTsv }: CsvViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<string[][]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
file.text().then(text => {
|
||||
const delimiter = isTsv ? '\t' : ',';
|
||||
setRows(parseCsv(text, delimiter));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [file, isTsv]);
|
||||
|
||||
const headers = rows[0] ?? [];
|
||||
const dataRows = rows.slice(1);
|
||||
|
||||
const sortedDataRows = useMemo(() => {
|
||||
if (sortCol === null) return dataRows;
|
||||
return [...dataRows].sort((a, b) => {
|
||||
const av = a[sortCol] ?? '';
|
||||
const bv = b[sortCol] ?? '';
|
||||
const numA = Number(av);
|
||||
const numB = Number(bv);
|
||||
const cmp = (!isNaN(numA) && !isNaN(numB)) ? numA - numB : av.localeCompare(bv);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [dataRows, sortCol, sortAsc]);
|
||||
|
||||
const handleSort = (colIdx: number) => {
|
||||
if (sortCol === colIdx) {
|
||||
setSortAsc(a => !a);
|
||||
} else {
|
||||
setSortCol(colIdx);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.emptyFile')}</Text></Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
|
||||
{/* Stats bar */}
|
||||
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
|
||||
<Group gap="md" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('viewer.nonPdf.csvStats', { rows: dataRows.length.toLocaleString(), columns: headers.length, size: formatFileSize(file.size) })}
|
||||
</Text>
|
||||
{sortCol !== null && (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="xs"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => { setSortCol(null); setSortAsc(true); }}
|
||||
>
|
||||
{t('viewer.nonPdf.sortedBy', { column: headers[sortCol] || t('viewer.nonPdf.columnDefault', { index: sortCol + 1 }) })} {sortAsc ? '\u2191' : '\u2193'} \u2715
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Table */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Table
|
||||
striped
|
||||
highlightOnHover
|
||||
withColumnBorders
|
||||
withTableBorder={false}
|
||||
style={{ fontSize: 'var(--mantine-font-size-xs)', whiteSpace: 'nowrap' }}
|
||||
stickyHeader
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 48, color: 'var(--mantine-color-dimmed)', textAlign: 'center', paddingInline: 8 }}>
|
||||
#
|
||||
</Table.Th>
|
||||
{headers.map((h, i) => (
|
||||
<Table.Th
|
||||
key={i}
|
||||
style={{ cursor: 'pointer', paddingInline: 8 }}
|
||||
onClick={() => handleSort(i)}
|
||||
>
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<Text size="xs" fw={600} truncate style={{ maxWidth: 200 }}>{h || t('viewer.nonPdf.columnDefault', { index: i + 1 })}</Text>
|
||||
<SortIcon
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
opacity: sortCol === i ? 1 : 0.3,
|
||||
color: sortCol === i ? 'var(--mantine-color-teal-6)' : undefined,
|
||||
transform: sortCol === i && !sortAsc ? 'scaleY(-1)' : undefined,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sortedDataRows.map((row, ri) => (
|
||||
<Table.Tr key={ri}>
|
||||
<Table.Td style={{ color: 'var(--mantine-color-dimmed)', textAlign: 'center', paddingInline: 8 }}>
|
||||
{ri + 1}
|
||||
</Table.Td>
|
||||
{headers.map((_, ci) => (
|
||||
<Table.Td key={ci} style={{ paddingInline: 8, maxWidth: 300 }}>
|
||||
<Text size="xs" truncate title={row[ci]}>{row[ci] ?? ''}</Text>
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
36
frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx
Normal file
36
frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Paper, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
|
||||
interface HtmlViewerProps {
|
||||
file: File;
|
||||
}
|
||||
|
||||
export function HtmlViewer({ file }: HtmlViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setObjectUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">{t('viewer.nonPdf.htmlPreviewWarning', { size: formatFileSize(file.size) })}</Text>
|
||||
</Paper>
|
||||
{objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={t('viewer.nonPdf.htmlPreview')}
|
||||
sandbox="allow-scripts"
|
||||
style={{ flex: 1, border: 'none', background: '#fff' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx
Normal file
44
frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
interface ImageViewerProps {
|
||||
file: File;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ file, fileName }: ImageViewerProps) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setObjectUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={fileName}
|
||||
draggable={false}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx
Normal file
57
frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Center, Paper, ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface JsonViewerProps {
|
||||
file: File;
|
||||
}
|
||||
|
||||
export function JsonViewer({ file }: JsonViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [isInvalidJson, setIsInvalidJson] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
file.text().then(text => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
setContent(JSON.stringify(parsed, null, 2));
|
||||
setIsInvalidJson(false);
|
||||
} catch (_e) {
|
||||
// Show raw content if JSON is invalid
|
||||
setContent(text);
|
||||
setIsInvalidJson(true);
|
||||
}
|
||||
});
|
||||
}, [file]);
|
||||
|
||||
if (content === null) {
|
||||
return <Center style={{ flex: 1 }}><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
|
||||
{isInvalidJson && (
|
||||
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-red-2)', background: 'var(--mantine-color-red-0)', flexShrink: 0 }}>
|
||||
<Text size="xs" c="red">{t('viewer.nonPdf.invalidJson')}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<Box
|
||||
component="pre"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 'var(--mantine-spacing-sm)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
53
frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx
Normal file
53
frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Badge, Box, Button, Group } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { FileTypeMeta } from '@app/components/viewer/nonpdf/types';
|
||||
|
||||
interface NonPdfBannerProps {
|
||||
meta: FileTypeMeta;
|
||||
onConvertToPdf?: () => void;
|
||||
}
|
||||
|
||||
export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="default"
|
||||
size="lg"
|
||||
leftSection={
|
||||
<Box style={{ display: 'flex', alignItems: 'center', fontSize: '0.85rem' }}>
|
||||
{meta.icon}
|
||||
</Box>
|
||||
}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{t('viewer.nonPdf.fileTypeBadge', { type: meta.label })}
|
||||
</Badge>
|
||||
{onConvertToPdf && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PictureAsPdfIcon style={{ fontSize: '0.9rem' }} />}
|
||||
onClick={onConvertToPdf}
|
||||
>
|
||||
{t('viewer.nonPdf.convertToPdf')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
192
frontend/src/core/components/viewer/nonpdf/TextViewer.tsx
Normal file
192
frontend/src/core/components/viewer/nonpdf/TextViewer.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Center, Checkbox, Group, Paper, ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
|
||||
// ─── Markdown renderer ────────────────────────────────────────────────────────
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Patterns: **bold**, *italic*, `code`, [link](url)
|
||||
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
let last = 0;
|
||||
let match;
|
||||
let key = 0;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
if (match.index > last) parts.push(text.slice(last, match.index));
|
||||
if (match[2]) parts.push(<strong key={key++}>{match[2]}</strong>);
|
||||
else if (match[3]) parts.push(<em key={key++}>{match[3]}</em>);
|
||||
else if (match[4]) parts.push(<code key={key++} style={{ background: 'var(--mantine-color-gray-1)', padding: '0 3px', borderRadius: 3, fontFamily: 'monospace', fontSize: '0.85em' }}>{match[4]}</code>);
|
||||
else if (match[5]) parts.push(<a key={key++} href={match[6]} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--mantine-color-blue-6)' }}>{match[5]}</a>);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): React.ReactNode[] {
|
||||
const lines = text.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeLines: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
elements.push(
|
||||
<pre key={i} style={{ background: 'var(--mantine-color-gray-1)', padding: '8px 12px', borderRadius: 4, overflowX: 'auto', fontSize: '0.85em', margin: '4px 0' }}>
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
codeLines = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeLines.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('### ')) {
|
||||
elements.push(<Text key={i} fw={600} size="md" mt="xs" mb={2}>{renderInline(line.slice(4))}</Text>);
|
||||
} else if (line.startsWith('## ')) {
|
||||
elements.push(<Text key={i} fw={700} size="lg" mt="sm" mb={4}>{renderInline(line.slice(3))}</Text>);
|
||||
} else if (line.startsWith('# ')) {
|
||||
elements.push(<Text key={i} fw={800} size="xl" mt="md" mb={6}>{renderInline(line.slice(2))}</Text>);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
elements.push(
|
||||
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
|
||||
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>{'\u2022'}</Text>
|
||||
<Text size="sm" style={{ lineHeight: 1.6 }}>{renderInline(line.slice(2))}</Text>
|
||||
</Group>
|
||||
);
|
||||
} else if (/^\d+\.\s/.test(line)) {
|
||||
const num = line.match(/^(\d+)\.\s/)?.[1];
|
||||
const rest = line.replace(/^\d+\.\s/, '');
|
||||
elements.push(
|
||||
<Group key={i} gap={6} align="flex-start" style={{ paddingLeft: 16 }}>
|
||||
<Text size="sm" style={{ lineHeight: 1.6, flexShrink: 0 }}>{num}.</Text>
|
||||
<Text size="sm" style={{ lineHeight: 1.6 }}>{renderInline(rest)}</Text>
|
||||
</Group>
|
||||
);
|
||||
} else if (line.trim() === '' || line === '---' || line === '***') {
|
||||
elements.push(<Box key={i} style={{ height: 8 }} />);
|
||||
} else {
|
||||
elements.push(<Text key={i} size="sm" style={{ lineHeight: 1.7 }}>{renderInline(line)}</Text>);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// ─── Text / Markdown viewer ───────────────────────────────────────────────────
|
||||
|
||||
interface TextViewerProps {
|
||||
file: File;
|
||||
isMarkdown: boolean;
|
||||
}
|
||||
|
||||
export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(!isMarkdown);
|
||||
const [renderMd, setRenderMd] = useState(isMarkdown);
|
||||
|
||||
useEffect(() => {
|
||||
file.text().then(setContent);
|
||||
}, [file]);
|
||||
|
||||
const lines = content?.split('\n') ?? [];
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ height: '100%', flex: 1 }}>
|
||||
{/* Toolbar */}
|
||||
<Paper radius={0} p="xs" style={{ borderBottom: '1px solid var(--mantine-color-gray-2)', flexShrink: 0 }}>
|
||||
<Group gap="md" align="center">
|
||||
<Text size="xs" c="dimmed">{t('viewer.nonPdf.textStats', { lines: lines.length.toLocaleString(), size: formatFileSize(file.size) })}</Text>
|
||||
{!isMarkdown && (
|
||||
<Checkbox
|
||||
label={<Text size="xs">{t('viewer.nonPdf.lineNumbers')}</Text>}
|
||||
checked={showLineNumbers}
|
||||
onChange={e => setShowLineNumbers(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
{isMarkdown && (
|
||||
<Checkbox
|
||||
label={<Text size="xs">{t('viewer.nonPdf.renderMarkdown')}</Text>}
|
||||
checked={renderMd}
|
||||
onChange={e => setRenderMd(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
{content === null ? (
|
||||
<Center><Text c="dimmed" size="sm">{t('viewer.nonPdf.loading')}</Text></Center>
|
||||
) : isMarkdown && renderMd ? (
|
||||
<Box style={{ maxWidth: 800, margin: '0 auto', padding: '8px 0' }}>
|
||||
{renderMarkdown(content)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
component="pre"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
component="div"
|
||||
style={{ display: 'table-row' }}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: 'table-cell',
|
||||
paddingRight: 16,
|
||||
paddingLeft: 4,
|
||||
textAlign: 'right',
|
||||
color: 'var(--mantine-color-gray-5)',
|
||||
userSelect: 'none',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
minWidth: `${String(lines.length).length + 1}ch`,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Box>
|
||||
)}
|
||||
<Box component="span" style={{ display: 'table-cell', paddingLeft: showLineNumbers ? 12 : 0 }}>
|
||||
{line || '\u00A0'}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
51
frontend/src/core/components/viewer/nonpdf/types.ts
Normal file
51
frontend/src/core/components/viewer/nonpdf/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import DataObjectIcon from '@mui/icons-material/DataObject';
|
||||
import HtmlIcon from '@mui/icons-material/Html';
|
||||
|
||||
import type { NonPdfFileType } from '@app/utils/fileUtils';
|
||||
|
||||
export interface FileTypeMeta {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string; // Mantine color name (e.g. 'teal', 'violet')
|
||||
accentColor: string;
|
||||
borderColor: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
// Shared neutral color scheme for all file type badges — consistent in light & dark mode
|
||||
const BADGE_COLORS = {
|
||||
color: 'gray' as const,
|
||||
accentColor: 'var(--mantine-color-gray-6)',
|
||||
borderColor: 'var(--mantine-color-gray-3)',
|
||||
bgColor: 'var(--mantine-color-gray-0)',
|
||||
textColor: 'var(--mantine-color-gray-9)',
|
||||
};
|
||||
|
||||
export function getFileTypeMeta(type: NonPdfFileType, fileName?: string): FileTypeMeta {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return { label: 'Image', icon: React.createElement(ImageIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
case 'csv':
|
||||
return { label: 'Spreadsheet', icon: React.createElement(TableChartIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
case 'json':
|
||||
return { label: 'JSON', icon: React.createElement(DataObjectIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
case 'markdown':
|
||||
return { label: 'Markdown', icon: React.createElement(CodeIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
case 'html':
|
||||
return { label: 'HTML', icon: React.createElement(HtmlIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
case 'text':
|
||||
return { label: 'Text', icon: React.createElement(ArticleIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
default: {
|
||||
// For unknown types, derive label from the file extension (e.g. ".docx" → "DOCX")
|
||||
const ext = fileName?.split('.').pop()?.toUpperCase();
|
||||
const label = ext || 'File';
|
||||
return { label, icon: React.createElement(ArticleIcon, { fontSize: 'small' }), ...BADGE_COLORS };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,3 +97,45 @@ export function isPdfFile(file: { name?: string; type?: string } | File | Blob |
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export type NonPdfFileType = 'image' | 'csv' | 'json' | 'text' | 'markdown' | 'html' | 'unknown';
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'tiff', 'tif', 'webp']);
|
||||
const CSV_EXTENSIONS = new Set(['csv', 'tsv']);
|
||||
const JSON_EXTENSIONS = new Set(['json']);
|
||||
const TEXT_EXTENSIONS = new Set(['txt']);
|
||||
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']);
|
||||
const HTML_EXTENSIONS = new Set(['html', 'htm']);
|
||||
|
||||
/**
|
||||
* Detects the non-PDF file type category for viewer routing.
|
||||
* Returns 'unknown' for PDFs or unrecognized formats.
|
||||
*/
|
||||
export function detectNonPdfFileType(file: { name?: string; type?: string } | File | null | undefined): NonPdfFileType {
|
||||
if (!file) return 'unknown';
|
||||
|
||||
const name = 'name' in file ? file.name : undefined;
|
||||
const mimeType = file.type ?? '';
|
||||
|
||||
// Check MIME type first
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType === 'text/csv') return 'csv';
|
||||
if (mimeType === 'text/tab-separated-values') return 'csv';
|
||||
if (mimeType === 'application/json') return 'json';
|
||||
if (mimeType === 'text/html') return 'html';
|
||||
if (mimeType === 'text/markdown') return 'markdown';
|
||||
|
||||
// Fall back to extension
|
||||
if (name) {
|
||||
const ext = detectFileExtension(name);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
|
||||
if (CSV_EXTENSIONS.has(ext)) return 'csv';
|
||||
if (JSON_EXTENSIONS.has(ext)) return 'json';
|
||||
if (MARKDOWN_EXTENSIONS.has(ext)) return 'markdown';
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text';
|
||||
if (HTML_EXTENSIONS.has(ext)) return 'html';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user