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)
{
"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<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
{
@ -2660,7 +2662,7 @@ public class Controller : ControllerBase
}
[HttpPost(nameof(UploadDocument))]
[RequestSizeLimit(26_214_400)]
[RequestSizeLimit(104_857_600)]
public async Task<ActionResult<Document>> UploadDocument(
IFormFile file,
[FromQuery] Int32 scope,

View File

@ -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 <PictureAsPdfIcon fontSize="small" color="error" />;
if (isVideo(contentType)) return <VideocamIcon fontSize="small" color="primary" />;
return <InsertDriveFileIcon fontSize="small" />;
}
@ -63,7 +70,36 @@ function DocumentList({
const [documents, setDocuments] = useState<DocumentItem[]>([]);
const [loading, setLoading] = useState(true);
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 [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] ? (
<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>
))}
</List>
@ -215,6 +300,23 @@ function DocumentList({
/>
)}
</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>
);
}

View File

@ -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<HTMLInputElement>(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);

View File

@ -51,7 +51,7 @@ function DocumentsTab({ installationId }: DocumentsTabProps) {
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
<FormattedMessage
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>
</CardContent>

View File

@ -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<HTMLInputElement>) => {
const files = e.target.files;
@ -382,7 +385,7 @@ function CommentThread({
<input
ref={fileInputRef}
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
style={{ display: 'none' }}
onChange={handleFileSelect}

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Anzahl der Wechselrichter",
"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",
"attachFileInvalidType": "Ungültiger Dateityp: {name}",
"attachFileTooLarge": "Datei zu gross: {name} (max. {limitMb} MB)",
"videoClickToPlay": "Zum Abspielen klicken",
"videoLoading": "Wird geladen…",
"attachments": "Anhänge",
"documents": "Dokumente",
"installationDocuments": "Installationsdokumente",

View File

@ -434,8 +434,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Number of Inverters",
"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",
"attachFileInvalidType": "Invalid file type: {name}",
"attachFileTooLarge": "File too large: {name} (max {limitMb} MB)",
"videoClickToPlay": "Click to play",
"videoLoading": "Loading…",
"attachments": "Attachments",
"documents": "Documents",
"installationDocuments": "Installation Documents",

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Nombre d'onduleurs",
"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",
"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",
"documents": "Documents",
"installationDocuments": "Documents d'installation",

View File

@ -686,8 +686,12 @@
"sodistorepro": "Sodistore Pro",
"numberOfInverters": "Numero di inverter",
"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",
"attachFileInvalidType": "Tipo di file non valido: {name}",
"attachFileTooLarge": "File troppo grande: {name} (max {limitMb} MB)",
"videoClickToPlay": "Clicca per riprodurre",
"videoLoading": "Caricamento…",
"attachments": "Allegati",
"documents": "Documenti",
"installationDocuments": "Documenti dell'installazione",