diff --git a/client/src/components/Files/FileManager.helpers.ts b/client/src/components/Files/FileManager.helpers.ts index 2d9b91b6..b9568e83 100644 --- a/client/src/components/Files/FileManager.helpers.ts +++ b/client/src/components/Files/FileManager.helpers.ts @@ -15,6 +15,17 @@ export function isMedia(mimeType?: string | null) { return isImage(mimeType) || isVideo(mimeType) } +/** + * Markdown file (#1345). Detected by EXTENSION first — browsers often send an + * empty / octet-stream / text/plain MIME for .md — falling back to the markdown + * MIME types. + */ +export function isMarkdown(mimeType?: string | null, name?: string | null) { + const ext = (name || '').toLowerCase().split('.').pop() + if (ext === 'md' || ext === 'markdown') return true + return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown') +} + export function getFileIcon(mimeType?: string | null) { if (!mimeType) return File if (mimeType === 'application/pdf') return FileText diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 323589e4..cd4dd40f 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'), })); +// Markdown pipeline mocked to render its children verbatim (the unified/ESM +// pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal. +vi.mock('react-markdown', () => ({ + default: ({ children }: { children: string }) => {children}, +})); +vi.mock('remark-gfm', () => ({ default: () => ({}) })); +vi.mock('remark-breaks', () => ({ default: () => ({}) })); +vi.mock('rehype-sanitize', () => ({ default: () => ({}) })); + // Mock filesApi vi.mock('../../api/client', async (importOriginal) => { const original = (await importOriginal()) as any; @@ -289,6 +298,21 @@ describe('FileManager', () => { }); }); + it('FE-COMP-FILEMANAGER-034: markdown file click opens an inline rendered preview (#1345)', async () => { + server.use(http.get('http://localhost/signed-url', () => HttpResponse.text('# Hello heading\n\nworld body'))); + const files = [buildFile({ id: 1, mime_type: 'text/markdown', original_name: 'notes.md' })]; + render(); + const user = userEvent.setup(); + + await user.click(screen.getByText('notes.md')); + + await waitFor(() => { + const md = screen.getByTestId('md'); + expect(md).toBeInTheDocument(); + expect(md.textContent).toContain('Hello heading'); + }); + }); + it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => { const files = [buildFile({ uploaded_by_name: 'Alice Smith' })]; render(); diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 10f142b0..74b9d22f 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -2,6 +2,8 @@ import { useFileManager, type FileManagerProps } from './useFileManager' import { ImageLightbox } from './FileManagerImageLightbox' import { AssignModal } from './FileManagerAssignModal' import { PdfPreviewModal } from './FileManagerPdfPreviewModal' +import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal' +import { isMarkdown } from './FileManager.helpers' import { FileManagerToolbar } from './FileManagerToolbar' import { TrashView } from './FileManagerTrashView' import { FilesView } from './FileManagerFilesView' @@ -17,8 +19,10 @@ export default function FileManager(props: FileManagerProps) { {/* Assign modal */} {assignFileId && } - {/* PDF preview modal */} - {previewFile && } + {/* Document preview modal (markdown is rendered inline; everything else PDF/object) */} + {previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name) + ? + : )} {/* Toolbar */} diff --git a/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx b/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx new file mode 100644 index 00000000..351b5039 --- /dev/null +++ b/client/src/components/Files/FileManagerMarkdownPreviewModal.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import ReactDOM from 'react-dom' +import { ExternalLink, Download, X } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import rehypeSanitize from 'rehype-sanitize' +import { openFile as openFileUrl } from '../../utils/fileDownload' +import type { FileManagerState } from './useFileManager' +import { triggerDownload } from './FileManager.helpers' + +/** + * Inline preview for uploaded Markdown files (#1345). Fetches the file's text via + * the signed preview URL and renders it with react-markdown. Output is sanitized + * with rehype-sanitize — these are UNTRUSTED uploads, unlike collab notes — and + * react-markdown v10 already drops raw HTML, so no script can execute. + */ +export function MarkdownPreviewModal(S: FileManagerState) { + const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S + const [text, setText] = useState('') + const [err, setErr] = useState(false) + + useEffect(() => { + if (!previewFileUrl) return + let cancelled = false + setErr(false) + setText('') + fetch(previewFileUrl, { credentials: 'include' }) + .then(r => (r.ok ? r.text() : Promise.reject(new Error('load failed')))) + .then(body => { if (!cancelled) setText(body) }) + .catch(() => { if (!cancelled) setErr(true) }) + return () => { cancelled = true } + }, [previewFileUrl]) + + return ReactDOM.createPortal( +
setPreviewFile(null)} + > +
e.stopPropagation()} + > +
+ {previewFile.original_name} +
+ + + +
+
+
+ {err + ?

{t('files.openError')}

+ : {text}} +
+
+
, + document.body + ) +} diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts index 87cfcf6c..072fecc8 100644 --- a/server/src/services/fileService.ts +++ b/server/src/services/fileService.ts @@ -11,7 +11,7 @@ import { TripFile } from '../types'; // --------------------------------------------------------------------------- export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB -export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass'; +export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,md,markdown'; // Video support (#823). Gallery/media uploads accept these in addition to images, // independent of the admin doc-types allowlist. Videos are stored as-is and