|
|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
/**
|
|
|
|
|
* @file ChatAttachmentItems.jsx
|
|
|
|
|
* @description UI components for rendering staged attachments in the input tray,
|
|
|
|
|
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` tags in messages.
|
|
|
|
|
* and safe direct VFS links for `[ATTACHMENT name="..." path="..." type=...]` and markdown relative links.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React, { PureComponent } from 'react';
|
|
|
|
|
@@ -9,7 +9,6 @@ import Box from '@mui/material/Box';
|
|
|
|
|
import Typography from '@mui/material/Typography';
|
|
|
|
|
import IconButton from '@mui/material/IconButton';
|
|
|
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
|
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
|
|
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
|
|
|
|
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
|
|
|
|
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
|
|
|
|
@@ -18,6 +17,8 @@ import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
|
|
|
|
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
|
|
|
|
import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
|
|
|
|
|
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
|
|
|
|
|
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
|
|
|
|
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
|
|
|
|
import { enqueueSnackbar } from 'notistack';
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
@@ -25,15 +26,89 @@ import {
|
|
|
|
|
formatFileSize,
|
|
|
|
|
} from './chatAttachments';
|
|
|
|
|
|
|
|
|
|
const KNOWN_FILE_EXTENSIONS = new Set([
|
|
|
|
|
'html', 'htm', 'csv', 'tsv', 'pdf', 'txt', 'text', 'md', 'markdown', 'log',
|
|
|
|
|
'json', 'xml', 'yaml', 'yml', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg',
|
|
|
|
|
'avif', 'bmp', 'ico', 'zip', 'tar', 'gz', 'bz2', '7z', 'rar',
|
|
|
|
|
'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'sql', 'sh', 'py', 'wasm',
|
|
|
|
|
'xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'odt', 'ods',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt`) to a full browser URL (`/api/vfs/...`).
|
|
|
|
|
* Invalidate/infer a MIME type from a filename or path.
|
|
|
|
|
* @param {string} filename
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
export function inferMimeType(filename) {
|
|
|
|
|
const ext = (String(filename || '').split('.').pop() || '').toLowerCase();
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case 'html':
|
|
|
|
|
case 'htm':
|
|
|
|
|
return 'text/html';
|
|
|
|
|
case 'pdf':
|
|
|
|
|
return 'application/pdf';
|
|
|
|
|
case 'csv':
|
|
|
|
|
return 'text/csv';
|
|
|
|
|
case 'tsv':
|
|
|
|
|
return 'text/tab-separated-values';
|
|
|
|
|
case 'xml':
|
|
|
|
|
return 'application/xml';
|
|
|
|
|
case 'txt':
|
|
|
|
|
case 'log':
|
|
|
|
|
case 'md':
|
|
|
|
|
return 'text/plain';
|
|
|
|
|
case 'json':
|
|
|
|
|
return 'application/json';
|
|
|
|
|
case 'png':
|
|
|
|
|
return 'image/png';
|
|
|
|
|
case 'jpg':
|
|
|
|
|
case 'jpeg':
|
|
|
|
|
return 'image/jpeg';
|
|
|
|
|
case 'webp':
|
|
|
|
|
return 'image/webp';
|
|
|
|
|
case 'svg':
|
|
|
|
|
return 'image/svg+xml';
|
|
|
|
|
case 'gif':
|
|
|
|
|
return 'image/gif';
|
|
|
|
|
default:
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks whether a given file can be displayed natively inside a browser tab
|
|
|
|
|
* (HTML pages, PDFs, images, text, json, xml, csv, logs, markdown).
|
|
|
|
|
* @param {string} filename
|
|
|
|
|
* @param {string} [mimeType]
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
export function isBrowserViewable(filename, mimeType) {
|
|
|
|
|
const type = String(mimeType || inferMimeType(filename) || '').toLowerCase();
|
|
|
|
|
const ext = (String(filename || '').split('.').pop() || '').toLowerCase();
|
|
|
|
|
if (type.startsWith('image/') || type === 'application/pdf' || type === 'text/html') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (['html', 'htm', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (['txt', 'log', 'json', 'xml', 'md', 'markdown', 'csv', 'tsv', 'js', 'ts', 'jsx', 'tsx', 'css', 'sql', 'sh', 'py'].includes(ext) || type.startsWith('text/')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a relative VFS path (e.g. `attachments/Rechnung 89589.txt` or `dummy-report.html`)
|
|
|
|
|
* to a full browser URL (`/api/vfs/...`).
|
|
|
|
|
* @param {string} rawPath
|
|
|
|
|
* @returns {string}
|
|
|
|
|
*/
|
|
|
|
|
export function resolveVfsUrl(rawPath) {
|
|
|
|
|
const p = String(rawPath ?? '').trim();
|
|
|
|
|
if (!p) return '#';
|
|
|
|
|
if (/^https?:\/\//i.test(p) || p.startsWith('/')) return p;
|
|
|
|
|
if (/^https?:\/\//i.test(p) || p.startsWith('/api/vfs/')) return p;
|
|
|
|
|
if (p.startsWith('/vfs/')) return `/api${p}`;
|
|
|
|
|
if (p.startsWith('/')) return p;
|
|
|
|
|
const segments = p
|
|
|
|
|
.replace(/^\.\//, '')
|
|
|
|
|
.split('/')
|
|
|
|
|
@@ -42,6 +117,67 @@ export function resolveVfsUrl(rawPath) {
|
|
|
|
|
return `/api/vfs/${segments.join('/')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Classifies a markdown link target according to the rule:
|
|
|
|
|
* - Internet links are "https://..." or simply "hostname.com" / "hostname.com/filename".
|
|
|
|
|
* - A pure relative path with no dotted hostname is always a VFS relative path (e.g. `[download](report.csv)` or ``).
|
|
|
|
|
* @param {string} rawUrl
|
|
|
|
|
* @returns {{ kind: 'web'|'vfs'|'nav'|'none', url?: string, path?: string }}
|
|
|
|
|
*/
|
|
|
|
|
export function classifyLinkTarget(rawUrl) {
|
|
|
|
|
const url = String(rawUrl ?? '').trim();
|
|
|
|
|
if (!url) return { kind: 'none' };
|
|
|
|
|
|
|
|
|
|
if (/^https?:\/\//i.test(url)) {
|
|
|
|
|
return { kind: 'web', url };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (/^(mailto:|#)/i.test(url)) {
|
|
|
|
|
return { kind: 'nav', url };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.startsWith('/')) {
|
|
|
|
|
if (url.startsWith('/api/vfs/') || url.startsWith('/vfs/')) {
|
|
|
|
|
const vfsPath = url.replace(/^\/api\/vfs\//, '').replace(/^\/vfs\//, '');
|
|
|
|
|
return { kind: 'vfs', path: vfsPath };
|
|
|
|
|
}
|
|
|
|
|
return { kind: 'nav', url };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Strip leading ./
|
|
|
|
|
const clean = url.replace(/^\.\//, '');
|
|
|
|
|
const firstSlash = clean.indexOf('/');
|
|
|
|
|
const firstSegment = firstSlash === -1 ? clean : clean.slice(0, firstSlash);
|
|
|
|
|
|
|
|
|
|
// If starts with www.
|
|
|
|
|
if (/^www\./i.test(firstSegment)) {
|
|
|
|
|
return { kind: 'web', url: `https://${clean}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If there's a slash and the first segment contains a dot:
|
|
|
|
|
// e.g. "hostname.com/filename" or "sub.domain.org/path"
|
|
|
|
|
if (firstSlash !== -1 && firstSegment.includes('.')) {
|
|
|
|
|
const ext = firstSegment.split('.').pop().toLowerCase();
|
|
|
|
|
if (!KNOWN_FILE_EXTENSIONS.has(ext)) {
|
|
|
|
|
return { kind: 'web', url: `https://${clean}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If there's no slash (e.g. "report.csv" or "hostname.com"):
|
|
|
|
|
if (firstSlash === -1 && firstSegment.includes('.')) {
|
|
|
|
|
const ext = firstSegment.split('.').pop().toLowerCase();
|
|
|
|
|
if (KNOWN_FILE_EXTENSIONS.has(ext)) {
|
|
|
|
|
// It's a file! e.g. report.csv, dummy-report.html -> VFS relative path
|
|
|
|
|
return { kind: 'vfs', path: clean };
|
|
|
|
|
}
|
|
|
|
|
// Dotted hostname without path, e.g. "example.com"
|
|
|
|
|
return { kind: 'web', url: `https://${clean}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pure relative path with no dotted hostname (e.g. "images/photo.png", "report.csv", "attachments/file.txt"):
|
|
|
|
|
return { kind: 'vfs', path: clean };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse attributes from an attachment tag:
|
|
|
|
|
* `name="Rechnung 89589.txt" path="attachments/Rechnung 89589.txt" type=text/plain`
|
|
|
|
|
@@ -115,26 +251,63 @@ function getAttachmentIcon(type, name, isUser) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]`.
|
|
|
|
|
* Direct link to VFS rendered for `[ATTACHMENT name="..." path="..." type=...]` and markdown relative links.
|
|
|
|
|
* Safely verifies HTTP response before triggering download, preventing saving 404 JSON error responses.
|
|
|
|
|
*/
|
|
|
|
|
export class VfsAttachmentLink extends PureComponent {
|
|
|
|
|
state = {
|
|
|
|
|
isDownloading: false,
|
|
|
|
|
isOpening: false,
|
|
|
|
|
notFound: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleClick = async (e) => {
|
|
|
|
|
// If modifier keys are pressed (e.g. open in new tab with Ctrl/Cmd), let browser handle
|
|
|
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
handleOpenInNewTab = async (e) => {
|
|
|
|
|
e?.preventDefault?.();
|
|
|
|
|
e?.stopPropagation?.();
|
|
|
|
|
const { name, path } = this.props;
|
|
|
|
|
const { isDownloading, notFound } = this.state;
|
|
|
|
|
if (isDownloading) return;
|
|
|
|
|
const downloadName = name || (path ? path.split('/').pop() : 'datei');
|
|
|
|
|
const { isOpening, isDownloading, notFound } = this.state;
|
|
|
|
|
if (isOpening || isDownloading) return;
|
|
|
|
|
|
|
|
|
|
if (notFound) {
|
|
|
|
|
enqueueSnackbar(`Datei "${name}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
enqueueSnackbar(`Datei "${downloadName}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const vfsUrl = resolveVfsUrl(path);
|
|
|
|
|
this.setState({ isOpening: true });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(vfsUrl, { credentials: 'include' });
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
if (res.status === 404) {
|
|
|
|
|
this.setState({ notFound: true, isOpening: false });
|
|
|
|
|
enqueueSnackbar(`Datei "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
} else {
|
|
|
|
|
this.setState({ isOpening: false });
|
|
|
|
|
enqueueSnackbar(`Fehler beim Öffnen von "${downloadName}" (${res.status}).`, { variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setState({ isOpening: false });
|
|
|
|
|
window.open(vfsUrl, '_blank', 'noopener,noreferrer');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.setState({ isOpening: false });
|
|
|
|
|
enqueueSnackbar(`Netzwerkfehler beim Öffnen: ${err?.message || err}`, { variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleDownload = async (e) => {
|
|
|
|
|
e?.preventDefault?.();
|
|
|
|
|
e?.stopPropagation?.();
|
|
|
|
|
const { name, path } = this.props;
|
|
|
|
|
const downloadName = name || (path ? path.split('/').pop() : 'datei');
|
|
|
|
|
const { isDownloading, isOpening, notFound } = this.state;
|
|
|
|
|
if (isDownloading || isOpening) return;
|
|
|
|
|
|
|
|
|
|
if (notFound) {
|
|
|
|
|
enqueueSnackbar(`Datei "${downloadName}" ist nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -146,10 +319,10 @@ export class VfsAttachmentLink extends PureComponent {
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
if (res.status === 404) {
|
|
|
|
|
this.setState({ notFound: true, isDownloading: false });
|
|
|
|
|
enqueueSnackbar(`Datei "${name}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
enqueueSnackbar(`Datei "${downloadName}" nicht mehr im VFS vorhanden (404).`, { variant: 'warning' });
|
|
|
|
|
} else {
|
|
|
|
|
this.setState({ isDownloading: false });
|
|
|
|
|
enqueueSnackbar(`Fehler beim Herunterladen von "${name}" (${res.status}).`, { variant: 'error' });
|
|
|
|
|
enqueueSnackbar(`Fehler beim Herunterladen von "${downloadName}" (${res.status}).`, { variant: 'error' });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@@ -158,7 +331,7 @@ export class VfsAttachmentLink extends PureComponent {
|
|
|
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
|
const tempLink = document.createElement('a');
|
|
|
|
|
tempLink.href = objectUrl;
|
|
|
|
|
tempLink.download = name || 'datei';
|
|
|
|
|
tempLink.download = downloadName;
|
|
|
|
|
document.body.appendChild(tempLink);
|
|
|
|
|
tempLink.click();
|
|
|
|
|
document.body.removeChild(tempLink);
|
|
|
|
|
@@ -170,23 +343,40 @@ export class VfsAttachmentLink extends PureComponent {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleClick = (e) => {
|
|
|
|
|
// If modifier keys are pressed (e.g. Ctrl/Cmd/Shift) or middle mouse click, let browser handle natively
|
|
|
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const { name, path, type } = this.props;
|
|
|
|
|
const downloadName = name || (path ? path.split('/').pop() : 'datei');
|
|
|
|
|
const effectiveType = type || inferMimeType(downloadName);
|
|
|
|
|
const viewable = isBrowserViewable(downloadName, effectiveType);
|
|
|
|
|
|
|
|
|
|
if (viewable) {
|
|
|
|
|
this.handleOpenInNewTab(e);
|
|
|
|
|
} else {
|
|
|
|
|
this.handleDownload(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleImageError = () => {
|
|
|
|
|
this.setState({ notFound: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
const { name, path, type, isUser = false } = this.props;
|
|
|
|
|
const { isDownloading, notFound } = this.state;
|
|
|
|
|
const { name, path, type, label, children, isUser = false } = this.props;
|
|
|
|
|
const { isDownloading, isOpening, notFound } = this.state;
|
|
|
|
|
const downloadName = name || (path ? path.split('/').pop() : 'datei');
|
|
|
|
|
const displayName = label || children || name || downloadName;
|
|
|
|
|
const effectiveType = type || inferMimeType(downloadName);
|
|
|
|
|
const vfsUrl = resolveVfsUrl(path);
|
|
|
|
|
const isImage = !notFound && ((type && type.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(name || path));
|
|
|
|
|
const canView = isBrowserViewable(downloadName, effectiveType);
|
|
|
|
|
const isImage = !notFound && ((effectiveType && effectiveType.startsWith('image/')) || /\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/i.test(downloadName || path));
|
|
|
|
|
|
|
|
|
|
if (isImage) {
|
|
|
|
|
if (isImage && !label && !children) {
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
component="a"
|
|
|
|
|
href={vfsUrl}
|
|
|
|
|
onClick={this.handleClick}
|
|
|
|
|
title={isDownloading ? 'Wird heruntergeladen…' : `VFS Bild öffnen / herunterladen: ${name}`}
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
my: 0.6,
|
|
|
|
|
@@ -195,147 +385,201 @@ export class VfsAttachmentLink extends PureComponent {
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
border: '1px solid',
|
|
|
|
|
borderColor: isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider',
|
|
|
|
|
textDecoration: 'none',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
opacity: isDownloading ? 0.75 : 1,
|
|
|
|
|
transition: 'transform 0.15s, box-shadow 0.15s, opacity 0.15s',
|
|
|
|
|
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
|
|
|
|
|
transition: 'transform 0.15s, box-shadow 0.15s',
|
|
|
|
|
'&:hover': {
|
|
|
|
|
transform: 'scale(1.02)',
|
|
|
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
|
|
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.18)',
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Box
|
|
|
|
|
component="img"
|
|
|
|
|
src={vfsUrl}
|
|
|
|
|
alt={name}
|
|
|
|
|
onError={this.handleImageError}
|
|
|
|
|
component="a"
|
|
|
|
|
href={notFound ? undefined : vfsUrl}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
onClick={this.handleClick}
|
|
|
|
|
sx={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
maxHeight: 200,
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
display: 'block',
|
|
|
|
|
bgcolor: 'action.hover',
|
|
|
|
|
cursor: notFound ? 'not-allowed' : 'pointer',
|
|
|
|
|
textDecoration: 'none',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
>
|
|
|
|
|
<Box
|
|
|
|
|
component="img"
|
|
|
|
|
src={vfsUrl}
|
|
|
|
|
alt={downloadName}
|
|
|
|
|
onError={this.handleImageError}
|
|
|
|
|
sx={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
maxHeight: 200,
|
|
|
|
|
objectFit: 'cover',
|
|
|
|
|
display: 'block',
|
|
|
|
|
bgcolor: 'action.hover',
|
|
|
|
|
transition: 'transform 0.18s ease-in-out',
|
|
|
|
|
'&:hover': notFound ? {} : {
|
|
|
|
|
transform: 'scale(1.025)',
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
<Box
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
gap: 0.75,
|
|
|
|
|
gap: 0.5,
|
|
|
|
|
px: 1,
|
|
|
|
|
py: 0.5,
|
|
|
|
|
bgcolor: isUser ? 'rgba(0,0,0,0.3)' : (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0,0,0,0.03)'),
|
|
|
|
|
py: 0.4,
|
|
|
|
|
color: isUser ? '#fff' : 'text.primary',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
|
|
|
|
<InsertPhotoRoundedIcon sx={{ fontSize: 16 }} />
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0, flex: 1 }}>
|
|
|
|
|
<Typography
|
|
|
|
|
variant="caption"
|
|
|
|
|
sx={{
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
fontSize: '0.72rem',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{name}
|
|
|
|
|
{downloadName} - {effectiveType || 'image'}
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
{isDownloading && <CircularProgress size={12} color="inherit" thickness={5} />}
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.2, flexShrink: 0 }}>
|
|
|
|
|
{(isDownloading || isOpening) && (
|
|
|
|
|
<CircularProgress size={14} color="inherit" thickness={5} sx={{ mr: 0.5 }} />
|
|
|
|
|
)}
|
|
|
|
|
{!notFound && (
|
|
|
|
|
<>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="In neuem Tab öffnen"
|
|
|
|
|
onClick={this.handleOpenInNewTab}
|
|
|
|
|
sx={{ p: 0.35, color: 'inherit', opacity: 0.85, '&:hover': { opacity: 1 } }}
|
|
|
|
|
>
|
|
|
|
|
<OpenInNewRoundedIcon sx={{ fontSize: 15 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Herunterladen"
|
|
|
|
|
onClick={this.handleDownload}
|
|
|
|
|
sx={{ p: 0.35, color: 'inherit', opacity: 0.85, '&:hover': { opacity: 1 } }}
|
|
|
|
|
>
|
|
|
|
|
<DownloadRoundedIcon sx={{ fontSize: 15 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const titleText = notFound
|
|
|
|
|
? `Datei nicht mehr im VFS vorhanden (404): ${name}`
|
|
|
|
|
: isDownloading
|
|
|
|
|
? `Wird heruntergeladen…`
|
|
|
|
|
: `VFS Datei herunterladen / öffnen: ${name}`;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip title={titleText} arrow>
|
|
|
|
|
<Box
|
|
|
|
|
component="a"
|
|
|
|
|
href={notFound ? undefined : vfsUrl}
|
|
|
|
|
onClick={this.handleClick}
|
|
|
|
|
<Box
|
|
|
|
|
component="a"
|
|
|
|
|
href={notFound ? undefined : vfsUrl}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
onClick={this.handleClick}
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 0.85,
|
|
|
|
|
my: 0.35,
|
|
|
|
|
mx: 0.2,
|
|
|
|
|
pl: 1.15,
|
|
|
|
|
pr: 0.85,
|
|
|
|
|
py: 0.5,
|
|
|
|
|
borderRadius: 2,
|
|
|
|
|
border: '1px solid',
|
|
|
|
|
borderColor: notFound
|
|
|
|
|
? (isUser ? 'rgba(255,255,255,0.2)' : 'error.light')
|
|
|
|
|
: (isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider'),
|
|
|
|
|
bgcolor: notFound
|
|
|
|
|
? (isUser ? 'rgba(0,0,0,0.2)' : 'action.disabledBackground')
|
|
|
|
|
: (isUser
|
|
|
|
|
? 'rgba(255, 255, 255, 0.18)'
|
|
|
|
|
: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)')),
|
|
|
|
|
textDecoration: notFound ? 'line-through' : 'none',
|
|
|
|
|
color: notFound
|
|
|
|
|
? (isUser ? 'rgba(255,255,255,0.6)' : 'text.disabled')
|
|
|
|
|
: (isUser ? 'inherit' : 'primary.main'),
|
|
|
|
|
opacity: notFound ? 0.7 : ((isDownloading || isOpening) ? 0.75 : 1),
|
|
|
|
|
cursor: notFound ? 'not-allowed' : 'pointer',
|
|
|
|
|
verticalAlign: 'middle',
|
|
|
|
|
maxWidth: '100%',
|
|
|
|
|
transition: 'all 150ms ease-in-out',
|
|
|
|
|
'&:hover': notFound ? {} : {
|
|
|
|
|
bgcolor: isUser ? 'rgba(255, 255, 255, 0.28)' : 'action.hover',
|
|
|
|
|
borderColor: isUser ? '#fff' : 'primary.main',
|
|
|
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Typography
|
|
|
|
|
component="span"
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 1,
|
|
|
|
|
my: 0.35,
|
|
|
|
|
mx: 0.2,
|
|
|
|
|
px: 1.25,
|
|
|
|
|
py: 0.65,
|
|
|
|
|
borderRadius: 2,
|
|
|
|
|
border: '1px solid',
|
|
|
|
|
borderColor: notFound
|
|
|
|
|
? (isUser ? 'rgba(255,255,255,0.2)' : 'error.light')
|
|
|
|
|
: (isUser ? 'rgba(255, 255, 255, 0.35)' : 'divider'),
|
|
|
|
|
bgcolor: notFound
|
|
|
|
|
? (isUser ? 'rgba(0,0,0,0.2)' : 'action.disabledBackground')
|
|
|
|
|
: (isUser
|
|
|
|
|
? 'rgba(255, 255, 255, 0.18)'
|
|
|
|
|
: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : 'rgba(0, 0, 0, 0.02)')),
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
color: 'inherit',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
lineHeight: 1.25,
|
|
|
|
|
textDecoration: notFound ? 'line-through' : 'none',
|
|
|
|
|
color: notFound
|
|
|
|
|
? (isUser ? 'rgba(255,255,255,0.6)' : 'text.disabled')
|
|
|
|
|
: (isUser ? 'inherit' : 'primary.main'),
|
|
|
|
|
opacity: notFound ? 0.7 : (isDownloading ? 0.75 : 1),
|
|
|
|
|
cursor: notFound ? 'not-allowed' : 'pointer',
|
|
|
|
|
verticalAlign: 'middle',
|
|
|
|
|
maxWidth: '100%',
|
|
|
|
|
transition: 'all 150ms ease-in-out',
|
|
|
|
|
'&:hover': notFound ? {} : {
|
|
|
|
|
bgcolor: isUser ? 'rgba(255, 255, 255, 0.28)' : 'action.hover',
|
|
|
|
|
borderColor: isUser ? '#fff' : 'primary.main',
|
|
|
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isDownloading ? (
|
|
|
|
|
<CircularProgress size={16} color="inherit" thickness={5} />
|
|
|
|
|
) : notFound ? (
|
|
|
|
|
<ErrorOutlineRoundedIcon sx={{ fontSize: 20, color: isUser ? 'inherit' : 'error.main' }} />
|
|
|
|
|
) : (
|
|
|
|
|
getAttachmentIcon(type, name, isUser)
|
|
|
|
|
)}
|
|
|
|
|
<Box sx={{ minWidth: 0 }}>
|
|
|
|
|
<Typography
|
|
|
|
|
component="span"
|
|
|
|
|
{downloadName} - {effectiveType || 'VFS'}{notFound ? ' (404)' : ''}
|
|
|
|
|
</Typography>
|
|
|
|
|
{!notFound && (
|
|
|
|
|
<Box
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: 0.2,
|
|
|
|
|
ml: 0.5,
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{(isDownloading || isOpening) && (
|
|
|
|
|
<CircularProgress size={13} color="inherit" thickness={4} sx={{ mx: 0.4 }} />
|
|
|
|
|
)}
|
|
|
|
|
{canView && (
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="In neuem Tab öffnen"
|
|
|
|
|
onClick={this.handleOpenInNewTab}
|
|
|
|
|
sx={{
|
|
|
|
|
p: 0.4,
|
|
|
|
|
color: 'inherit',
|
|
|
|
|
opacity: 0.75,
|
|
|
|
|
'&:hover': { opacity: 1, bgcolor: 'action.hover' },
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<OpenInNewRoundedIcon sx={{ fontSize: 15 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Herunterladen"
|
|
|
|
|
onClick={this.handleDownload}
|
|
|
|
|
sx={{
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
p: 0.4,
|
|
|
|
|
color: 'inherit',
|
|
|
|
|
display: 'block',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
maxWidth: 260,
|
|
|
|
|
lineHeight: 1.25,
|
|
|
|
|
textDecoration: notFound ? 'line-through' : 'none',
|
|
|
|
|
opacity: 0.75,
|
|
|
|
|
'&:hover': { opacity: 1, bgcolor: 'action.hover' },
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{name}
|
|
|
|
|
</Typography>
|
|
|
|
|
<Typography
|
|
|
|
|
component="span"
|
|
|
|
|
sx={{
|
|
|
|
|
fontSize: '0.68rem',
|
|
|
|
|
opacity: 0.8,
|
|
|
|
|
display: 'block',
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{notFound ? 'nicht mehr vorhanden (404)' : (type || 'VFS Datei')}
|
|
|
|
|
</Typography>
|
|
|
|
|
<DownloadRoundedIcon sx={{ fontSize: 15 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|