/**
* 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);