- Created complete documentation in docs/ directory - Added PROJECT_OVERVIEW.md with feature highlights and getting started guide - Added ARCHITECTURE.md with system design and technical details - Added SECURITY.md with comprehensive security implementation guide - Added DEVELOPMENT.md with development workflows and best practices - Added DEPLOYMENT.md with production deployment instructions - Added API.md with complete REST API documentation - Added CONTRIBUTING.md with contribution guidelines - Added CHANGELOG.md with version history and migration notes - Reorganized all documentation files into docs/ directory for better organization - Updated README.md with proper documentation links and quick navigation - Enhanced project structure with professional documentation standards
441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
/**
|
||
* EasyStream Upload Progress Widget
|
||
*
|
||
* Displays real-time upload progress for files being uploaded
|
||
* Features:
|
||
* - Multiple simultaneous uploads
|
||
* - Real-time progress bars
|
||
* - Processing status indicators
|
||
* - Cancel uploads
|
||
* - Auto-hide when complete
|
||
*/
|
||
|
||
class UploadProgressWidget {
|
||
constructor(options = {}) {
|
||
this.options = {
|
||
apiUrl: options.apiUrl || '/api/upload/progress.php',
|
||
pollInterval: options.pollInterval || 1000, // Poll every second
|
||
autoHide: options.autoHide !== false,
|
||
hideDelay: options.hideDelay || 3000,
|
||
...options
|
||
};
|
||
|
||
this.uploads = new Map();
|
||
this.widget = null;
|
||
this.pollTimer = null;
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.createWidget();
|
||
this.loadExistingUploads();
|
||
}
|
||
|
||
createWidget() {
|
||
const widget = document.createElement('div');
|
||
widget.className = 'upload-progress-widget';
|
||
widget.innerHTML = `
|
||
<div class="upload-widget-header">
|
||
<h3>Uploads</h3>
|
||
<button class="collapse-btn" aria-label="Collapse">−</button>
|
||
</div>
|
||
<div class="upload-widget-body">
|
||
<div class="upload-list"></div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(widget);
|
||
this.widget = widget;
|
||
|
||
// Collapse functionality
|
||
widget.querySelector('.collapse-btn').addEventListener('click', () => {
|
||
widget.classList.toggle('collapsed');
|
||
widget.querySelector('.collapse-btn').textContent =
|
||
widget.classList.contains('collapsed') ? '+' : '−';
|
||
});
|
||
|
||
this.injectStyles();
|
||
}
|
||
|
||
injectStyles() {
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.upload-progress-widget {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
width: 380px;
|
||
max-height: 500px;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||
z-index: 9999;
|
||
display: none;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.upload-progress-widget.visible {
|
||
display: flex;
|
||
}
|
||
|
||
.upload-progress-widget.collapsed .upload-widget-body {
|
||
display: none;
|
||
}
|
||
|
||
.upload-widget-header {
|
||
padding: 16px 20px;
|
||
background: #f9f9f9;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.upload-widget-header h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.collapse-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.upload-widget-body {
|
||
overflow-y: auto;
|
||
max-height: 440px;
|
||
}
|
||
|
||
.upload-list {
|
||
padding: 12px;
|
||
}
|
||
|
||
.upload-item {
|
||
padding: 12px;
|
||
margin-bottom: 12px;
|
||
background: #f9f9f9;
|
||
border-radius: 8px;
|
||
border: 1px solid #e0e0e0;
|
||
}
|
||
|
||
.upload-item.completed {
|
||
background: #e8f5e9;
|
||
border-color: #4caf50;
|
||
}
|
||
|
||
.upload-item.failed {
|
||
background: #ffebee;
|
||
border-color: #f44336;
|
||
}
|
||
|
||
.upload-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upload-filename {
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.upload-cancel {
|
||
background: none;
|
||
border: none;
|
||
color: #f44336;
|
||
cursor: pointer;
|
||
padding: 0 4px;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.upload-status {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upload-progress-bar {
|
||
height: 6px;
|
||
background: #e0e0e0;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.upload-progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #065fd4, #1e88e5);
|
||
transition: width 0.3s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.upload-progress-fill.processing {
|
||
background: linear-gradient(90deg, #f57c00, #ff9800);
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.upload-progress-fill.completed {
|
||
background: #4caf50;
|
||
}
|
||
|
||
.upload-progress-fill.failed {
|
||
background: #f44336;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
.upload-percent {
|
||
font-size: 12px;
|
||
color: #666;
|
||
text-align: right;
|
||
}
|
||
|
||
.upload-error {
|
||
font-size: 12px;
|
||
color: #f44336;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.upload-progress-widget {
|
||
width: calc(100% - 40px);
|
||
right: 20px;
|
||
left: 20px;
|
||
}
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
.upload-progress-widget {
|
||
background: #212121;
|
||
}
|
||
|
||
.upload-widget-header {
|
||
background: #2a2a2a;
|
||
border-bottom-color: #303030;
|
||
}
|
||
|
||
.upload-item {
|
||
background: #2a2a2a;
|
||
border-color: #303030;
|
||
}
|
||
}
|
||
`;
|
||
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
addUpload(uploadId, filename, fileType) {
|
||
this.uploads.set(uploadId, {
|
||
id: uploadId,
|
||
filename: filename,
|
||
fileType: fileType,
|
||
status: 'uploading',
|
||
percent: 0
|
||
});
|
||
|
||
this.renderUpload(uploadId);
|
||
this.show();
|
||
this.startPolling();
|
||
}
|
||
|
||
renderUpload(uploadId) {
|
||
const upload = this.uploads.get(uploadId);
|
||
if (!upload) return;
|
||
|
||
const list = this.widget.querySelector('.upload-list');
|
||
let item = list.querySelector(`[data-upload-id="${uploadId}"]`);
|
||
|
||
if (!item) {
|
||
item = document.createElement('div');
|
||
item.className = 'upload-item';
|
||
item.dataset.uploadId = uploadId;
|
||
list.appendChild(item);
|
||
}
|
||
|
||
const statusText = this.getStatusText(upload.status, upload.processingStep);
|
||
const progressClass = upload.status === 'processing' || upload.status === 'encoding' ? 'processing' :
|
||
upload.status === 'completed' ? 'completed' :
|
||
upload.status === 'failed' ? 'failed' : '';
|
||
|
||
item.className = `upload-item ${upload.status}`;
|
||
item.innerHTML = `
|
||
<div class="upload-header">
|
||
<div class="upload-filename" title="${upload.filename}">${upload.filename}</div>
|
||
${upload.status === 'uploading' || upload.status === 'processing' ?
|
||
`<button class="upload-cancel" data-upload-id="${uploadId}" aria-label="Cancel">×</button>` :
|
||
''}
|
||
</div>
|
||
<div class="upload-status">${statusText}</div>
|
||
<div class="upload-progress-bar">
|
||
<div class="upload-progress-fill ${progressClass}" style="width: ${upload.percent}%"></div>
|
||
</div>
|
||
<div class="upload-percent">${Math.round(upload.percent)}%</div>
|
||
${upload.errorMessage ? `<div class="upload-error">${upload.errorMessage}</div>` : ''}
|
||
`;
|
||
|
||
// Add cancel handler
|
||
const cancelBtn = item.querySelector('.upload-cancel');
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener('click', () => this.cancelUpload(uploadId));
|
||
}
|
||
}
|
||
|
||
getStatusText(status, processingStep) {
|
||
const statusMap = {
|
||
'uploading': 'Uploading...',
|
||
'processing': processingStep || 'Processing...',
|
||
'encoding': processingStep || 'Encoding video...',
|
||
'completed': 'Upload complete!',
|
||
'failed': 'Upload failed',
|
||
'cancelled': 'Cancelled'
|
||
};
|
||
|
||
return statusMap[status] || status;
|
||
}
|
||
|
||
async updateStatus(uploadId) {
|
||
try {
|
||
const response = await fetch(`${this.options.apiUrl}?action=get_status&upload_id=${uploadId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
console.error('Error fetching upload status:', data.error);
|
||
return;
|
||
}
|
||
|
||
const upload = this.uploads.get(uploadId);
|
||
if (upload) {
|
||
upload.status = data.status;
|
||
upload.percent = data.upload_percent;
|
||
upload.processingStep = data.processing_step;
|
||
upload.errorMessage = data.error_message;
|
||
upload.fileKey = data.file_key;
|
||
|
||
this.renderUpload(uploadId);
|
||
|
||
// Auto-hide completed uploads
|
||
if (data.status === 'completed' && this.options.autoHide) {
|
||
setTimeout(() => {
|
||
this.removeUpload(uploadId);
|
||
}, this.options.hideDelay);
|
||
}
|
||
|
||
// Stop polling if all uploads are done
|
||
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
||
const hasActive = Array.from(this.uploads.values()).some(u =>
|
||
u.status === 'uploading' || u.status === 'processing' || u.status === 'encoding'
|
||
);
|
||
|
||
if (!hasActive) {
|
||
this.stopPolling();
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating upload status:', error);
|
||
}
|
||
}
|
||
|
||
async loadExistingUploads() {
|
||
try {
|
||
const response = await fetch(`${this.options.apiUrl}?action=get_all`);
|
||
const data = await response.json();
|
||
|
||
if (data.uploads && data.uploads.length > 0) {
|
||
data.uploads.forEach(upload => {
|
||
this.uploads.set(upload.upload_id, {
|
||
id: upload.upload_id,
|
||
filename: upload.filename,
|
||
fileType: upload.file_type,
|
||
status: upload.status,
|
||
percent: upload.upload_percent,
|
||
processingStep: upload.processing_step
|
||
});
|
||
|
||
this.renderUpload(upload.upload_id);
|
||
});
|
||
|
||
this.show();
|
||
this.startPolling();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading existing uploads:', error);
|
||
}
|
||
}
|
||
|
||
async cancelUpload(uploadId) {
|
||
try {
|
||
const response = await fetch(`${this.options.apiUrl}?action=cancel&upload_id=${uploadId}`);
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.removeUpload(uploadId);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error cancelling upload:', error);
|
||
}
|
||
}
|
||
|
||
removeUpload(uploadId) {
|
||
this.uploads.delete(uploadId);
|
||
const item = this.widget.querySelector(`[data-upload-id="${uploadId}"]`);
|
||
if (item) {
|
||
item.remove();
|
||
}
|
||
|
||
if (this.uploads.size === 0) {
|
||
this.hide();
|
||
}
|
||
}
|
||
|
||
startPolling() {
|
||
if (this.pollTimer) return;
|
||
|
||
this.pollTimer = setInterval(() => {
|
||
this.uploads.forEach((upload, id) => {
|
||
if (upload.status === 'uploading' || upload.status === 'processing' || upload.status === 'encoding') {
|
||
this.updateStatus(id);
|
||
}
|
||
});
|
||
}, this.options.pollInterval);
|
||
}
|
||
|
||
stopPolling() {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer);
|
||
this.pollTimer = null;
|
||
}
|
||
}
|
||
|
||
show() {
|
||
this.widget.classList.add('visible');
|
||
}
|
||
|
||
hide() {
|
||
this.widget.classList.remove('visible');
|
||
this.stopPolling();
|
||
}
|
||
}
|
||
|
||
// Export for use in other scripts
|
||
window.UploadProgressWidget = UploadProgressWidget;
|