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