mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }) => <span data-testid="md">{children}</span>,
|
||||
}));
|
||||
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(<FileManager {...defaultProps} files={files} />);
|
||||
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(<FileManager {...defaultProps} files={files} />);
|
||||
|
||||
@@ -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 && <AssignModal {...S} />}
|
||||
|
||||
{/* PDF preview modal */}
|
||||
{previewFile && <PdfPreviewModal {...S} />}
|
||||
{/* Document preview modal (markdown is rendered inline; everything else PDF/object) */}
|
||||
{previewFile && (isMarkdown(previewFile.mime_type, previewFile.original_name)
|
||||
? <MarkdownPreviewModal {...S} />
|
||||
: <PdfPreviewModal {...S} />)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<FileManagerToolbar {...S} />
|
||||
|
||||
@@ -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(
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxWidth: 820, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 6 }}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md" style={{ flex: 1, overflowY: 'auto', padding: '20px 28px', color: 'var(--text-primary)', lineHeight: 1.6, wordBreak: 'break-word' }}>
|
||||
{err
|
||||
? <p style={{ color: 'var(--text-muted)' }}>{t('files.openError')}</p>
|
||||
: <Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]}>{text}</Markdown>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user