feat: Implement file upload progress tracking and enhance share link functionality
This commit is contained in:
parent
2cf335c5ef
commit
18f1cd150b
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Litecloud</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
@ -26,9 +26,11 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
const [shareDuration, setShareDuration] = useState(7); // durata di default 7 giorni
|
const [shareDuration, setShareDuration] = useState(7); // durata di default 7 giorni
|
||||||
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
|
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
|
||||||
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null);
|
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
|
const [uploadFileName, setUploadFileName] = useState<string | null>(null);
|
||||||
|
const [uploadStartTime, setUploadStartTime] = useState<number | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLTableDataCellElement | null>(null);
|
|
||||||
|
|
||||||
// Recupera il token solo una volta al mount o quando cambia
|
// Recupera il token solo una volta al mount o quando cambia
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -55,13 +57,21 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
if (token) fetchFiles();
|
if (token) fetchFiles();
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
// Fix gestione click fuori dal dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
// Se il dropdown è aperto e il click NON è dentro un menu dropdown, chiudi
|
||||||
|
const dropdowns = document.querySelectorAll('.custom-dropdown');
|
||||||
|
let clickedInside = false;
|
||||||
|
dropdowns.forEach(dropdown => {
|
||||||
|
if (dropdown.contains(event.target as Node)) {
|
||||||
|
clickedInside = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!clickedInside) {
|
||||||
setOpenDropdown(null);
|
setOpenDropdown(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -73,32 +83,51 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadFileName(file.name);
|
||||||
|
setUploadStartTime(Date.now());
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('filename', file.name);
|
formData.append('filename', file.name);
|
||||||
|
|
||||||
if (!token) throw new Error('No auth token found');
|
if (!token) throw new Error('No auth token found');
|
||||||
const response = await fetch('/api/upload', {
|
const xhr = new XMLHttpRequest();
|
||||||
method: 'POST',
|
xhr.open('POST', '/api/upload');
|
||||||
headers: {
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
'Authorization': `Bearer ${token}`,
|
xhr.upload.onprogress = (e) => {
|
||||||
},
|
if (e.lengthComputable) {
|
||||||
body: formData,
|
setUploadProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
});
|
}
|
||||||
|
};
|
||||||
if (response.ok) {
|
xhr.onload = async () => {
|
||||||
setSuccess('File uploaded successfully!');
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
fetchFiles();
|
setSuccess('File uploaded successfully!');
|
||||||
} else {
|
fetchFiles();
|
||||||
throw new Error('Upload failed');
|
} else {
|
||||||
}
|
setError('Upload failed');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setUploadProgress(null);
|
||||||
|
setUploadFileName(null);
|
||||||
|
setUploadStartTime(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
setError('Failed to upload file');
|
||||||
|
setLoading(false);
|
||||||
|
setUploadProgress(null);
|
||||||
|
setUploadFileName(null);
|
||||||
|
setUploadStartTime(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
xhr.send(formData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to upload file - ' + (err instanceof Error ? err.message : 'Unknown error'));
|
setError('Failed to upload file - ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (fileInputRef.current) {
|
setUploadProgress(null);
|
||||||
fileInputRef.current.value = '';
|
setUploadFileName(null);
|
||||||
}
|
setUploadStartTime(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -227,6 +256,18 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
|
|
||||||
// Funzione per ottenere la URL del file (puoi adattare se serve fetch protetto)
|
// Funzione per ottenere la URL del file (puoi adattare se serve fetch protetto)
|
||||||
|
|
||||||
|
// Calcola il tempo rimanente stimato
|
||||||
|
const getEstimatedTimeLeft = () => {
|
||||||
|
if (uploadProgress === null || uploadProgress === 0 || !uploadStartTime) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = (now - uploadStartTime) / 1000; // in secondi
|
||||||
|
const estimatedTotal = elapsed / (uploadProgress / 100);
|
||||||
|
const remaining = estimatedTotal - elapsed;
|
||||||
|
if (!isFinite(remaining) || remaining < 0) return null;
|
||||||
|
if (remaining < 60) return `${Math.round(remaining)}s`;
|
||||||
|
return `${Math.floor(remaining / 60)}m ${Math.round(remaining % 60)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="component-container">
|
<div className="component-container">
|
||||||
{/* Success/Error Messages */}
|
{/* Success/Error Messages */}
|
||||||
|
@ -306,7 +347,7 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
</td>
|
</td>
|
||||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatFileSize(file.size)}</td>
|
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatFileSize(file.size)}</td>
|
||||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(file.uploaded_at)}</td>
|
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(file.uploaded_at)}</td>
|
||||||
<td className="table-cell relative actions-cell" ref={dropdownRef}>
|
<td className="table-cell relative actions-cell">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenDropdown(openDropdown === file.id ? null : file.id)}
|
onClick={() => setOpenDropdown(openDropdown === file.id ? null : file.id)}
|
||||||
className={`actions-button ellipsis-button ${isDarkMode ? 'actions-button-dark' : 'actions-button-light'}`}
|
className={`actions-button ellipsis-button ${isDarkMode ? 'actions-button-dark' : 'actions-button-light'}`}
|
||||||
|
@ -366,6 +407,32 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modal/progressbar upload */}
|
||||||
|
{uploadProgress !== null && uploadFileName && (
|
||||||
|
<div className="upload-progress-popout">
|
||||||
|
<div className="upload-progress-content">
|
||||||
|
<div className="upload-progress-filename">{uploadFileName}</div>
|
||||||
|
<div className="upload-progress-bar-container">
|
||||||
|
<svg className="upload-progress-svg" viewBox="0 0 40 40">
|
||||||
|
<circle className="upload-progress-bg" cx="20" cy="20" r="18" />
|
||||||
|
<circle
|
||||||
|
className="upload-progress-bar"
|
||||||
|
cx="20"
|
||||||
|
cy="20"
|
||||||
|
r="18"
|
||||||
|
strokeDasharray={2 * Math.PI * 18}
|
||||||
|
strokeDashoffset={2 * Math.PI * 18 * (1 - uploadProgress / 100)}
|
||||||
|
/>
|
||||||
|
<text x="50%" y="54%" textAnchor="middle" className="upload-progress-text">{uploadProgress}%</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="upload-progress-eta">
|
||||||
|
{getEstimatedTimeLeft() ? `~${getEstimatedTimeLeft()} left` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -84,7 +84,7 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
|
||||||
|
|
||||||
const copyToClipboard = async (shareId: string) => {
|
const copyToClipboard = async (shareId: string) => {
|
||||||
try {
|
try {
|
||||||
const shareUrl = `${window.location.origin}/shared/${shareId}`;
|
const shareUrl = `${window.location.origin}/api/shared/${shareId}`;
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setSuccess('Share link copied to clipboard!');
|
setSuccess('Share link copied to clipboard!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -364,10 +364,12 @@ body, html, #root {
|
||||||
|
|
||||||
/* Sidebar Title */
|
/* Sidebar Title */
|
||||||
.sidebar-title {
|
.sidebar-title {
|
||||||
font-size: 1.5rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 2rem 0;
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
transition: color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar Navigation */
|
/* Sidebar Navigation */
|
||||||
|
@ -929,4 +931,89 @@ body, html, #root {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload progress popout styles */
|
||||||
|
.upload-progress-popout {
|
||||||
|
position: fixed;
|
||||||
|
right: 32px;
|
||||||
|
bottom: 32px;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(34, 34, 34, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
|
||||||
|
padding: 18px 28px 18px 18px;
|
||||||
|
min-width: 220px;
|
||||||
|
min-height: 110px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
animation: popout-fadein 0.3s;
|
||||||
|
}
|
||||||
|
.upload-progress-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.upload-progress-filename {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.upload-progress-bar-container {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.upload-progress-svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
.upload-progress-bg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #444;
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
.upload-progress-bar {
|
||||||
|
fill: none;
|
||||||
|
stroke: #4ade80;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.3s;
|
||||||
|
}
|
||||||
|
.upload-progress-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: #fff;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
}
|
||||||
|
.upload-progress-eta {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #b3e6c7;
|
||||||
|
margin-top: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.upload-progress-popout {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 10px 12px 10px 10px;
|
||||||
|
}
|
||||||
|
.upload-progress-bar-container, .upload-progress-svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes popout-fadein {
|
||||||
|
from { opacity: 0; transform: translateY(30px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
Loading…
Reference in a new issue