allow upload video on monitor

This commit is contained in:
Yinyin Liu 2026-04-28 15:26:39 +02:00
parent 7c6b86d562
commit b93c051d5f
9 changed files with 157 additions and 19 deletions

View File

@ -2639,16 +2639,18 @@ public class Controller : ControllerBase
private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<String> AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{ {
"image/jpeg", "image/png", "image/gif", "image/webp", "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 // Some browsers send generic MIME types — allow them if the file extension is valid
private static readonly HashSet<String> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<String> 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 private static S3Bucket DocumentBucket
{ {
@ -2660,7 +2662,7 @@ public class Controller : ControllerBase
} }
[HttpPost(nameof(UploadDocument))] [HttpPost(nameof(UploadDocument))]
[RequestSizeLimit(26_214_400)] [RequestSizeLimit(104_857_600)]
public async Task<ActionResult<Document>> UploadDocument( public async Task<ActionResult<Document>> UploadDocument(
IFormFile file, IFormFile file,
[FromQuery] Int32 scope, [FromQuery] Int32 scope,

View File

@ -13,6 +13,8 @@ import DownloadIcon from '@mui/icons-material/Download';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; 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 { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
@ -48,8 +50,13 @@ function isImage(contentType: string): boolean {
return contentType.startsWith('image/'); return contentType.startsWith('image/');
} }
function isVideo(contentType: string): boolean {
return contentType.startsWith('video/');
}
function getFileIcon(contentType: string) { function getFileIcon(contentType: string) {
if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />; if (contentType === 'application/pdf') return <PictureAsPdfIcon fontSize="small" color="error" />;
if (isVideo(contentType)) return <VideocamIcon fontSize="small" color="primary" />;
return <InsertDriveFileIcon fontSize="small" />; return <InsertDriveFileIcon fontSize="small" />;
} }
@ -63,7 +70,36 @@ function DocumentList({
const [documents, setDocuments] = useState<DocumentItem[]>([]); const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [previews, setPreviews] = useState<Record<number, string>>({}); const [previews, setPreviews] = useState<Record<number, string>>({});
const [videoUrls, setVideoUrls] = useState<Record<number, string>>({});
const [loadingVideoIds, setLoadingVideoIds] = useState<Set<number>>(new Set());
const [expandedImage, setExpandedImage] = useState<string | null>(null); const [expandedImage, setExpandedImage] = useState<string | null>(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 = () => { const fetchDocuments = () => {
setLoading(true); setLoading(true);
@ -104,13 +140,15 @@ function DocumentList({
useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url)); Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url));
Object.values(videoUrls).forEach((url) => window.URL.revokeObjectURL(url));
}; };
}, []); }, []);
const handleDownload = (doc: DocumentItem) => { const handleDownload = (doc: DocumentItem) => {
if (previews[doc.id]) { const cached = previews[doc.id] || videoUrls[doc.id];
if (cached) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = previews[doc.id]; link.href = cached;
link.setAttribute('download', doc.originalName); link.setAttribute('download', doc.originalName);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@ -196,6 +234,53 @@ function DocumentList({
}} }}
/> />
)} )}
{isVideo(doc.contentType) && (
videoUrls[doc.id] ? (
<Box
component="video"
controls
src={videoUrls[doc.id]}
onClick={() => 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'
}}
/>
) : (
<Box
onClick={() => 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 }
}}
>
<PlayCircleOutlineIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
{loadingVideoIds.has(doc.id) ? (
<FormattedMessage id="videoLoading" defaultMessage="Loading…" />
) : (
<FormattedMessage id="videoClickToPlay" defaultMessage="Click to play" />
)}
</Typography>
</Box>
)
)}
</ListItem> </ListItem>
))} ))}
</List> </List>
@ -215,6 +300,23 @@ function DocumentList({
/> />
)} )}
</Dialog> </Dialog>
{/* Full-size video preview dialog */}
<Dialog
open={!!expandedVideo}
onClose={() => setExpandedVideo(null)}
maxWidth="lg"
>
{expandedVideo && (
<Box
component="video"
controls
autoPlay
src={expandedVideo.url}
sx={{ maxWidth: '90vw', maxHeight: '90vh', backgroundColor: 'common.black' }}
/>
)}
</Dialog>
</Box> </Box>
); );
} }

View File

@ -7,7 +7,7 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import AttachFileIcon from '@mui/icons-material/AttachFile'; import AttachFileIcon from '@mui/icons-material/AttachFile';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig'; import axiosConfig from 'src/Resources/axiosConfig';
const ALLOWED_TYPES = [ const ALLOWED_TYPES = [
@ -15,9 +15,13 @@ const ALLOWED_TYPES = [
'image/png', 'image/png',
'image/gif', 'image/gif',
'image/webp', '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 { export interface UploadedDocument {
id: number; id: number;
@ -48,6 +52,7 @@ function FileUploadButton({
onUploaded, onUploaded,
disabled = false disabled = false
}: FileUploadButtonProps) { }: FileUploadButtonProps) {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@ -62,11 +67,21 @@ function FileUploadButton({
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
if (!ALLOWED_TYPES.includes(file.type)) { 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; return;
} }
if (file.size > MAX_FILE_SIZE) { 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; return;
} }
validFiles.push(file); validFiles.push(file);

View File

@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) {
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
<FormattedMessage <FormattedMessage
id="documentsHint" id="documentsHint"
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB." defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB."
/> />
</Typography> </Typography>
</CardContent> </CardContent>

View File

@ -117,8 +117,11 @@ function CommentThread({
}, 0); }, 0);
}; };
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const ALLOWED_TYPES = [
const MAX_FILE_SIZE = 25 * 1024 * 1024; '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<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
@ -382,7 +385,7 @@ function CommentThread({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,video/mp4,video/quicktime,video/webm"
multiple multiple
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileSelect} onChange={handleFileSelect}

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro", "sodistorepro": "Sodistore Pro",
"numberOfInverters": "Anzahl der Wechselrichter", "numberOfInverters": "Anzahl der Wechselrichter",
"documentsTab": "Dokumente", "documentsTab": "Dokumente",
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.", "documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximale Dateigrösse: 100 MB.",
"attachFiles": "Dateien anhängen", "attachFiles": "Dateien anhängen",
"attachFileInvalidType": "Ungültiger Dateityp: {name}",
"attachFileTooLarge": "Datei zu gross: {name} (max. {limitMb} MB)",
"videoClickToPlay": "Zum Abspielen klicken",
"videoLoading": "Wird geladen…",
"attachments": "Anhänge", "attachments": "Anhänge",
"documents": "Dokumente", "documents": "Dokumente",
"installationDocuments": "Installationsdokumente", "installationDocuments": "Installationsdokumente",

View File

@ -434,8 +434,12 @@
"sodistorepro": "Sodistore Pro", "sodistorepro": "Sodistore Pro",
"numberOfInverters": "Number of Inverters", "numberOfInverters": "Number of Inverters",
"documentsTab": "Documents", "documentsTab": "Documents",
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.", "documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Maximum file size: 100 MB.",
"attachFiles": "Attach Files", "attachFiles": "Attach Files",
"attachFileInvalidType": "Invalid file type: {name}",
"attachFileTooLarge": "File too large: {name} (max {limitMb} MB)",
"videoClickToPlay": "Click to play",
"videoLoading": "Loading…",
"attachments": "Attachments", "attachments": "Attachments",
"documents": "Documents", "documents": "Documents",
"installationDocuments": "Installation Documents", "installationDocuments": "Installation Documents",

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro", "sodistorepro": "Sodistore Pro",
"numberOfInverters": "Nombre d'onduleurs", "numberOfInverters": "Nombre d'onduleurs",
"documentsTab": "Documents", "documentsTab": "Documents",
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.", "documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Taille maximale : 100 Mo.",
"attachFiles": "Joindre des fichiers", "attachFiles": "Joindre des fichiers",
"attachFileInvalidType": "Type de fichier non valide : {name}",
"attachFileTooLarge": "Fichier trop volumineux : {name} (max. {limitMb} Mo)",
"videoClickToPlay": "Cliquer pour lire",
"videoLoading": "Chargement…",
"attachments": "Pièces jointes", "attachments": "Pièces jointes",
"documents": "Documents", "documents": "Documents",
"installationDocuments": "Documents d'installation", "installationDocuments": "Documents d'installation",

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro", "sodistorepro": "Sodistore Pro",
"numberOfInverters": "Numero di inverter", "numberOfInverters": "Numero di inverter",
"documentsTab": "Documenti", "documentsTab": "Documenti",
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.", "documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF, MP4, MOV, WebM. Dimensione massima: 100 MB.",
"attachFiles": "Allega file", "attachFiles": "Allega file",
"attachFileInvalidType": "Tipo di file non valido: {name}",
"attachFileTooLarge": "File troppo grande: {name} (max {limitMb} MB)",
"videoClickToPlay": "Clicca per riprodurre",
"videoLoading": "Caricamento…",
"attachments": "Allegati", "attachments": "Allegati",
"documents": "Documenti", "documents": "Documenti",
"installationDocuments": "Documenti dell'installazione", "installationDocuments": "Documenti dell'installazione",