Przeglądaj źródła

Add queued upload progress modal

Alexander Musikhin 2 dni temu
rodzic
commit
bf2806f2a5
2 zmienionych plików z 530 dodań i 0 usunięć
  1. 494 0
      resources/js/custom.js
  2. 36 0
      resources/sass/app.scss

+ 494 - 0
resources/js/custom.js

@@ -1,4 +1,497 @@
 $(document).ready(function () {
+    function initQueuedUploads() {
+        const uploadForms = new WeakSet();
+        const state = {
+            modal: null,
+            modalElement: null,
+            queue: [],
+            running: new Set(),
+            completed: 0,
+            failed: 0,
+            totalBytes: 0,
+            lastRedirectUrl: '',
+            isCancelled: false,
+            activeForm: null,
+        };
+        const maxParallelUploads = 2;
+
+        function formatBytes(bytes) {
+            if (!bytes) {
+                return '0 МБ';
+            }
+
+            const units = ['Б', 'КБ', 'МБ', 'ГБ'];
+            let value = bytes;
+            let unitIndex = 0;
+
+            while (value >= 1024 && unitIndex < units.length - 1) {
+                value /= 1024;
+                unitIndex += 1;
+            }
+
+            return value.toFixed(unitIndex === 0 ? 0 : 1) + ' ' + units[unitIndex];
+        }
+
+        function ensureModal() {
+            if (state.modalElement) {
+                return state.modalElement;
+            }
+
+            const wrapper = document.createElement('div');
+            wrapper.innerHTML = '' +
+                '<div class="modal fade upload-queue-modal" id="uploadQueueModal" tabindex="-1" aria-labelledby="uploadQueueModalLabel" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">' +
+                    '<div class="modal-dialog modal-dialog-scrollable modal-lg">' +
+                        '<div class="modal-content">' +
+                            '<div class="modal-header">' +
+                                '<h1 class="modal-title fs-5" id="uploadQueueModalLabel">Загрузка файлов</h1>' +
+                                '<button type="button" class="btn-close d-none" data-upload-close aria-label="Закрыть"></button>' +
+                            '</div>' +
+                            '<div class="modal-body">' +
+                                '<div class="upload-queue-summary">' +
+                                    '<div class="d-flex justify-content-between align-items-center gap-3 mb-2">' +
+                                        '<div class="fw-semibold" data-upload-status>Подготовка загрузки</div>' +
+                                        '<div class="text-muted small text-nowrap" data-upload-bytes>0 МБ / 0 МБ</div>' +
+                                    '</div>' +
+                                    '<div class="progress upload-queue-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100">' +
+                                        '<div class="progress-bar" data-upload-total-progress style="width: 0%">0%</div>' +
+                                    '</div>' +
+                                    '<div class="small text-muted mt-2" data-upload-counts>0 из 0 файлов</div>' +
+                                '</div>' +
+                                '<div class="upload-queue-list list-group mt-3" data-upload-list></div>' +
+                            '</div>' +
+                            '<div class="modal-footer">' +
+                                '<button type="button" class="btn btn-outline-secondary" data-upload-cancel>Отмена</button>' +
+                                '<button type="button" class="btn btn-outline-primary d-none" data-upload-retry>Повторить ошибки</button>' +
+                                '<button type="button" class="btn btn-primary d-none" data-upload-refresh>Обновить страницу</button>' +
+                            '</div>' +
+                        '</div>' +
+                    '</div>' +
+                '</div>';
+
+            state.modalElement = wrapper.firstElementChild;
+            document.body.appendChild(state.modalElement);
+            state.modal = bootstrap.Modal.getOrCreateInstance(state.modalElement);
+
+            state.modalElement.querySelector('[data-upload-cancel]').addEventListener('click', cancelUploads);
+            state.modalElement.querySelector('[data-upload-retry]').addEventListener('click', retryFailedUploads);
+            state.modalElement.querySelector('[data-upload-refresh]').addEventListener('click', refreshAfterUpload);
+            state.modalElement.querySelector('[data-upload-close]').addEventListener('click', function () {
+                state.modal.hide();
+            });
+
+            return state.modalElement;
+        }
+
+        function getElements() {
+            const modal = ensureModal();
+
+            return {
+                status: modal.querySelector('[data-upload-status]'),
+                bytes: modal.querySelector('[data-upload-bytes]'),
+                progress: modal.querySelector('[data-upload-total-progress]'),
+                counts: modal.querySelector('[data-upload-counts]'),
+                list: modal.querySelector('[data-upload-list]'),
+                cancel: modal.querySelector('[data-upload-cancel]'),
+                retry: modal.querySelector('[data-upload-retry]'),
+                refresh: modal.querySelector('[data-upload-refresh]'),
+                close: modal.querySelector('[data-upload-close]'),
+            };
+        }
+
+        function getSelectedFiles(form) {
+            return Array.from(form.querySelectorAll('input[type="file"]'))
+                .flatMap(function (input) {
+                    return Array.from(input.files || []).map(function (file) {
+                        return { input: input, file: file };
+                    });
+                });
+        }
+
+        function buildQueue(form) {
+            const selectedFiles = getSelectedFiles(form);
+
+            return selectedFiles.map(function (entry, index) {
+                const row = document.createElement('div');
+                row.className = 'list-group-item upload-queue-item';
+                row.innerHTML = '' +
+                    '<div class="d-flex justify-content-between gap-3">' +
+                        '<div class="upload-queue-name text-truncate"></div>' +
+                        '<div class="upload-queue-size text-muted small text-nowrap"></div>' +
+                    '</div>' +
+                    '<div class="d-flex justify-content-between gap-3 mt-1">' +
+                        '<div class="small" data-upload-item-status>Ожидает</div>' +
+                        '<div class="small text-muted" data-upload-item-percent>0%</div>' +
+                    '</div>' +
+                    '<div class="progress upload-queue-item-progress mt-2" role="progressbar" aria-valuemin="0" aria-valuemax="100">' +
+                        '<div class="progress-bar" data-upload-item-progress style="width: 0%"></div>' +
+                    '</div>';
+
+                row.querySelector('.upload-queue-name').textContent = entry.file.name;
+                row.querySelector('.upload-queue-size').textContent = formatBytes(entry.file.size);
+
+                return {
+                    id: index,
+                    form: form,
+                    input: entry.input,
+                    file: entry.file,
+                    row: row,
+                    loaded: 0,
+                    status: 'pending',
+                    xhr: null,
+                    error: '',
+                };
+            });
+        }
+
+        function updateItem(item, status, loaded, error) {
+            item.status = status;
+            item.loaded = typeof loaded === 'number' ? loaded : item.loaded;
+            item.error = error || '';
+
+            const percent = item.file.size > 0 ? Math.round((item.loaded / item.file.size) * 100) : 0;
+            const statusElement = item.row.querySelector('[data-upload-item-status]');
+            const percentElement = item.row.querySelector('[data-upload-item-percent]');
+            const progressElement = item.row.querySelector('[data-upload-item-progress]');
+
+            item.row.classList.toggle('upload-queue-item-failed', status === 'failed');
+            item.row.classList.toggle('upload-queue-item-success', status === 'done');
+            progressElement.classList.toggle('bg-danger', status === 'failed');
+            progressElement.classList.toggle('bg-success', status === 'done');
+
+            const statusLabels = {
+                pending: 'Ожидает',
+                uploading: 'Загружается',
+                done: 'Загружено',
+                failed: item.error || 'Ошибка загрузки',
+                cancelled: 'Отменено',
+            };
+
+            statusElement.textContent = statusLabels[status] || status;
+            percentElement.textContent = Math.min(percent, 100) + '%';
+            progressElement.style.width = Math.min(percent, 100) + '%';
+        }
+
+        function updateSummary() {
+            const elements = getElements();
+            const uploadedBytes = state.queue.reduce(function (sum, item) {
+                if (item.status === 'done') {
+                    return sum + item.file.size;
+                }
+
+                if (item.status === 'uploading') {
+                    return sum + item.loaded;
+                }
+
+                return sum;
+            }, 0);
+            const doneCount = state.queue.filter(function (item) { return item.status === 'done'; }).length;
+            const failedCount = state.queue.filter(function (item) { return item.status === 'failed'; }).length;
+            const pendingCount = state.queue.filter(function (item) { return item.status === 'pending'; }).length;
+            const percent = state.totalBytes > 0 ? Math.round((uploadedBytes / state.totalBytes) * 100) : 0;
+
+            elements.progress.style.width = Math.min(percent, 100) + '%';
+            elements.progress.textContent = Math.min(percent, 100) + '%';
+            elements.bytes.textContent = formatBytes(uploadedBytes) + ' / ' + formatBytes(state.totalBytes);
+            elements.counts.textContent = doneCount + ' из ' + state.queue.length + ' файлов';
+
+            if (state.isCancelled) {
+                elements.status.textContent = 'Загрузка отменена';
+            } else if (failedCount > 0 && state.running.size === 0 && pendingCount === 0) {
+                elements.status.textContent = 'Загружено с ошибками: ' + doneCount + ' из ' + state.queue.length;
+            } else if (doneCount === state.queue.length && state.queue.length > 0) {
+                elements.status.textContent = 'Все файлы загружены';
+            } else {
+                elements.status.textContent = 'Загрузка: ' + doneCount + ' из ' + state.queue.length;
+            }
+
+            elements.cancel.classList.toggle('d-none', state.running.size === 0 && pendingCount === 0);
+            elements.retry.classList.toggle('d-none', failedCount === 0 || state.running.size > 0 || pendingCount > 0);
+            elements.refresh.classList.toggle('d-none', doneCount === 0 || state.running.size > 0 || pendingCount > 0);
+            elements.close.classList.toggle('d-none', state.running.size > 0 || pendingCount > 0);
+        }
+
+        function appendFormFields(formData, form, activeInput) {
+            Array.from(form.elements).forEach(function (element) {
+                if (!element.name || element.disabled || element === activeInput) {
+                    return;
+                }
+
+                if (element.type === 'file') {
+                    return;
+                }
+
+                if ((element.type === 'checkbox' || element.type === 'radio') && !element.checked) {
+                    return;
+                }
+
+                formData.append(element.name, element.value);
+            });
+        }
+
+        function extractError(xhr) {
+            if (xhr.responseText) {
+                try {
+                    const data = JSON.parse(xhr.responseText);
+
+                    if (data.message) {
+                        return data.message;
+                    }
+
+                    if (data.errors) {
+                        return Object.values(data.errors).flat().join(' ');
+                    }
+                } catch (error) {
+                    // Response is not JSON. Fall through to generic status handling.
+                }
+
+                const htmlError = extractHtmlError(xhr.responseText);
+
+                if (htmlError) {
+                    return htmlError;
+                }
+            }
+
+            if (xhr.status === 413) {
+                return 'Файл слишком большой';
+            }
+
+            if (xhr.status === 419) {
+                return 'Сессия истекла, обновите страницу';
+            }
+
+            if (xhr.status >= 500) {
+                return 'Ошибка сервера';
+            }
+
+            return xhr.status ? 'Ошибка HTTP ' + xhr.status : 'Ошибка сети';
+        }
+
+        function extractHtmlError(responseText) {
+            if (!responseText || !responseText.includes('alert-danger')) {
+                return '';
+            }
+
+            try {
+                const documentFragment = new DOMParser().parseFromString(responseText, 'text/html');
+                const alert = documentFragment.querySelector('.main-alert.alert-danger, .alert-danger[role="alert"]');
+
+                return alert ? alert.textContent.trim().replace(/\s+/g, ' ') : '';
+            } catch (error) {
+                return '';
+            }
+        }
+
+        function hasHtmlUploadError(xhr) {
+            const contentType = xhr.getResponseHeader('Content-Type') || '';
+
+            return contentType.includes('text/html') && Boolean(extractHtmlError(xhr.responseText));
+        }
+
+        function uploadItem(item) {
+            const formData = new FormData();
+            const xhr = new XMLHttpRequest();
+            const method = (item.form.getAttribute('method') || 'POST').toUpperCase();
+            const action = item.form.getAttribute('action') || window.location.href;
+            const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
+
+            appendFormFields(formData, item.form, item.input);
+            formData.append(item.input.name, item.file, item.file.name);
+
+            item.xhr = xhr;
+            state.running.add(item);
+            updateItem(item, 'uploading', 0);
+            updateSummary();
+
+            xhr.open(method, action, true);
+            xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+            xhr.setRequestHeader('Accept', 'application/json, text/html;q=0.9, */*;q=0.8');
+
+            if (csrfToken) {
+                xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
+            }
+
+            xhr.upload.onprogress = function (event) {
+                if (!event.lengthComputable) {
+                    return;
+                }
+
+                updateItem(item, 'uploading', event.loaded);
+                updateSummary();
+            };
+
+            xhr.onload = function () {
+                state.running.delete(item);
+                item.xhr = null;
+
+                if (xhr.status >= 200 && xhr.status < 300 && !hasHtmlUploadError(xhr)) {
+                    state.lastRedirectUrl = xhr.responseURL || state.lastRedirectUrl;
+                    state.completed += 1;
+                    updateItem(item, 'done', item.file.size);
+                } else {
+                    state.failed += 1;
+                    updateItem(item, 'failed', item.loaded, extractError(xhr));
+                }
+
+                updateSummary();
+                runNext();
+            };
+
+            xhr.onerror = function () {
+                state.running.delete(item);
+                item.xhr = null;
+                state.failed += 1;
+                updateItem(item, 'failed', item.loaded, 'Ошибка сети');
+                updateSummary();
+                runNext();
+            };
+
+            xhr.onabort = function () {
+                state.running.delete(item);
+                item.xhr = null;
+                updateItem(item, 'cancelled', item.loaded);
+                updateSummary();
+            };
+
+            xhr.send(formData);
+        }
+
+        function runNext() {
+            if (state.isCancelled) {
+                return;
+            }
+
+            while (state.running.size < maxParallelUploads) {
+                const nextItem = state.queue.find(function (item) {
+                    return item.status === 'pending';
+                });
+
+                if (!nextItem) {
+                    break;
+                }
+
+                uploadItem(nextItem);
+            }
+
+            const hasPending = state.queue.some(function (item) { return item.status === 'pending'; });
+
+            if (!hasPending && state.running.size === 0 && state.failed === 0 && state.completed > 0) {
+                setTimeout(refreshAfterUpload, 700);
+            }
+        }
+
+        function startUpload(form) {
+            const hasActiveUpload = state.running.size > 0 || state.queue.some(function (item) {
+                return item.status === 'pending' || item.status === 'uploading';
+            });
+
+            if (hasActiveUpload) {
+                customAlert('Дождитесь завершения текущей загрузки файлов.');
+                return;
+            }
+
+            const elements = getElements();
+            state.queue = buildQueue(form);
+            state.running = new Set();
+            state.completed = 0;
+            state.failed = 0;
+            state.totalBytes = state.queue.reduce(function (sum, item) {
+                return sum + item.file.size;
+            }, 0);
+            state.lastRedirectUrl = '';
+            state.isCancelled = false;
+            state.activeForm = form;
+
+            elements.list.innerHTML = '';
+            state.queue.forEach(function (item) {
+                elements.list.appendChild(item.row);
+                updateItem(item, 'pending', 0);
+            });
+
+            state.modal.show();
+            updateSummary();
+            runNext();
+        }
+
+        function cancelUploads() {
+            state.isCancelled = true;
+            state.queue.forEach(function (item) {
+                if (item.status === 'pending') {
+                    updateItem(item, 'cancelled', 0);
+                }
+            });
+            state.running.forEach(function (item) {
+                if (item.xhr) {
+                    item.xhr.abort();
+                }
+            });
+            updateSummary();
+        }
+
+        function retryFailedUploads() {
+            state.queue.forEach(function (item) {
+                if (item.status === 'failed') {
+                    updateItem(item, 'pending', 0);
+                }
+            });
+
+            state.failed = 0;
+            state.isCancelled = false;
+            updateSummary();
+            runNext();
+        }
+
+        function refreshAfterUpload() {
+            const redirectUrl = state.lastRedirectUrl;
+
+            if (redirectUrl && redirectUrl.startsWith(window.location.origin)) {
+                window.location.href = redirectUrl;
+                return;
+            }
+
+            window.location.reload();
+        }
+
+        document.addEventListener('submit', function (event) {
+            const form = event.target;
+
+            if (!(form instanceof HTMLFormElement)
+                || uploadForms.has(form)
+                || form.matches('[data-chat-form]')
+                || form.dataset.uploadNative === '1'
+                || !form.matches('form[enctype="multipart/form-data"]')
+                || getSelectedFiles(form).length === 0) {
+                return;
+            }
+
+            event.preventDefault();
+            event.stopPropagation();
+            uploadForms.add(form);
+            startUpload(form);
+            setTimeout(function () {
+                uploadForms.delete(form);
+            }, 0);
+        }, true);
+
+        document.addEventListener('change', function (event) {
+            const input = event.target;
+
+            if (!(input instanceof HTMLInputElement)
+                || input.type !== 'file'
+                || !input.getAttribute('onchange')?.includes('submit')
+                || !input.form
+                || input.form.matches('[data-chat-form]')
+                || input.form.dataset.uploadNative === '1'
+                || !input.form.matches('form[enctype="multipart/form-data"]')
+                || getSelectedFiles(input.form).length === 0) {
+                return;
+            }
+
+            event.preventDefault();
+            event.stopImmediatePropagation();
+            startUpload(input.form);
+        }, true);
+    }
+
     function initMobileDatePicker() {
         const mobileQuery = window.matchMedia('(max-width: 767.98px)');
         const coarsePointer = window.matchMedia('(pointer: coarse)');
@@ -366,6 +859,7 @@ $(document).ready(function () {
     }
 
     cleanupStaleModalState();
+    initQueuedUploads();
     initMobileDatePicker();
     window.addEventListener('pageshow', cleanupStaleModalState);
 

+ 36 - 0
resources/sass/app.scss

@@ -1059,3 +1059,39 @@ td p {
 .section-header-row {
   background-color: #d4edda;
 }
+
+.upload-queue-modal {
+  .modal-dialog {
+    max-width: min(760px, calc(100vw - 1rem));
+  }
+}
+
+.upload-queue-progress {
+  height: 1.25rem;
+}
+
+.upload-queue-item-progress {
+  height: 0.5rem;
+}
+
+.upload-queue-list {
+  max-height: 45vh;
+  overflow-y: auto;
+}
+
+.upload-queue-item {
+  border-radius: 0;
+}
+
+.upload-queue-item-success {
+  background-color: rgba(25, 135, 84, 0.06);
+}
+
+.upload-queue-item-failed {
+  background-color: rgba(220, 53, 69, 0.06);
+}
+
+.upload-queue-name {
+  min-width: 0;
+  font-weight: 500;
+}