feat: version 2.9.0 -- ver documentos .md/.txt directo, sin descargar

Agrega un boton "Ver" en Documentos (adjuntos de proyectos/actividades) para archivos de
texto plano y Markdown: se leen via blob (mismo mecanismo ya usado para descargar, que ya
maneja el Bearer token) y se muestran en un popup -- el .md se renderiza con formato (marked)
y se sanitiza con DOMPurify antes de insertarse en el DOM, para no abrir una via de XSS con
HTML/JS embebido en un documento subido por otro usuario.

De paso corrige que subir un .md fallaba con "tipo de archivo no permitido": el navegador no
manda un mimetype confiable para esa extension (varia segun SO), asi que ahora se normaliza
por extension en el fileFilter de multer en vez de depender solo del mimetype del navegador.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-07 17:17:03 -05:00
parent 27cf2712fb
commit 3d220bcc20
11 changed files with 139 additions and 15 deletions
+37 -3
View File
@@ -1,15 +1,35 @@
import { useRef } from 'react';
import { Upload, Download, Trash2, Paperclip } from 'lucide-react';
import { useRef, useState } from 'react';
import { Upload, Download, Trash2, Paperclip, Eye } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment } from '../services/attachments.js';
import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment, readAttachmentText } from '../services/attachments.js';
import { renderMarkdown } from '../utils/renderMarkdown.js';
import { Button } from './Button.jsx';
import { DetailPopup } from './DetailPopup.jsx';
const VIEWABLE_MIME = new Set(['text/plain', 'text/markdown']);
export function AttachmentsPanel({ entityType, entityId }) {
const { t, openDialog } = useUi();
const runAction = useAction();
const { data: files, reload } = useAsync(() => listAttachments(entityType, entityId), [entityType, entityId]);
const inputRef = useRef(null);
const [viewing, setViewing] = useState(null); // adjunto abierto en el popup de lectura
const [viewContent, setViewContent] = useState('');
const [viewLoading, setViewLoading] = useState(false);
async function openView(file) {
setViewing(file);
setViewContent('');
setViewLoading(true);
try {
setViewContent(await readAttachmentText(file.id));
} catch {
setViewContent(t('document_view_error'));
} finally {
setViewLoading(false);
}
}
async function handleFile(e) {
const file = e.target.files?.[0];
@@ -51,10 +71,24 @@ export function AttachmentsPanel({ entityType, entityId }) {
{f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB · {String(f.created_at).slice(0, 10)}
</div>
</div>
{VIEWABLE_MIME.has(f.mime_type) && (
<button className="icon-btn" title={t('document_view')} onClick={() => openView(f)}><Eye size={16} /></button>
)}
<button className="icon-btn" onClick={() => downloadAttachment(f.id, f.original_name)}><Download size={16} /></button>
<button className="icon-btn" onClick={() => confirmDelete(f)}><Trash2 size={16} /></button>
</div>
))}
<DetailPopup open={!!viewing} title={viewing?.original_name || ''} onClose={() => setViewing(null)} width={720} height={600}>
{viewLoading ? (
<div className="text-muted">{t('common_loading')}</div>
) : viewing?.mime_type === 'text/markdown' ? (
// eslint-disable-next-line react/no-danger -- sanitizado con DOMPurify en renderMarkdown()
<div className="markdown-view" dangerouslySetInnerHTML={{ __html: renderMarkdown(viewContent) }} />
) : (
<pre className="text-view">{viewContent}</pre>
)}
</DetailPopup>
</div>
);
}