diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 6ba6ca42be..bde7c6d1d2 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -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"
diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx
index 8636d82c06..eac0cca7fc 100644
--- a/frontend/src/core/components/fileEditor/FileEditor.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditor.tsx
@@ -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;
diff --git a/frontend/src/core/components/viewer/NonPdfViewer.tsx b/frontend/src/core/components/viewer/NonPdfViewer.tsx
new file mode 100644
index 0000000000..dd13a4a889
--- /dev/null
+++ b/frontend/src/core/components/viewer/NonPdfViewer.tsx
@@ -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 ;
+ case 'csv':
+ return ;
+ case 'json':
+ return ;
+ case 'markdown':
+ return ;
+ case 'text':
+ return ;
+ case 'html':
+ return ;
+ default:
+ return (
+
+
+
+ Preview not available for this file type
+ {isConvertAvailable && (
+ } onClick={handleConvertToPdf}>
+ Convert to PDF
+
+ )}
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+ {renderContent()}
+
+
+ );
+}
+
+// ─── 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 (
+
+ No file selected
+
+ );
+ }
+
+ return ;
+}
+
+export default NonPdfViewerWrapper;
diff --git a/frontend/src/core/components/viewer/Viewer.tsx b/frontend/src/core/components/viewer/Viewer.tsx
index e296c9d6d8..adea7d8145 100644
--- a/frontend/src/core/components/viewer/Viewer.tsx
+++ b/frontend/src/core/components/viewer/Viewer.tsx
@@ -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 ;
+ }
+
return ;
};
diff --git a/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx b/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx
new file mode 100644
index 0000000000..298109a9e7
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/CsvViewer.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [sortCol, setSortCol] = useState(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 {t('viewer.nonPdf.loading')};
+ }
+
+ if (rows.length === 0) {
+ return {t('viewer.nonPdf.emptyFile')};
+ }
+
+ return (
+
+ {/* Stats bar */}
+
+
+
+ {t('viewer.nonPdf.csvStats', { rows: dataRows.length.toLocaleString(), columns: headers.length, size: formatFileSize(file.size) })}
+
+ {sortCol !== null && (
+ { setSortCol(null); setSortAsc(true); }}
+ >
+ {t('viewer.nonPdf.sortedBy', { column: headers[sortCol] || t('viewer.nonPdf.columnDefault', { index: sortCol + 1 }) })} {sortAsc ? '\u2191' : '\u2193'} \u2715
+
+ )}
+
+
+
+ {/* Table */}
+
+
+
+
+
+ #
+
+ {headers.map((h, i) => (
+ handleSort(i)}
+ >
+
+ {h || t('viewer.nonPdf.columnDefault', { index: i + 1 })}
+
+
+
+ ))}
+
+
+
+ {sortedDataRows.map((row, ri) => (
+
+
+ {ri + 1}
+
+ {headers.map((_, ci) => (
+
+ {row[ci] ?? ''}
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx b/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx
new file mode 100644
index 0000000000..d970c353b8
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/HtmlViewer.tsx
@@ -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(null);
+
+ useEffect(() => {
+ const url = URL.createObjectURL(file);
+ setObjectUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }, [file]);
+
+ return (
+
+
+ {t('viewer.nonPdf.htmlPreviewWarning', { size: formatFileSize(file.size) })}
+
+ {objectUrl && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx b/frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx
new file mode 100644
index 0000000000..187d2b17fb
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/ImageViewer.tsx
@@ -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(null);
+
+ useEffect(() => {
+ const url = URL.createObjectURL(file);
+ setObjectUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }, [file]);
+
+ return (
+
+ {objectUrl && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx b/frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx
new file mode 100644
index 0000000000..9dabf3f6e1
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/JsonViewer.tsx
@@ -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(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 {t('viewer.nonPdf.loading')};
+ }
+
+ return (
+
+ {isInvalidJson && (
+
+ {t('viewer.nonPdf.invalidJson')}
+
+ )}
+
+
+ {content}
+
+
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx b/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx
new file mode 100644
index 0000000000..b890f889ac
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx
@@ -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 (
+
+
+ {meta.icon}
+
+ }
+ style={{ fontWeight: 600 }}
+ >
+ {t('viewer.nonPdf.fileTypeBadge', { type: meta.label })}
+
+ {onConvertToPdf && (
+ }
+ onClick={onConvertToPdf}
+ >
+ {t('viewer.nonPdf.convertToPdf')}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx
new file mode 100644
index 0000000000..f25daa21c6
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx
@@ -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({match[2]});
+ else if (match[3]) parts.push({match[3]});
+ else if (match[4]) parts.push({match[4]});
+ else if (match[5]) parts.push({match[5]});
+ 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(
+
+ {codeLines.join('\n')}
+
+ );
+ codeLines = [];
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+ i++;
+ continue;
+ }
+
+ if (inCodeBlock) {
+ codeLines.push(line);
+ i++;
+ continue;
+ }
+
+ if (line.startsWith('### ')) {
+ elements.push({renderInline(line.slice(4))});
+ } else if (line.startsWith('## ')) {
+ elements.push({renderInline(line.slice(3))});
+ } else if (line.startsWith('# ')) {
+ elements.push({renderInline(line.slice(2))});
+ } else if (line.startsWith('- ') || line.startsWith('* ')) {
+ elements.push(
+
+ {'\u2022'}
+ {renderInline(line.slice(2))}
+
+ );
+ } else if (/^\d+\.\s/.test(line)) {
+ const num = line.match(/^(\d+)\.\s/)?.[1];
+ const rest = line.replace(/^\d+\.\s/, '');
+ elements.push(
+
+ {num}.
+ {renderInline(rest)}
+
+ );
+ } else if (line.trim() === '' || line === '---' || line === '***') {
+ elements.push();
+ } else {
+ elements.push({renderInline(line)});
+ }
+ 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(null);
+ const [showLineNumbers, setShowLineNumbers] = useState(!isMarkdown);
+ const [renderMd, setRenderMd] = useState(isMarkdown);
+
+ useEffect(() => {
+ file.text().then(setContent);
+ }, [file]);
+
+ const lines = content?.split('\n') ?? [];
+
+ return (
+
+ {/* Toolbar */}
+
+
+ {t('viewer.nonPdf.textStats', { lines: lines.length.toLocaleString(), size: formatFileSize(file.size) })}
+ {!isMarkdown && (
+ {t('viewer.nonPdf.lineNumbers')}}
+ checked={showLineNumbers}
+ onChange={e => setShowLineNumbers(e.currentTarget.checked)}
+ size="xs"
+ />
+ )}
+ {isMarkdown && (
+ {t('viewer.nonPdf.renderMarkdown')}}
+ checked={renderMd}
+ onChange={e => setRenderMd(e.currentTarget.checked)}
+ size="xs"
+ />
+ )}
+
+
+
+ {/* Content */}
+
+ {content === null ? (
+ {t('viewer.nonPdf.loading')}
+ ) : isMarkdown && renderMd ? (
+
+ {renderMarkdown(content)}
+
+ ) : (
+
+ {lines.map((line, i) => (
+
+ {showLineNumbers && (
+
+ {i + 1}
+
+ )}
+
+ {line || '\u00A0'}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/core/components/viewer/nonpdf/types.ts b/frontend/src/core/components/viewer/nonpdf/types.ts
new file mode 100644
index 0000000000..92c69fd88d
--- /dev/null
+++ b/frontend/src/core/components/viewer/nonpdf/types.ts
@@ -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 };
+ }
+ }
+}
diff --git a/frontend/src/core/utils/fileUtils.ts b/frontend/src/core/utils/fileUtils.ts
index 4061884b55..ed76f6060c 100644
--- a/frontend/src/core/utils/fileUtils.ts
+++ b/frontend/src/core/utils/fileUtils.ts
@@ -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';
+}
+