/** * JS раздела «Медиа»: файловый браузер, upload и простой crop-редактор. */ (function (window, document) { 'use strict'; var Adminx = window.Adminx || (window.Adminx = {}); function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); } function submitAction(url, data, onDone) { var form = document.getElementById('mediaActionForm'); var fd; if (!form) { return; } fd = new FormData(form); Object.keys(data || {}).forEach(function (key) { fd.set(key, data[key]); }); Adminx.Loader.show(); Adminx.Ajax.post(url, fd).then(function (payload) { Adminx.Loader.hide(); var d = payload.data || {}; if (d.success && typeof onDone === 'function') { onDone(d); } Adminx.Ajax.handle(payload); if (d.success && !d.redirect) { window.location.reload(); } }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); } Adminx.Media = { pendingPreview: '', init: function () { var self = this; this.bindUpload(); this.bindCrop(); this.bindLightbox(); this.bindLivePreview(); document.addEventListener('click', function (e) { var folder = e.target.closest('[data-media-folder]'); var rename = e.target.closest('[data-media-rename]'); var del = e.target.closest('[data-media-delete]'); var clearThumbs = e.target.closest('[data-media-clear-thumbs]'); var copy = e.target.closest('[data-copy]'); var webp = e.target.closest('[data-media-webp]'); var zoom = e.target.closest('[data-media-result-zoom]'); if (folder) { self.createFolder(); return; } if (rename) { self.rename(rename); return; } if (del) { self.remove(del); return; } if (clearThumbs) { self.clearThumbnails(clearThumbs); return; } if (webp) { self.convertWebp(webp); return; } if (zoom) { e.preventDefault(); self.openResultLarge(); return; } if (copy) { self.copy(copy.getAttribute('data-copy')); } }); }, base: function () { return Adminx.base(); }, modalInput: function (cfg, done) { var overlay = document.createElement('div'); overlay.className = 'overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); requestAnimationFrame(function () { overlay.classList.add('show'); }); var input = overlay.querySelector('[data-input]'); var close = function () { overlay.classList.remove('show'); setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 180); }; var apply = function () { var value = (input.value || '').trim(); close(); if (value) { done(value); } }; var onKey = function (e) { if (e.key === 'Escape') { close(); } if (e.key === 'Enter') { apply(); } }; overlay.addEventListener('click', function (e) { if (e.target === overlay || e.target.closest('[data-cancel]')) { close(); } if (e.target.closest('[data-ok]')) { apply(); } }); document.addEventListener('keydown', onKey); input.focus(); input.select(); }, createFolder: function () { var dir = document.querySelector('input[name="dir"]'); var self = this; this.modalInput({ title: 'Создать папку', message: 'Папка будет создана в текущем каталоге.', value: '', ok: 'Создать' }, function (name) { submitAction(self.base() + '/media/folders', { dir: dir ? dir.value : '/uploads', name: name }); }); }, rename: function (button) { var self = this; var kind = button.getAttribute('data-kind') === 'folder' ? 'папку' : 'файл'; this.modalInput({ title: 'Переименовать ' + kind, message: button.getAttribute('data-path') || '', value: button.getAttribute('data-name') || '', ok: 'Переименовать' }, function (name) { submitAction(self.base() + '/media/rename', { path: button.getAttribute('data-path') || '', name: name }); }); }, remove: function (button) { var self = this; var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; var kind = button.getAttribute('data-kind') === 'folder' ? 'папку и всё содержимое' : 'файл'; Adminx.Confirm.open({ kind: 'error', title: 'Удалить ' + kind + '?', message: '«' + name + '» будет удалён с диска.', confirmLabel: 'Удалить', confirmClass: 'btn-danger', onConfirm: function () { submitAction(self.base() + '/media/delete', { path: button.getAttribute('data-path') || '' }); } }); }, clearThumbnails: function (button) { var self = this; var name = button.getAttribute('data-name') || button.getAttribute('data-path') || ''; Adminx.Confirm.open({ kind: 'warning', title: 'Удалить превью в папке?', message: 'Будет удалена только служебная папка превью для «' + name + '». Исходные файлы останутся на месте.', confirmLabel: 'Удалить превью', confirmClass: 'btn-danger', onConfirm: function () { submitAction(self.base() + '/media/clear-thumbnails', { path: button.getAttribute('data-path') || '' }); } }); }, copy: function (text) { if (!text) { return; } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { Adminx.Toast.show('Путь скопирован', 'success'); }); } else { Adminx.Toast.show(text, 'info'); } }, bindUpload: function () { var form = document.getElementById('mediaUploadForm'); if (!form) { return; } var input = form.querySelector('input[type="file"]'); var drop = form.querySelector('[data-media-drop]'); var trigger = document.querySelector('[data-media-upload-trigger]'); var status = form.querySelector('[data-media-upload-status]'); var self = this; var depth = 0; function setStatus(text, cls) { if (!status) { return; } status.textContent = text || ''; status.className = cls ? 'is-' + cls : ''; } function upload(files) { var fd; var i; if (!files || !files.length) { setStatus('Файлы не выбраны', 'error'); return; } fd = new FormData(form); fd.delete('files[]'); for (i = 0; i < files.length; i++) { fd.append('files[]', files[i]); } form.classList.add('is-loading'); setStatus('Загрузка: ' + files.length, 'progress'); Adminx.Ajax.post(self.base() + '/media/upload', fd).then(function (payload) { form.classList.remove('is-loading'); var d = payload.data || {}; if (d.success) { setStatus(d.message || 'Готово', 'success'); Adminx.Ajax.handle(payload); } else { setStatus(d.message || 'Ошибка загрузки', 'error'); Adminx.Toast.show(d.message || 'Ошибка загрузки', 'error'); } }).catch(function () { form.classList.remove('is-loading'); setStatus('Ошибка сети', 'error'); }); } if (trigger) { trigger.addEventListener('click', function () { input.click(); }); } drop.addEventListener('click', function () { input.click(); }); input.addEventListener('change', function () { upload(input.files); }); drop.addEventListener('dragenter', function (e) { e.preventDefault(); depth++; drop.classList.add('is-dragover'); setStatus('Отпустите файлы', 'progress'); }); drop.addEventListener('dragover', function (e) { e.preventDefault(); drop.classList.add('is-dragover'); }); drop.addEventListener('dragleave', function (e) { e.preventDefault(); depth = Math.max(0, depth - 1); if (depth === 0) { drop.classList.remove('is-dragover'); } }); drop.addEventListener('drop', function (e) { e.preventDefault(); depth = 0; drop.classList.remove('is-dragover'); upload(e.dataTransfer ? e.dataTransfer.files : null); }); }, bindLightbox: function () { var self = this; var modal = document.querySelector('[data-media-lightbox]'); if (!modal) { return; } this.lightbox = { modal: modal, image: modal.querySelector('[data-media-lightbox-image]'), title: modal.querySelector('[data-media-lightbox-title]'), meta: modal.querySelector('[data-media-lightbox-meta]') }; document.addEventListener('click', function (e) { var trigger = e.target.closest('[data-media-preview]'); if (trigger) { e.preventDefault(); e.stopPropagation(); self.openLightbox( trigger.getAttribute('data-media-preview') || '', trigger.getAttribute('data-media-preview-name') || 'Предпросмотр', trigger.getAttribute('data-media-preview-meta') || '' ); return; } if (e.target === modal || e.target.closest('[data-media-lightbox-close]')) { self.closeLightbox(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !modal.hasAttribute('hidden')) { self.closeLightbox(); } }); // Уходя со страницы с открытым превью — подчистить временный файл. window.addEventListener('pagehide', function () { self.clearPreview(true); }); }, openLightbox: function (url, title, meta) { var lb = this.lightbox; if (!lb) { return; } if (lb.image) { lb.image.src = url || ''; lb.image.alt = title || ''; } if (lb.title) { lb.title.textContent = title || 'Предпросмотр'; } if (lb.meta) { lb.meta.textContent = meta || ''; } lb.modal.removeAttribute('hidden'); document.documentElement.classList.add('media-lightbox-open'); }, closeLightbox: function () { var lb = this.lightbox; if (!lb) { return; } lb.modal.setAttribute('hidden', 'hidden'); if (lb.image) { lb.image.src = ''; } document.documentElement.classList.remove('media-lightbox-open'); }, // Встроенный «Результат»: живой серверный рендер по настройкам + выделению. bindLivePreview: function () { var self = this; var form = document.querySelector('.media-transform-form'); var result = document.querySelector('[data-media-result]'); if (!form || !result) { return; } this.result = { img: result.querySelector('[data-media-result-img]'), meta: result.querySelector('[data-media-result-meta]'), empty: result.querySelector('[data-media-result-empty]') }; var timer = null; var onChange = function () { clearTimeout(timer); timer = setTimeout(function () { self.refreshResult(); }, 300); }; form.addEventListener('change', onChange); form.addEventListener('input', onChange); // Первый рендер результата при открытии страницы. setTimeout(function () { self.refreshResult(); }, 60); }, // Сгенерировать серверный результат по текущим настройкам и показать во встроенном блоке. refreshResult: function () { var self = this; var form = document.querySelector('.media-transform-form'); if (!form || !this.result) { return; } var reqId = (this.resultReq = (this.resultReq || 0) + 1); Adminx.Ajax.post(this.base() + '/media/preview', new FormData(form)).then(function (payload) { if (reqId !== self.resultReq) { return; } // пришёл ответ на устаревший запрос var d = payload.data || {}; if (!d.success) { return; } var data = d.data || {}; self.clearPreview(false); self.pendingPreview = data.path || ''; self.resultUrl = (data.url || '') + '?t=' + Date.now(); self.resultMeta = data.meta || ''; if (self.result.img) { self.result.img.src = self.resultUrl; self.result.img.style.display = ''; } if (self.result.empty) { self.result.empty.style.display = 'none'; } if (self.result.meta) { self.result.meta.textContent = data.meta || ''; } // Если открыт крупный просмотр — обновить и его. if (self.lightbox && !self.lightbox.modal.hasAttribute('hidden') && self.lightbox.image) { self.lightbox.image.src = self.resultUrl; if (self.lightbox.meta) { self.lightbox.meta.textContent = data.meta || ''; } } }).catch(function () {}); }, // Открыть текущий результат крупно (лупа/кнопка «Крупно»). openResultLarge: function () { if (this.resultUrl) { this.openLightbox(this.resultUrl, 'Результат по настройкам', this.resultMeta || ''); } else { this.refreshResult(); Adminx.Toast.show('Готовлю результат…', 'info'); } }, // Удалить временный превью-файл (при закрытии окна или уходе со страницы). clearPreview: function (useBeacon) { var path = this.pendingPreview; if (!path) { return; } this.pendingPreview = ''; var url = this.base() + '/media/preview-clear'; var fd = new FormData(); fd.append('_csrf', Adminx.csrf()); fd.append('path', path); if (useBeacon && navigator.sendBeacon) { navigator.sendBeacon(url, fd); return; } Adminx.Ajax.post(url, fd).catch(function () {}); }, convertWebp: function (button) { var form = button.closest('form'); var fd; if (!form) { return; } Adminx.Confirm.open({ kind: 'info', title: 'Создать WebP-копию?', message: 'Будет создан соседний WebP-файл с тем же именем исходника.', confirmLabel: 'Создать WebP', confirmClass: 'btn-primary', onConfirm: function () { fd = new FormData(); fd.append('_csrf', Adminx.csrf()); fd.append('path', button.getAttribute('data-path') || ''); fd.append('quality', form.querySelector('[name="quality"]') ? form.querySelector('[name="quality"]').value : '82'); Adminx.Loader.show(); Adminx.Ajax.post(Adminx.Media.base() + '/media/convert-webp', fd).then(function (payload) { Adminx.Loader.hide(); Adminx.Ajax.handle(payload); }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); } }); }, bindCrop: function () { var root = document.querySelector('[data-media-editor]'); if (!root) { return; } var img = root.querySelector('[data-crop-image]'); var frame = root.querySelector('[data-crop-frame]'); var box = root.querySelector('[data-crop-box]'); var form = root.querySelector('.media-transform-form'); if (!img || !frame || !box || !form) { return; } var state = { x: 12, y: 12, w: 76, h: 76, drag: null, sx: 0, sy: 0 }; var sizeBadge = box.querySelector('[data-crop-size]'); var ratioSelect = form.querySelector('[data-crop-ratio]'); var inputs = { enabled: form.querySelector('[data-crop-enabled]'), x: form.querySelector('[data-crop-input="x"]'), y: form.querySelector('[data-crop-input="y"]'), w: form.querySelector('[data-crop-input="w"]'), h: form.querySelector('[data-crop-input="h"]'), outputW: form.querySelector('[name="width"]'), outputH: form.querySelector('[name="height"]') }; function aspectLabel(w, h) { function gcd(a, b) { return b ? gcd(b, a % b) : a; } var g = gcd(w, h) || 1; var rw = Math.round(w / g); var rh = Math.round(h / g); if (rw <= 40 && rh <= 40) { return rw + ':' + rh; } return (w / Math.max(1, h)).toFixed(2) + ':1'; } function sync(syncOutputSize) { var rect = img.getBoundingClientRect(); var naturalW = img.naturalWidth || 1; var naturalH = img.naturalHeight || 1; var full = state.x === 0 && state.y === 0 && state.w === 100 && state.h === 100; var px = Math.round((state.x / 100) * naturalW); var py = Math.round((state.y / 100) * naturalH); var pw = Math.max(1, Math.round((state.w / 100) * naturalW)); var ph = Math.max(1, Math.round((state.h / 100) * naturalH)); box.style.left = state.x + '%'; box.style.top = state.y + '%'; box.style.width = state.w + '%'; box.style.height = state.h + '%'; if (inputs.enabled) { inputs.enabled.value = full ? '0' : '1'; } if (inputs.x) { inputs.x.value = px; } if (inputs.y) { inputs.y.value = py; } if (inputs.w) { inputs.w.value = pw; } if (inputs.h) { inputs.h.value = ph; } if (syncOutputSize && inputs.outputW && inputs.outputH) { var outputScale = Math.min(1, 2400 / pw, 2400 / ph); inputs.outputW.value = Math.max(1, Math.round(pw * outputScale)); inputs.outputH.value = Math.max(1, Math.round(ph * outputScale)); } if (sizeBadge) { sizeBadge.textContent = (full ? 'Весь кадр · ' : 'Выделение · ') + pw + '×' + ph + ' px · ' + aspectLabel(pw, ph); } // Кроп-инпуты меняются программно — уведомим живое превью вручную. if (inputs.w) { inputs.w.dispatchEvent(new Event('input', { bubbles: true })); } return rect; } function clamp(preserveSize) { if (preserveSize) { state.w = Math.max(1, Math.min(100, state.w)); state.h = Math.max(1, Math.min(100, state.h)); state.x = Math.max(0, Math.min(100 - state.w, state.x)); state.y = Math.max(0, Math.min(100 - state.h, state.y)); return; } state.x = Math.max(0, Math.min(99, state.x)); state.y = Math.max(0, Math.min(99, state.y)); state.w = Math.max(1, Math.min(100 - state.x, state.w)); state.h = Math.max(1, Math.min(100 - state.y, state.h)); } function ratio(value) { if (!value || value === 'free' || value === 'original') { return 0; } var p = value.split(':'); return p.length === 2 ? (parseFloat(p[0]) / parseFloat(p[1])) : 0; } function setRatio(value) { var r = ratio(value); var rect = img.getBoundingClientRect(); var imageRatio = rect.height > 0 ? rect.width / rect.height : ((img.naturalWidth || 1) / (img.naturalHeight || 1)); if (!r || !imageRatio) { state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 }; } else if (r >= imageRatio) { state.w = 76; state.h = (state.w * imageRatio) / r; state.x = 12; state.y = (100 - state.h) / 2; } else { state.h = 76; state.w = (state.h * r) / imageRatio; state.y = 12; state.x = (100 - state.w) / 2; } clamp(); sync(true); } box.addEventListener('mousedown', function (e) { var handle = e.target.getAttribute('data-crop-handle'); state.drag = handle || 'move'; state.sx = e.clientX; state.sy = e.clientY; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { var rect; var dx; var dy; if (!state.drag) { return; } rect = img.getBoundingClientRect(); dx = ((e.clientX - state.sx) / rect.width) * 100; dy = ((e.clientY - state.sy) / rect.height) * 100; state.sx = e.clientX; state.sy = e.clientY; if (state.drag === 'move') { state.x += dx; state.y += dy; clamp(true); sync(false); } else { if (state.drag.indexOf('e') !== -1) { state.w += dx; } if (state.drag.indexOf('s') !== -1) { state.h += dy; } if (state.drag.indexOf('w') !== -1) { state.x += dx; state.w -= dx; } if (state.drag.indexOf('n') !== -1) { state.y += dy; state.h -= dy; } if (ratioSelect) { ratioSelect.value = 'free'; } clamp(false); sync(true); } }); document.addEventListener('mouseup', function () { state.drag = null; }); if (ratioSelect) { ratioSelect.addEventListener('change', function (e) { setRatio(e.target.value); }); } form.querySelector('[data-crop-reset]').addEventListener('click', function () { state = { x: 0, y: 0, w: 100, h: 100, drag: null, sx: 0, sy: 0 }; sync(true); }); form.addEventListener('submit', function (e) { e.preventDefault(); Adminx.Loader.show(); Adminx.Ajax.post(form.action, new FormData(form)).then(function (payload) { Adminx.Loader.hide(); Adminx.Ajax.handle(payload); }).catch(function () { Adminx.Loader.hide(); Adminx.Toast.show('Ошибка сети', 'error'); }); }); window.addEventListener('resize', sync); if (img.complete) { sync(); } else { img.addEventListener('load', sync); } } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { Adminx.Media.init(); }); } else { Adminx.Media.init(); } })(window, document);