| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935 |
- $(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)');
- if (!mobileQuery.matches || !coarsePointer.matches) {
- return;
- }
- const dateInputs = Array.from(document.querySelectorAll('input[type="date"]')).filter(function (input) {
- return !input.disabled && !input.dataset.mobileDateEnhanced;
- });
- if (!dateInputs.length) {
- return;
- }
- const monthFormatter = new Intl.DateTimeFormat('ru-RU', {
- month: 'long',
- year: 'numeric',
- });
- const displayFormatter = new Intl.DateTimeFormat('ru-RU');
- const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
- const overlay = document.createElement('div');
- overlay.className = 'mobile-date-picker';
- overlay.hidden = true;
- overlay.innerHTML = '' +
- '<div class="mobile-date-picker__dialog" role="dialog" aria-modal="true" aria-label="Выбор даты">' +
- '<div class="mobile-date-picker__header">' +
- '<button type="button" class="btn btn-link mobile-date-picker__close" aria-label="Закрыть"><i class="bi bi-x-lg"></i></button>' +
- '<div class="mobile-date-picker__title"></div>' +
- '<button type="button" class="btn btn-link mobile-date-picker__today">Сегодня</button>' +
- '</div>' +
- '<div class="mobile-date-picker__month-nav">' +
- '<button type="button" class="btn btn-link mobile-date-picker__nav mobile-date-picker__nav--prev" aria-label="Предыдущий месяц"><i class="bi bi-chevron-left"></i></button>' +
- '<div class="mobile-date-picker__month-label"></div>' +
- '<button type="button" class="btn btn-link mobile-date-picker__nav mobile-date-picker__nav--next" aria-label="Следующий месяц"><i class="bi bi-chevron-right"></i></button>' +
- '</div>' +
- '<div class="mobile-date-picker__weekdays"></div>' +
- '<div class="mobile-date-picker__grid"></div>' +
- '<div class="mobile-date-picker__footer">' +
- '<button type="button" class="btn btn-outline-secondary mobile-date-picker__clear">Очистить</button>' +
- '<button type="button" class="btn btn-primary mobile-date-picker__apply">Готово</button>' +
- '</div>' +
- '</div>';
- document.body.appendChild(overlay);
- const titleEl = overlay.querySelector('.mobile-date-picker__title');
- const monthLabelEl = overlay.querySelector('.mobile-date-picker__month-label');
- const weekdaysEl = overlay.querySelector('.mobile-date-picker__weekdays');
- const gridEl = overlay.querySelector('.mobile-date-picker__grid');
- const closeBtn = overlay.querySelector('.mobile-date-picker__close');
- const todayBtn = overlay.querySelector('.mobile-date-picker__today');
- const clearBtn = overlay.querySelector('.mobile-date-picker__clear');
- const applyBtn = overlay.querySelector('.mobile-date-picker__apply');
- const prevBtn = overlay.querySelector('.mobile-date-picker__nav--prev');
- const nextBtn = overlay.querySelector('.mobile-date-picker__nav--next');
- weekdaysEl.innerHTML = weekdayLabels.map(function (day) {
- return '<div class="mobile-date-picker__weekday">' + day + '</div>';
- }).join('');
- function parseIsoDate(value) {
- if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
- return null;
- }
- const parts = value.split('-').map(Number);
- const date = new Date(parts[0], parts[1] - 1, parts[2]);
- if (Number.isNaN(date.getTime())) {
- return null;
- }
- return date;
- }
- function formatIsoDate(date) {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- return year + '-' + month + '-' + day;
- }
- function formatDisplayDate(value) {
- const date = parseIsoDate(value);
- return date ? displayFormatter.format(date) : '';
- }
- function normalizeMonthLabel(date) {
- const label = monthFormatter.format(date);
- return label.charAt(0).toUpperCase() + label.slice(1);
- }
- function startOfMonth(date) {
- return new Date(date.getFullYear(), date.getMonth(), 1);
- }
- function isSameDate(a, b) {
- return a && b
- && a.getFullYear() === b.getFullYear()
- && a.getMonth() === b.getMonth()
- && a.getDate() === b.getDate();
- }
- const state = {
- activeInput: null,
- hiddenInput: null,
- displayInput: null,
- selectedDate: null,
- draftDate: null,
- viewDate: startOfMonth(new Date()),
- };
- function syncDisplay(hiddenInput, displayInput) {
- displayInput.value = formatDisplayDate(hiddenInput.value);
- displayInput.classList.toggle('mobile-date-input--empty', !hiddenInput.value);
- }
- function closePicker() {
- overlay.hidden = true;
- document.body.classList.remove('mobile-date-picker-open');
- state.activeInput = null;
- state.hiddenInput = null;
- state.displayInput = null;
- state.selectedDate = null;
- state.draftDate = null;
- }
- function renderCalendar() {
- const today = new Date();
- const monthStart = startOfMonth(state.viewDate);
- const gridStart = new Date(monthStart);
- const firstWeekday = (monthStart.getDay() + 6) % 7;
- gridStart.setDate(monthStart.getDate() - firstWeekday);
- monthLabelEl.textContent = normalizeMonthLabel(monthStart);
- gridEl.innerHTML = '';
- for (let index = 0; index < 42; index += 1) {
- const day = new Date(gridStart);
- day.setDate(gridStart.getDate() + index);
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'mobile-date-picker__day';
- button.textContent = String(day.getDate());
- button.dataset.value = formatIsoDate(day);
- if (day.getMonth() !== monthStart.getMonth()) {
- button.classList.add('is-outside');
- }
- if (isSameDate(day, today)) {
- button.classList.add('is-today');
- }
- if (state.draftDate && isSameDate(day, state.draftDate)) {
- button.classList.add('is-selected');
- }
- button.addEventListener('click', function () {
- state.draftDate = parseIsoDate(button.dataset.value);
- renderCalendar();
- });
- gridEl.appendChild(button);
- }
- }
- function openPicker(hiddenInput, displayInput) {
- state.activeInput = hiddenInput;
- state.hiddenInput = hiddenInput;
- state.displayInput = displayInput;
- state.selectedDate = parseIsoDate(hiddenInput.value);
- state.draftDate = state.selectedDate;
- state.viewDate = startOfMonth(state.selectedDate || new Date());
- titleEl.textContent = hiddenInput.dataset.mobileDateTitle || 'Выбор даты';
- overlay.hidden = false;
- document.body.classList.add('mobile-date-picker-open');
- renderCalendar();
- }
- closeBtn.addEventListener('click', closePicker);
- overlay.addEventListener('click', function (event) {
- if (event.target === overlay) {
- closePicker();
- }
- });
- prevBtn.addEventListener('click', function () {
- state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() - 1, 1);
- renderCalendar();
- });
- nextBtn.addEventListener('click', function () {
- state.viewDate = new Date(state.viewDate.getFullYear(), state.viewDate.getMonth() + 1, 1);
- renderCalendar();
- });
- todayBtn.addEventListener('click', function () {
- state.draftDate = startOfMonth(new Date());
- state.draftDate = new Date(state.draftDate.getFullYear(), state.draftDate.getMonth(), new Date().getDate());
- state.viewDate = startOfMonth(state.draftDate);
- renderCalendar();
- });
- clearBtn.addEventListener('click', function () {
- if (!state.hiddenInput || !state.displayInput) {
- return;
- }
- state.hiddenInput.value = '';
- state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
- state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
- syncDisplay(state.hiddenInput, state.displayInput);
- closePicker();
- });
- applyBtn.addEventListener('click', function () {
- if (!state.hiddenInput || !state.displayInput || !state.draftDate) {
- closePicker();
- return;
- }
- state.hiddenInput.value = formatIsoDate(state.draftDate);
- state.hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
- state.hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
- syncDisplay(state.hiddenInput, state.displayInput);
- closePicker();
- });
- document.addEventListener('keydown', function (event) {
- if (event.key === 'Escape' && !overlay.hidden) {
- closePicker();
- }
- });
- dateInputs.forEach(function (input) {
- input.dataset.mobileDateEnhanced = '1';
- input.dataset.mobileDateTitle = input.closest('.row')?.querySelector('label[for="' + input.id + '"]')?.textContent.trim() || 'Выбор даты';
- const displayInput = input.cloneNode(false);
- displayInput.type = 'text';
- displayInput.removeAttribute('name');
- displayInput.value = '';
- displayInput.readOnly = true;
- displayInput.dataset.mobileDateDisplay = '1';
- displayInput.classList.add('mobile-date-input');
- displayInput.placeholder = 'Выберите дату';
- input.type = 'hidden';
- input.dataset.mobileDateHidden = '1';
- input.parentNode.insertBefore(displayInput, input.nextSibling);
- syncDisplay(input, displayInput);
- displayInput.addEventListener('click', function () {
- openPicker(input, displayInput);
- });
- });
- }
- function cleanupStaleModalState() {
- if (!document.querySelector('.modal.show')) {
- document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
- backdrop.remove();
- });
- document.body.classList.remove('modal-open');
- document.body.style.removeProperty('padding-right');
- document.body.style.removeProperty('overflow');
- }
- }
- function getNotificationBadge() {
- return document.getElementById('notification-badge');
- }
- function updateNotificationBadge(count) {
- const badge = getNotificationBadge();
- if (!badge) {
- return;
- }
- const safeCount = Math.max(0, parseInt(count || 0, 10));
- badge.dataset.count = String(safeCount);
- badge.classList.toggle('d-none', safeCount === 0);
- }
- function incrementNotificationBadge() {
- const badge = getNotificationBadge();
- if (!badge) {
- return;
- }
- const current = parseInt(badge.dataset.count || '0', 10);
- updateNotificationBadge(current + 1);
- }
- function markNotificationRead(notificationId) {
- if (!notificationId || !window.notificationsReadUrlTemplate) {
- return;
- }
- fetch(window.notificationsReadUrlTemplate.replace('__id__', String(notificationId)), {
- method: 'POST',
- headers: {
- 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
- 'Accept': 'application/json',
- },
- keepalive: true,
- })
- .then(function (response) {
- if (!response.ok) {
- return null;
- }
- return response.json();
- })
- .then(function (data) {
- if (data && typeof data.unread !== 'undefined') {
- updateNotificationBadge(data.unread);
- }
- })
- .catch(function () {
- // noop
- });
- }
- function appendWsPopup(notification) {
- const container = document.getElementById('ws-notification-container');
- if (!container || !notification) {
- return;
- }
- const popup = document.createElement('div');
- popup.className = 'ws-notification-popup';
- popup.dataset.id = String(notification.id || '');
- const safeTitle = notification.title || 'Уведомление';
- const bodyHtml = notification.message_html || notification.message || '';
- popup.innerHTML =
- '<div class="ws-notification-header">' +
- '<strong>' + safeTitle + '</strong>' +
- '<button type="button" class="btn-close btn-sm ws-notification-close" aria-label="Закрыть"></button>' +
- '</div>' +
- '<div class="ws-notification-body">' + bodyHtml + '</div>';
- const closeBtn = popup.querySelector('.ws-notification-close');
- if (closeBtn) {
- closeBtn.addEventListener('click', function (event) {
- event.stopPropagation();
- markNotificationRead(notification.id);
- popup.remove();
- });
- }
- container.appendChild(popup);
- }
- cleanupStaleModalState();
- initQueuedUploads();
- initMobileDatePicker();
- window.addEventListener('pageshow', cleanupStaleModalState);
- if ($('.main-alert').length) {
- setTimeout(function () {
- $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
- $('.main-alert').slideUp(500);
- });
- }, 3000);
- }
- const user = localStorage.getItem('user');
- if (user > 0) {
- const socket = new WebSocket(localStorage.getItem('socketAddress'));
- socket.onopen = function () {
- console.log('[WS] Connected. Listen messages for user ' + user);
- };
- socket.onmessage = function (event) {
- const received = JSON.parse(event.data);
- if (parseInt(received.data.user_id, 10) !== parseInt(user, 10)) {
- return;
- }
- const action = received.data.action;
- if (action === 'persistent_notification') {
- incrementNotificationBadge();
- appendWsPopup(received.data.notification);
- return;
- }
- if (action !== 'message') {
- return;
- }
- if (received.data.payload.download) {
- document.location.href = '/storage/export/' + received.data.payload.download;
- }
- if (received.data.payload.link) {
- document.location.href = received.data.payload.link;
- setTimeout(function () {
- document.location.reload();
- }, 2000);
- }
- setTimeout(function () {
- if (received.data.payload.error) {
- $('.alerts').append('<div class="main-alert2 alert alert-danger" role="alert">' + received.data.message + '</div>');
- } else {
- $('.alerts').append('<div class="main-alert2 alert alert-success" role="alert">' + received.data.message + '</div>');
- }
- setTimeout(function () {
- $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () {
- $('.main-alert2').slideUp(500);
- });
- }, 3000);
- }, 1000);
- };
- socket.onclose = function (event) {
- if (event.wasClean) {
- console.log('[WS] Closed clear, code=' + event.code + ' reason=' + event.reason);
- } else {
- console.log('[WS] Connection lost', event);
- }
- };
- socket.onerror = function (error) {
- console.log('[error]', error);
- };
- }
- });
|