allow upload video on monitor
This commit is contained in:
parent
7c6b86d562
commit
b93c051d5f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue