mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
co-authored by
a <a>
parent
82a3b8c770
commit
cdc288e78d
@@ -4856,7 +4856,7 @@ enableSepiaFilter = "Enable Sepia Filter"
|
|||||||
firstPage = "First Page"
|
firstPage = "First Page"
|
||||||
lastPage = "Last Page"
|
lastPage = "Last Page"
|
||||||
nextPage = "Next 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"
|
previousPage = "Previous Page"
|
||||||
singlePageView = "Single Page View"
|
singlePageView = "Single Page View"
|
||||||
unknownFile = "Unknown file"
|
unknownFile = "Unknown file"
|
||||||
@@ -4864,6 +4864,21 @@ zoomIn = "Zoom In"
|
|||||||
zoomOut = "Zoom Out"
|
zoomOut = "Zoom Out"
|
||||||
resetZoom = "Reset zoom"
|
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]
|
[rightRail]
|
||||||
closeSelected = "Close Selected Files"
|
closeSelected = "Close Selected Files"
|
||||||
selectAll = "Select All"
|
selectAll = "Select All"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||||
|
import { useViewer } from '@app/contexts/ViewerContext';
|
||||||
import { zipFileService } from '@app/services/zipFileService';
|
import { zipFileService } from '@app/services/zipFileService';
|
||||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||||
@@ -51,6 +52,9 @@ const FileEditor = ({
|
|||||||
// Get navigation actions
|
// Get navigation actions
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
|
||||||
|
// Get viewer context for setting active file index
|
||||||
|
const { setActiveFileIndex } = useViewer();
|
||||||
|
|
||||||
// Get file selection context
|
// Get file selection context
|
||||||
const { setSelectedFiles } = useFileSelection();
|
const { setSelectedFiles } = useFileSelection();
|
||||||
|
|
||||||
@@ -345,13 +349,14 @@ const FileEditor = ({
|
|||||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||||
|
|
||||||
const handleViewFile = useCallback((fileId: FileId) => {
|
const handleViewFile = useCallback((fileId: FileId) => {
|
||||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||||
if (record) {
|
if (index !== -1) {
|
||||||
// Set the file as selected in context and switch to viewer for preview
|
// Set the file as selected in context, sync the viewer index, and switch to viewer
|
||||||
setSelectedFiles([fileId]);
|
setSelectedFiles([fileId]);
|
||||||
|
setActiveFileIndex(index);
|
||||||
navActions.setWorkbench('viewer');
|
navActions.setWorkbench('viewer');
|
||||||
}
|
}
|
||||||
}, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
|
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]);
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|||||||
@@ -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 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 {
|
export type { ViewerProps };
|
||||||
sidebarsVisible: boolean;
|
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
previewFile?: File | null;
|
|
||||||
activeFileIndex?: number;
|
|
||||||
setActiveFileIndex?: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Viewer = (props: 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} />;
|
return <EmbedPdfViewer {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
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