diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index a80045590..832f84dda 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2639,16 +2639,18 @@ public class Controller : ControllerBase private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/gif", "image/webp", - "application/pdf", "application/x-pdf" + "application/pdf", "application/x-pdf", + "video/mp4", "video/quicktime", "video/webm" }; // Some browsers send generic MIME types — allow them if the file extension is valid private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", + ".mp4", ".mov", ".webm" }; - private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB + private const Int64 MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB private static S3Bucket DocumentBucket { @@ -2660,7 +2662,7 @@ public class Controller : ControllerBase } [HttpPost(nameof(UploadDocument))] - [RequestSizeLimit(26_214_400)] + [RequestSizeLimit(104_857_600)] public async Task> UploadDocument( IFormFile file, [FromQuery] Int32 scope, diff --git a/typescript/frontend-marios2/src/components/DocumentList.tsx b/typescript/frontend-marios2/src/components/DocumentList.tsx index 41f3ad042..9c92b589e 100644 --- a/typescript/frontend-marios2/src/components/DocumentList.tsx +++ b/typescript/frontend-marios2/src/components/DocumentList.tsx @@ -13,6 +13,8 @@ import DownloadIcon from '@mui/icons-material/Download'; import DeleteIcon from '@mui/icons-material/Delete'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; import { FormattedMessage } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; @@ -48,8 +50,13 @@ function isImage(contentType: string): boolean { return contentType.startsWith('image/'); } +function isVideo(contentType: string): boolean { + return contentType.startsWith('video/'); +} + function getFileIcon(contentType: string) { if (contentType === 'application/pdf') return ; + if (isVideo(contentType)) return ; return ; } @@ -63,7 +70,36 @@ function DocumentList({ const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [previews, setPreviews] = useState>({}); + const [videoUrls, setVideoUrls] = useState>({}); + const [loadingVideoIds, setLoadingVideoIds] = useState>(new Set()); const [expandedImage, setExpandedImage] = useState(null); + const [expandedVideo, setExpandedVideo] = useState<{ url: string; contentType: string } | null>(null); + + const loadVideoBlob = (doc: DocumentItem) => { + if (videoUrls[doc.id] || loadingVideoIds.has(doc.id)) return; + setLoadingVideoIds((prev) => { + const next = new Set(prev); + next.add(doc.id); + return next; + }); + axiosConfig + .get('/DownloadDocument', { + params: { id: doc.id }, + responseType: 'blob' + }) + .then((res) => { + const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType })); + setVideoUrls((prev) => ({ ...prev, [doc.id]: url })); + }) + .catch(() => {}) + .finally(() => { + setLoadingVideoIds((prev) => { + const next = new Set(prev); + next.delete(doc.id); + return next; + }); + }); + }; const fetchDocuments = () => { setLoading(true); @@ -104,13 +140,15 @@ function DocumentList({ useEffect(() => { return () => { Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url)); + Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url)); }; }, []); const handleDownload = (doc: DocumentItem) => { - if (previews[doc.id]) { + const cached = previews[doc.id] || videoUrls[doc.id]; + if (cached) { const link = document.createElement('a'); - link.href = previews[doc.id]; + link.href = cached; link.setAttribute('download', doc.originalName); document.body.appendChild(link); link.click(); @@ -196,6 +234,53 @@ function DocumentList({ }} /> )} + {isVideo(doc.contentType) && ( + videoUrls[doc.id] ? ( + setExpandedVideo({ url: videoUrls[doc.id], contentType: doc.contentType })} + sx={{ + maxWidth: 240, + maxHeight: 160, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + cursor: 'pointer', + backgroundColor: 'common.black' + }} + /> + ) : ( + loadVideoBlob(doc)} + sx={{ + width: 240, + height: 135, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'action.hover', + cursor: loadingVideoIds.has(doc.id) ? 'progress' : 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 0.5, + '&:hover': { opacity: 0.85 } + }} + > + + + {loadingVideoIds.has(doc.id) ? ( + + ) : ( + + )} + + + ) + )} ))} @@ -215,6 +300,23 @@ function DocumentList({ /> )} + + {/* Full-size video preview dialog */} + setExpandedVideo(null)} + maxWidth="lg" + > + {expandedVideo && ( + + )} + ); } diff --git a/typescript/frontend-marios2/src/components/FileUploadButton.tsx b/typescript/frontend-marios2/src/components/FileUploadButton.tsx index 992531db4..f78ea2c7c 100644 --- a/typescript/frontend-marios2/src/components/FileUploadButton.tsx +++ b/typescript/frontend-marios2/src/components/FileUploadButton.tsx @@ -7,7 +7,7 @@ import { Typography } from '@mui/material'; import AttachFileIcon from '@mui/icons-material/AttachFile'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; const ALLOWED_TYPES = [ @@ -15,9 +15,13 @@ const ALLOWED_TYPES = [ 'image/png', 'image/gif', 'image/webp', - 'application/pdf' + 'application/pdf', + 'video/mp4', + 'video/quicktime', + 'video/webm' ]; -const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB +const MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024); export interface UploadedDocument { id: number; @@ -48,6 +52,7 @@ function FileUploadButton({ onUploaded, disabled = false }: FileUploadButtonProps) { + const intl = useIntl(); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); @@ -62,11 +67,21 @@ function FileUploadButton({ for (let i = 0; i < files.length; i++) { const file = files[i]; if (!ALLOWED_TYPES.includes(file.type)) { - setError(`Invalid file type: ${file.name}`); + setError( + intl.formatMessage( + { id: 'attachFileInvalidType', defaultMessage: 'Invalid file type: {name}' }, + { name: file.name } + ) + ); return; } if (file.size > MAX_FILE_SIZE) { - setError(`File too large: ${file.name} (max 25 MB)`); + setError( + intl.formatMessage( + { id: 'attachFileTooLarge', defaultMessage: 'File too large: {name} (max {limitMb} MB)' }, + { name: file.name, limitMb: MAX_FILE_SIZE_MB } + ) + ); return; } validFiles.push(file); diff --git a/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx index 7d9cc6407..79dc807f6 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx @@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) { diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 8802dc883..71dfaef80 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -117,8 +117,11 @@ function CommentThread({ }, 0); }; - const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; - const MAX_FILE_SIZE = 25 * 1024 * 1024; + const ALLOWED_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', + 'video/mp4', 'video/quicktime', 'video/webm' + ]; + const MAX_FILE_SIZE = 100 * 1024 * 1024; const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; @@ -382,7 +385,7 @@ function CommentThread({