';
target.hidden = false;
function selfEsc(value) {
var node = document.createElement('span');
node.textContent = String(value || '');
return node.innerHTML;
}
},
copyPayload: function () {
var code = document.querySelector('[data-document-payload-code]');
var value = code ? code.textContent : '';
if (!value) { return; }
var done = function () { Adminx.Toast.show('JSON скопирован', 'success'); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).then(done);
return;
}
var textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
done();
},
syncEditors: function () {
if (!this.form) { return; }
this.form.querySelectorAll('textarea[data-code-editor]').forEach(function (textarea) {
if (textarea._adminxCodeMirror) { textarea._adminxCodeMirror.save(); }
});
this.form.querySelectorAll('textarea[data-rich-editor]').forEach(function (textarea) {
if (textarea._adminxTiptap && textarea.getAttribute('data-rich-editor-dirty') === '1') {
textarea.value = textarea._adminxTiptap.getHTML();
}
});
},
remove: function (row) {
var self = this;
if (!row) { return; }
if (Adminx.Confirm) {
Adminx.Confirm.open({
kind: 'error',
title: 'Удалить документ?',
message: 'Документ будет помечен удалённым и останется доступен для восстановления.',
confirmLabel: 'Удалить',
confirmClass: 'btn-danger',
onConfirm: function () { self.action(row, '/delete'); }
});
return;
}
if (confirm('Пометить документ удалённым?')) { this.action(row, '/delete'); }
},
restore: function (row) {
if (!row) { return; }
this.action(row, '/restore');
},
copy: function (row) {
if (!row) { return; }
var self = this;
Adminx.Confirm.open({
kind: 'info',
title: 'Создать копию документа?',
message: 'Будет создан новый черновик со всеми значениями полей.',
confirmLabel: 'Создать копию',
onConfirm: function () {
var id = row.getAttribute('data-id');
var data = new FormData();
data.append('_csrf', self.csrf());
self.ajax(self.base() + '/documents/' + encodeURIComponent(id) + '/copy', data, function (payload) {
Adminx.Toast.show(payload.message || 'Копия создана', 'success');
window.location.href = payload.redirect || (self.base() + '/documents/' + ((payload.data || {}).id || '') + '/edit');
});
}
});
},
purge: function (row) {
if (!row) { return; }
var self = this;
Adminx.Confirm.open({
kind: 'danger',
title: 'Удалить документ окончательно?',
message: 'Будут удалены документ, значения полей, ревизии, редиректы, заметки и статистика. Действие необратимо.',
confirmLabel: 'Удалить окончательно',
onConfirm: function () { self.action(row, '/purge'); }
});
},
action: function (row, suffix) {
var id = row.getAttribute('data-id');
var data = new FormData();
data.append('_csrf', this.csrf());
var self = this;
this.ajax(this.base() + '/documents/' + encodeURIComponent(id) + suffix, data, function (payload) {
Adminx.Toast.show(payload.message || 'Готово', 'success');
self.applyFilterUrl(window.location.href, false);
});
},
updateBulk: function () {
var selected = document.querySelectorAll('[data-document-check]:checked');
var bar = document.querySelector('[data-documents-bulk]');
var count = document.querySelector('[data-documents-selected]');
var all = document.querySelector('[data-documents-check-all]');
var total = document.querySelectorAll('[data-document-check]').length;
if (bar) {
bar.hidden = selected.length === 0;
bar.classList.toggle('visible', selected.length > 0);
}
if (count) { count.textContent = selected.length; }
if (all) { all.checked = total > 0 && selected.length === total; all.indeterminate = selected.length > 0 && selected.length < total; }
},
clearBulk: function () {
document.querySelectorAll('[data-document-check], [data-documents-check-all]').forEach(function (box) { box.checked = false; box.indeterminate = false; });
this.updateBulk();
},
applyBulk: function () {
var action = document.querySelector('[data-documents-bulk-action]');
var ids = Array.prototype.map.call(document.querySelectorAll('[data-document-check]:checked'), function (box) { return box.value; });
if (!action || !action.value || !ids.length) { Adminx.Toast.show('Выберите документы и действие', 'warning'); return; }
var self = this;
var labels = {publish:'опубликовать',unpublish:'снять с публикации',delete:'переместить в корзину',restore:'восстановить',purge:'удалить окончательно'};
Adminx.Confirm.open({kind:action.value==='purge'?'danger':'warning',title:'Выполнить пакетное действие?',message:'Документов: '+ids.length+'. Действие: '+labels[action.value]+'.',confirmLabel:'Применить',onConfirm:function(){var data=new FormData();data.append('_csrf',self.csrf());data.append('action',action.value);ids.forEach(function(id){data.append('ids[]',id);});self.ajax(self.base()+'/documents/bulk',data,function(payload){var result=payload.data||{};Adminx.Toast.show((payload.message||'Готово')+' · обработано '+(result.done||0),result.errors&&result.errors.length?'warning':'success');self.applyFilterUrl(window.location.href,false);});}});
},
rebuildSnapshots: function () {
var self = this;
var rubric = document.querySelector('.documents-filter [name="rubric_id"]');
var rubricId = rubric ? (parseInt(rubric.value, 10) || 0) : 0;
var scope = rubricId ? ('выбранной рубрики #' + rubricId) : 'всех рабочих документов';
Adminx.Confirm.open({
kind: 'warning',
title: 'Пересобрать JSON-снимки?',
message: 'Снимки будут последовательно пересобраны для ' + scope + '. Окно нужно оставить открытым до завершения.',
confirmLabel: 'Пересобрать',
onConfirm: function () { self.runSnapshotBatch(rubricId, 0, 0, []); }
});
},
runSnapshotBatch: function (rubricId, after, total, failures) {
var self = this;
var data = new FormData();
data.append('_csrf', this.csrf());
data.append('rubric_id', rubricId || 0);
data.append('after', after || 0);
data.append('limit', 50);
Adminx.Loader.show();
fetch(this.base() + '/documents/snapshots/rebuild', { method: 'POST', body: data, headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) {
if (!payload.success) { throw payload; }
var result = payload.data || {};
total += parseInt(result.done, 10) || 0;
failures = failures.concat(result.failed || []);
if (result.has_more) {
self.runSnapshotBatch(rubricId, parseInt(result.next_after, 10) || after, total, failures);
return;
}
Adminx.Loader.hide();
Adminx.Toast.show('JSON-снимки пересобраны: ' + total + (failures.length ? ', ошибок: ' + failures.length : ''), failures.length ? 'warning' : 'success');
})
.catch(function (payload) {
Adminx.Loader.hide();
Adminx.Toast.show((payload && payload.message) || 'Пакетная пересборка остановлена', 'error');
});
},
openSnapshot: function () {
if (!this.form) { return; }
var idField = this.field('id');
var id = idField ? (parseInt(idField.value, 10) || 0) : 0;
if (!id) { return; }
if (Adminx.Drawer) { Adminx.Drawer.open('documentSnapshotDrawer'); }
this.loadSnapshot(id);
},
loadSnapshot: function (id) {
var self = this;
var target = document.querySelector('[data-document-snapshot-status]');
if (target) { target.innerHTML = '
Состояние
Загрузка...
'; }
fetch(this.base() + '/documents/' + id + '/snapshot', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) { self.renderSnapshot(payload.data || {}); })
.catch(function (payload) { Adminx.Toast.show((payload && payload.message) || 'Не удалось получить статус снимка', 'error'); });
},
renderSnapshot: function (status) {
var target = document.querySelector('[data-document-snapshot-status]');
if (!target) { return; }
var state = status.current ? 'актуален' : (status.exists ? 'нужна пересборка' : 'не создан');
target.innerHTML = '
Состояние
' + state + '
'
+ '
Сформирован
' + this.esc(status.generated_label || 'ещё не создан') + '
'
+ '
Поля
' + this.esc(status.fields_count || 0) + '
'
+ '
Размер
' + this.esc(status.size_label || '0 Б') + '
'
+ '
Файл
' + this.esc(status.path || '') + '
';
},
rebuildSnapshot: function () {
if (!this.form) { return; }
var idField = this.field('id');
var id = idField ? (parseInt(idField.value, 10) || 0) : 0;
if (!id) { return; }
var data = new FormData();
data.append('_csrf', this.csrf());
var self = this;
this.ajax(this.base() + '/documents/' + id + '/snapshot/rebuild', data, function (payload) {
self.renderSnapshot(payload.data || {});
Adminx.Toast.show(payload.message || 'JSON-снимок пересобран', 'success');
});
},
ajax: function (url, data, done, fail) {
Adminx.Loader.show();
fetch(url, { method: 'POST', body: data, headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) {
if (!payload.success) { throw payload; }
if (done) { done(payload); }
})
.catch(function (payload) {
var handled = fail ? fail(payload || {}) === false : false;
if (handled) { return; }
Adminx.Toast.show((payload && payload.message) || 'Ошибка запроса', 'error');
})
.finally(function () { Adminx.Loader.hide(); });
},
showEditConflict: function (payload) {
var data = payload.data || {};
var reload = function () { window.location.href = data.reload_url || window.location.href; };
if (window.Adminx && Adminx.Confirm && typeof Adminx.Confirm.open === 'function') {
Adminx.Confirm.open({
kind: 'warning',
title: 'Документ изменился',
message: 'На сервере уже сохранена более новая версия. Обновите страницу, проверьте изменения и сохраните документ повторно.',
confirmLabel: 'Обновить страницу',
cancelLabel: 'Остаться',
onConfirm: reload
});
return;
}
Adminx.Toast.show(payload.message || 'Документ уже изменён. Обновите страницу.', 'warning');
},
json: function (res) {
return res.text().then(function (raw) {
var payload = {};
try {
payload = raw ? JSON.parse(raw) : {};
} catch (e) {
payload = {
success: false,
message: res.status >= 500
? 'Внутренняя ошибка сервера. Обновите страницу и повторите попытку.'
: 'Сервер вернул некорректный ответ.'
};
}
if (!res.ok || payload.success === false) { throw payload; }
return payload;
});
},
csrf: function () {
var field = this.form ? this.field('_csrf') : document.querySelector('[data-documents-csrf]');
return field ? field.value : '';
},
issueApiToken: function (form) {
if (!form || form.getAttribute('aria-busy') === 'true') { return; }
var scopes = form.querySelectorAll('[name="scopes[]"]:checked');
if (!scopes.length) {
Adminx.Toast.show('Выберите хотя бы одно разрешение', 'warning');
return;
}
var self = this;
var submit = form.querySelector('[data-api-token-submit]');
form.setAttribute('aria-busy', 'true');
if (submit) { submit.disabled = true; }
var data = new FormData(form);
data.append('_csrf', this.csrf());
this.ajax((form.getAttribute('data-base') || this.base()) + '/documents/api/tokens', data, function (payload) {
var secret = form.querySelector('[data-api-token-secret]');
var fields = form.querySelector('[data-api-token-fields]');
var value = form.querySelector('[data-api-token-value]');
var subtitle = document.querySelector('[data-api-token-subtitle]');
if (value) { value.value = payload.data && payload.data.token ? payload.data.token : ''; }
if (fields) { fields.hidden = true; }
if (secret) { secret.hidden = false; }
if (submit) { submit.hidden = true; }
if (subtitle) { subtitle.textContent = 'Секрет готов к копированию.'; }
form.removeAttribute('aria-busy');
form.closest('.drawer').setAttribute('data-token-created', '1');
Adminx.Toast.show(payload.message || 'API-токен создан', 'success');
window.setTimeout(function () { if (value) { value.focus(); value.select(); } }, 30);
}, function () {
form.removeAttribute('aria-busy');
if (submit) { submit.disabled = false; }
});
},
copyApiToken: function () {
var value = document.querySelector('[data-api-token-value]');
var token = value ? value.value : '';
if (!token) { return; }
var label = document.querySelector('[data-api-token-copy-label]');
var done = function () {
if (label) { label.textContent = 'Скопировано'; }
Adminx.Toast.show('Токен скопирован', 'success');
window.setTimeout(function () { if (label) { label.textContent = 'Копировать'; } }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(token).then(done).catch(function () {
value.focus(); value.select(); document.execCommand('copy'); done();
});
return;
}
value.focus(); value.select(); document.execCommand('copy'); done();
},
revokeApiToken: function (row) {
if (!row) { return; }
var self = this;
var id = parseInt(row.getAttribute('data-id'), 10) || 0;
if (!id) { return; }
Adminx.Confirm.open({
kind: 'danger',
title: 'Отозвать API-токен?',
message: 'Интеграция сразу потеряет доступ. Вернуть этот токен будет невозможно.',
confirmLabel: 'Отозвать',
onConfirm: function () {
var data = new FormData();
data.append('_csrf', self.csrf());
self.ajax(self.base() + '/documents/api/tokens/' + id + '/revoke', data, function (payload) {
var state = row.querySelector('[data-api-token-state]');
var button = row.querySelector('[data-api-token-revoke]');
if (state) { state.className = 'badge badge-gray'; state.textContent = 'Отозван'; }
if (button) { button.remove(); }
row.classList.add('is-revoked');
Adminx.Toast.show(payload.message || 'API-токен отозван', 'success');
});
}
});
},
resetApiTokenForm: function () {
var form = document.querySelector('[data-api-token-form]');
if (!form) { return; }
form.reset();
form.removeAttribute('aria-busy');
var fields = form.querySelector('[data-api-token-fields]');
var secret = form.querySelector('[data-api-token-secret]');
var value = form.querySelector('[data-api-token-value]');
var submit = form.querySelector('[data-api-token-submit]');
var subtitle = document.querySelector('[data-api-token-subtitle]');
if (fields) { fields.hidden = false; }
if (secret) { secret.hidden = true; }
if (value) { value.value = ''; }
if (submit) { submit.hidden = false; submit.disabled = false; }
if (subtitle) { subtitle.textContent = 'Права доступа для одной внешней интеграции.'; }
},
clearErrors: function () {
if (!this.form) { return; }
this.form.querySelectorAll('.field-error').forEach(function (el) { el.textContent = ''; });
this.form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); });
this.errorTargets = [];
var summary = this.form.querySelector('[data-document-error-summary]');
var list = summary ? summary.querySelector('[data-document-error-list]') : null;
if (list) { list.innerHTML = ''; }
if (summary) { summary.hidden = true; }
},
showErrors: function (errors) {
var self = this;
var jumpPanel = null;
var summaryItems = [];
Object.keys(errors || {}).forEach(function (key) {
var fieldMatch = key.match(/^fields(?:\[(\d+)\]|\.(\d+))$/);
var target = null;
var label = key;
if (fieldMatch) {
var fieldId = fieldMatch[1] || fieldMatch[2];
var container = self.form.querySelector('.ax-document-field[data-rubric-field-id="' + fieldId + '"]');
if (container) {
container.classList.add('is-invalid');
target = container;
var fieldLabel = container.querySelector('.ax-label');
if (fieldLabel) { label = fieldLabel.textContent.replace('*', '').trim(); }
var err = container.querySelector('[data-field-value-error]');
if (!err) {
err = document.createElement('div');
err.className = 'field-error';
err.setAttribute('data-field-value-error', '');
(container.querySelector('.ax-document-field-body') || container).appendChild(err);
}
err.textContent = errors[key];
var panel = container.closest('[data-document-field-panel]');
if (panel && jumpPanel === null) { jumpPanel = panel.getAttribute('data-document-field-panel'); }
}
if (target) { summaryItems.push({ key: key, label: label, message: String(errors[key]), target: target }); }
return;
}
var error = self.form.querySelector('[data-error="' + key + '"]');
var field = self.field(key);
if (error) { error.textContent = errors[key]; }
if (field) { field.classList.add('is-invalid'); }
target = field || error;
if (field) {
var fieldRoot = field.closest('.field');
var directLabel = fieldRoot ? fieldRoot.querySelector('.field-label') : null;
if (directLabel) { label = directLabel.textContent.replace(/\d+\s*\/\s*\d+$/, '').trim(); }
}
if (target) { summaryItems.push({ key: key, label: label, message: String(errors[key]), target: target }); }
});
this.renderErrorSummary(summaryItems);
// если ошибка в поле скрытой группы — переключаемся на её вкладку
if (jumpPanel !== null) { self.activateFieldGroup(jumpPanel); }
var firstInvalid = self.form.querySelector('.is-invalid');
if (firstInvalid) {
window.setTimeout(function () {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
var input = firstInvalid.matches('input,select,textarea') ? firstInvalid : firstInvalid.querySelector('input,select,textarea,[contenteditable="true"]');
if (input && typeof input.focus === 'function') { input.focus({ preventScroll: true }); }
}, 80);
}
},
renderErrorSummary: function (items) {
var summary = this.form ? this.form.querySelector('[data-document-error-summary]') : null;
var list = summary ? summary.querySelector('[data-document-error-list]') : null;
var title = summary ? summary.querySelector('[data-document-error-title]') : null;
if (!summary || !list || !items.length) { return; }
this.errorTargets = items.map(function (item) { return item.target; });
list.innerHTML = '';
items.forEach(function (item, index) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'documents-error-jump';
button.setAttribute('data-document-error-jump', String(index));
var name = document.createElement('b');
name.textContent = item.label;
var message = document.createElement('span');
message.textContent = item.message;
var icon = document.createElement('i');
icon.className = 'ti ti-arrow-up-right';
button.appendChild(name);
button.appendChild(message);
button.appendChild(icon);
list.appendChild(button);
});
if (title) { title.textContent = 'Найдено ошибок: ' + items.length; }
summary.hidden = false;
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
jumpToError: function (index) {
var target = this.errorTargets[parseInt(index, 10) || 0];
if (!target) { return; }
var level = target.closest('[data-editor-level]');
if (level) {
var required = level.getAttribute('data-editor-level');
if (required === 'advanced') { this.setEditorMode('advanced', true); }
else if (required === 'normal' && this.editorMode === 'quick') { this.setEditorMode('normal', true); }
}
var panel = target.closest('[data-document-field-panel]');
if (panel) { this.activateFieldGroup(panel.getAttribute('data-document-field-panel')); }
window.setTimeout(function () {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
var input = target.matches('input,select,textarea') ? target : target.querySelector('input,select,textarea,[contenteditable="true"]');
if (input && typeof input.focus === 'function') { input.focus({ preventScroll: true }); }
}, 90);
},
scheduleAliasCheck: function () {
var self = this;
clearTimeout(this.aliasTimer);
this.setAliasState('', 'Проверка...');
this.aliasTimer = setTimeout(function () { self.checkAlias(false); }, 350);
},
checkAlias: function (required, done) {
if (!this.form) { if (done) { done(true); } return; }
var alias = this.field('document_alias').value.trim();
var id = this.field('id').value || 0;
var self = this;
if (!alias) {
this.setAliasState('empty', 'Alias можно оставить пустым');
if (done) { done(true); }
return;
}
fetch(this.base() + '/documents/alias-check?alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) {
var data = payload.data || {};
self.setAliasState(data.state || (data.ok ? 'ok' : 'error'), data.message || payload.message || '');
if (done) { done(data.ok !== false); }
})
.catch(function () {
self.setAliasState('error', 'Не удалось проверить alias');
if (done) { done(!required); }
});
},
setAliasState: function (state, message) {
var el = document.querySelector('[data-document-alias-state]');
this.aliasState = state || '';
if (!el) { return; }
el.textContent = message || '';
el.classList.toggle('is-ok', state === 'ok');
el.classList.toggle('is-error', state === 'error');
},
scheduleShortAliasCheck: function () {
var self = this;
clearTimeout(this.shortAliasTimer);
this.setShortAliasState('', 'Проверка...');
this.shortAliasTimer = setTimeout(function () { self.checkShortAlias(false); }, 350);
},
checkShortAlias: function (required, done) {
if (!this.form) { if (done) { done(true); } return; }
var input = this.field('document_short_alias');
var alias = input ? input.value.trim() : '';
var id = this.field('id').value || 0;
var self = this;
if (!alias) {
this.setShortAliasState('empty', 'До 10 символов, необязательно.');
if (done) { done(true); }
return;
}
fetch(this.base() + '/documents/alias-check?kind=short&alias=' + encodeURIComponent(alias) + '&id=' + encodeURIComponent(id), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) {
var data = payload.data || {};
self.setShortAliasState(data.state || (data.ok ? 'ok' : 'error'), data.message || payload.message || '');
if (done) { done(data.ok !== false); }
})
.catch(function () {
self.setShortAliasState('error', 'Не удалось проверить короткий alias');
if (done) { done(!required); }
});
},
setShortAliasState: function (state, message) {
var el = document.querySelector('[data-document-short-alias-state]');
if (!el) { return; }
el.textContent = message || '';
el.classList.toggle('is-ok', state === 'ok');
el.classList.toggle('is-error', state === 'error');
},
slugify: function (value) {
var map = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i',
'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t',
'у':'u','ф':'f','х':'h','ц':'c','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'',
'э':'e','ю':'yu','я':'ya'
};
return String(value || '').toLowerCase().replace(/[а-яё]/g, function (ch) { return map[ch] !== undefined ? map[ch] : ch; })
.replace(/[^a-z0-9]+/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
},
rubricAliasTemplate: function () {
var select = this.field('rubric_id');
if (!select || !select.options || select.selectedIndex < 0) { return ''; }
return String(select.options[select.selectedIndex].getAttribute('data-alias-template') || '').replace(/^\/+|\/+$/g, '');
},
expandAliasTemplate: function (pattern) {
var published = this.field('document_published');
var value = published && published.value ? new Date(published.value) : new Date();
if (isNaN(value.getTime())) { value = new Date(); }
var pad = function (part) { return String(part).padStart(2, '0'); };
var replacements = {
'%d': pad(value.getDate()), '%m': pad(value.getMonth() + 1), '%Y': String(value.getFullYear()),
'%y': String(value.getFullYear()).slice(-2), '%H': pad(value.getHours()), '%M': pad(value.getMinutes()), '%S': pad(value.getSeconds())
};
pattern = String(pattern || '');
Object.keys(replacements).forEach(function (token) { pattern = pattern.split(token).join(replacements[token]); });
return pattern.replace(/^\/+|\/+$/g, '');
},
composeRubricAlias: function (leaf) {
leaf = String(leaf || '').replace(/^\/+|\/+$/g, '');
var prefix = this.expandAliasTemplate(this.rubricAliasTemplate());
return prefix ? prefix + '/' + leaf : leaf;
},
updateAliasTemplateHint: function () {
var hint = document.querySelector('[data-document-alias-template-hint]');
if (!hint) { return; }
var pattern = this.rubricAliasTemplate();
hint.textContent = pattern
? 'Шаблон рубрики: /' + pattern + '/ + alias документа. Дата берётся из публикации.'
: 'У рубрики нет шаблона пути: alias документа используется от корня сайта.';
},
syncSlugFromTitle: function () {
if (this.aliasTouched) { return; }
var title = this.field('document_title');
var alias = this.field('document_alias');
if (!title || !alias) { return; }
var leaf = this.slugify(title.value);
alias.value = this.composeRubricAlias(leaf);
this.updateAliasTemplateHint();
this.scheduleAliasCheck();
},
generateSlug: function (button) {
var self = this;
var title = this.field('document_title');
var alias = this.field('document_alias');
if (!title || !alias) { return; }
var parent = this.field('document_parent');
var body = new FormData();
body.append('title', title.value || '');
body.append('id', button.getAttribute('data-document-id') || '0');
body.append('parent', parent ? (parent.value || '0') : '0');
var rubric = this.field('rubric_id');
var published = this.field('document_published');
body.append('rubric_id', rubric ? (rubric.value || '0') : '0');
body.append('published', published ? (published.value || '') : '');
body.append('_csrf', this.csrf());
fetch(button.getAttribute('data-slug-generate'), {
method: 'POST', body: body,
headers: { 'Accept': 'application/json', 'X-CSRF-Token': this.csrf() },
credentials: 'same-origin'
})
.then(this.json)
.then(function (payload) {
var data = payload.data || {};
if (data.alias) {
alias.value = data.alias;
self.aliasTouched = true;
self.checkAlias(false);
self.updateSeo();
} else if (payload.message && window.Adminx && Adminx.toast) {
Adminx.toast(payload.message, 'error');
}
})
.catch(function () {
if (window.Adminx && Adminx.toast) { Adminx.toast('Не удалось сгенерировать alias', 'error'); }
});
},
generateShortAlias: function (button) {
var self = this;
var input = this.field('document_short_alias');
if (!input) { return; }
var body = new FormData();
body.append('id', button.getAttribute('data-document-id') || '0');
body.append('_csrf', this.csrf());
fetch(button.getAttribute('data-short-generate'), {
method: 'POST', body: body,
headers: { 'Accept': 'application/json', 'X-CSRF-Token': this.csrf() },
credentials: 'same-origin'
})
.then(this.json)
.then(function (payload) {
var data = payload.data || {};
if (data.alias) {
input.value = data.alias;
input.dispatchEvent(new Event('input', { bubbles: true }));
} else if (payload.message && window.Adminx && Adminx.toast) {
Adminx.toast(payload.message, 'error');
}
})
.catch(function () {
if (window.Adminx && Adminx.toast) { Adminx.toast('Не удалось сгенерировать короткий алиас', 'error'); }
});
},
updateSeo: function () {
if (!this.form) { return; }
var title = (this.field('document_title') || {}).value || '';
var desc = (this.field('document_meta_description') || {}).value || '';
var alias = (this.field('document_alias') || {}).value || '';
this.setSeoCount('title', title.length);
this.setSeoCount('description', desc.length);
var u = document.querySelector('[data-seo-snippet-url]'); if (u) { u.textContent = '/' + alias; }
var t = document.querySelector('[data-seo-snippet-title]'); if (t) { t.textContent = title || 'Заголовок документа'; }
var d = document.querySelector('[data-seo-snippet-desc]'); if (d) { d.textContent = desc || 'Описание появится после заполнения meta description.'; }
},
setSeoCount: function (key, len) {
var el = document.querySelector('[data-seo-count="' + key + '"]');
if (!el) { return; }
var max = parseInt(el.getAttribute('data-seo-max'), 10) || 0;
el.textContent = len + '/' + max;
el.classList.toggle('is-over', max > 0 && len > max);
},
openMediaPicker: function (target) {
var self = this;
var input = target ? target.querySelector('[data-document-media-url]') : null;
var list = target ? target.closest('[data-document-media-list], [data-document-media-single]') : null;
var fallbackDir = list ? (list.getAttribute('data-picker-dir') || '/uploads') : '/uploads';
Adminx.MediaPicker.open({
type: this.mediaPickerType(target),
dir: this.mediaDirectory(input ? input.value : '', fallbackDir),
title: 'Выбрать файл',
description: 'Путь будет записан в поле документа.',
onPick: function (file) { self.applyPickedMedia(target, file); }
});
},
mediaDirectory: function (value, fallback) {
var path = String(value || '').trim();
var safeFallback = String(fallback || '/uploads').trim() || '/uploads';
if (!path) { return safeFallback; }
try {
path = new URL(path, window.location.origin).pathname;
path = decodeURIComponent(path);
} catch (e) {
path = path.split(/[?#]/, 1)[0];
}
path = path.replace(/\\/g, '/').replace(/\/+/g, '/');
if (path !== '/uploads' && path.indexOf('/uploads/') !== 0) { return safeFallback; }
var slash = path.lastIndexOf('/');
return slash > 0 ? (path.slice(0, slash) || '/uploads') : safeFallback;
},
applyPickedMedia: function (target, file) {
var input = target ? target.querySelector('[data-document-media-url]') : null;
if (input && file && file.url) { input.value = file.url; input.dispatchEvent(new Event('input', { bubbles: true })); }
},
mediaPickerType: function (target) {
var input = target ? target.querySelector('[data-document-picker-type]') : null;
var type = input ? input.getAttribute('data-document-picker-type') : '';
if (!type && target && target.closest('[data-media-accept]')) {
type = target.closest('[data-media-accept]').getAttribute('data-media-accept') || '';
}
return type === 'file' || type === 'all' ? type : 'image';
},
addMediaRow: function (list) {
if (!list) { return; }
var fieldId = list.getAttribute('data-field-id') || '';
var type = list.getAttribute('data-field-type') || 'image_multi';
var box = list.querySelector('[data-document-media-items]');
if (!box) { return; }
box.insertAdjacentHTML('beforeend', this.mediaRowHtml(fieldId, type, box.querySelectorAll('[data-document-media-row]').length));
this.updateMediaListEmpty(list);
this.setDirty(true);
},
openMediaUpload: function (list) {
var input = list ? list.querySelector('[data-document-media-files]') : null;
if (input) { input.click(); }
},
uploadMediaFiles: function (container, files) {
if (!container || !files || !files.length) { return; }
var data = new FormData();
data.append('_csrf', this.csrf());
data.append('dir', container.getAttribute('data-upload-dir') || '/uploads');
data.append('field_id', container.getAttribute('data-field-id') || '0');
data.append('document_id', (this.field('id') || {}).value || '0');
data.append('media_draft_token', (this.field('media_draft_token') || {}).value || '');
Array.prototype.forEach.call(files, function (file) { data.append('files[]', file); });
var self = this;
this.ajax(container.getAttribute('data-upload-url') || (this.base() + '/media/upload'), data, function (payload) {
var uploaded = ((payload.data || {}).files) || [];
if (container.matches('[data-document-media-single]')) {
self.applyUploadedSingle(container, uploaded[0] || null);
} else {
self.appendMediaFiles(container, uploaded);
}
Adminx.Toast.show(payload.message || 'Файлы загружены', 'success');
});
},
applyUploadedSingle: function (container, file) {
var input = container ? container.querySelector('[data-document-media-url]') : null;
var url = this.mediaFileUrl(file);
if (!input || !url) { return; }
input.value = url;
input.dispatchEvent(new Event('input', { bubbles: true }));
},
refreshMediaDraft: function (token, documentId) {
var tokenInput = this.field('media_draft_token');
if (tokenInput) { tokenInput.value = token; }
documentId = parseInt(documentId, 10) || 0;
document.querySelectorAll('[data-document-media-list], [data-document-media-single]').forEach(function (container) {
var fieldId = container.getAttribute('data-field-id') || '0';
container.setAttribute('data-upload-dir', '/uploads/.drafts/' + token + '/' + fieldId);
var target = container.getAttribute('data-target-dir') || '';
if (documentId && target.indexOf('{ID}') !== -1) {
target = target.replace(/\{ID\}/g, String(documentId));
container.setAttribute('data-target-dir', target);
container.setAttribute('data-picker-dir', target);
var targetLabel = container.querySelector('[data-document-media-target]');
if (targetLabel) { targetLabel.textContent = target; }
}
});
},
importMediaFolder: function (list) {
if (!list) { return; }
var self = this;
Adminx.MediaPicker.open({
type: list.getAttribute('data-media-accept') || 'image',
dir: list.getAttribute('data-picker-dir') || '/uploads',
title: 'Добавить файлы из папки',
description: 'Откройте нужную папку и подтвердите выбор. В поле добавятся все подходящие файлы из неё.',
folderLabel: 'Добавить из этой папки',
onFolder: function (dir) { self.loadMediaFolder(list, dir); }
});
},
loadMediaFolder: function (list, dir) {
var self = this;
var params = new URLSearchParams();
params.set('dir', dir || list.getAttribute('data-picker-dir') || '/uploads');
params.set('type', list.getAttribute('data-media-accept') || 'image');
params.set('limit', 1000);
fetch(this.base() + '/media/folder-files?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) {
var files = ((payload.data || {}).files) || [];
self.appendMediaFiles(list, files);
Adminx.Toast.show(files.length ? ('Добавлено из папки: ' + files.length) : 'В папке нет подходящих файлов', files.length ? 'success' : 'info');
})
.catch(function (payload) { Adminx.Toast.show((payload && payload.message) || 'Не удалось прочитать папку', 'error'); });
},
appendMediaFiles: function (list, files) {
if (!list || !files || !files.length) { return; }
var seen = {};
list.querySelectorAll('[data-document-media-url]').forEach(function (input) {
var value = String(input.value || '').trim();
if (value) { seen[value] = true; }
});
var added = 0;
var self = this;
Array.prototype.forEach.call(files, function (file) {
var url = self.mediaFileUrl(file);
if (!url || seen[url]) { return; }
var row = self.createMediaRow(list);
var input = row ? row.querySelector('[data-media-key="url"]') : null;
if (!input) { return; }
input.value = url;
input.dispatchEvent(new Event('input', { bubbles: true }));
var name = row.querySelector('[data-media-key="name"]');
if (name && !name.value) { name.value = file.name || url.split('/').pop(); }
seen[url] = true;
added++;
});
this.updateMediaListEmpty(list);
if (!added && files.length) { Adminx.Toast.show('Эти файлы уже есть в поле', 'info'); }
},
createMediaRow: function (list) {
this.addMediaRow(list);
var rows = list.querySelectorAll('[data-document-media-row]');
return rows.length ? rows[rows.length - 1] : null;
},
mediaFileUrl: function (file) {
if (!file) { return ''; }
return String(file.url || file.path || '').trim();
},
removeMediaRow: function (row) {
if (!row) { return; }
var list = row.closest('[data-document-media-list]');
row.remove();
this.reindexMediaList(list);
this.updateMediaListEmpty(list);
this.setDirty(true);
},
moveMediaRow: function (row, direction) {
if (!row) { return; }
var list = row.closest('[data-document-media-list]');
var moved = false;
if (direction < 0 && row.previousElementSibling) {
row.parentNode.insertBefore(row, row.previousElementSibling);
moved = true;
}
if (direction > 0 && row.nextElementSibling) {
row.parentNode.insertBefore(row.nextElementSibling, row);
moved = true;
}
this.reindexMediaList(list);
if (moved) { this.setDirty(true); }
},
clearMediaRows: function (list) {
if (!list) { return; }
var self = this;
var clear = function () {
var box = list.querySelector('[data-document-media-items]');
if (box) { box.innerHTML = ''; }
self.updateMediaListEmpty(list);
self.setDirty(true);
};
if (Adminx.Confirm) {
Adminx.Confirm.open({
kind: 'danger',
title: 'Очистить поле?',
message: 'Все элементы этого поля будут удалены из документа после сохранения.',
confirmLabel: 'Очистить',
onConfirm: clear
});
return;
}
if (confirm('Очистить все элементы поля?')) { clear(); }
},
updateMediaPreview: function (input) {
var key = input.getAttribute('data-media-key');
if (key && key !== 'url') { return; }
var scope = input.closest('[data-document-media-row]') || input.closest('.documents-single-media-field');
if (!scope) { return; }
var url = String(input.value || '').trim();
var isDoc = scope.classList.contains('documents-media-row-doc_files') || (scope.getAttribute('data-media-accept') === 'all');
var preview = scope.querySelector('.documents-media-thumb, .documents-media-preview');
if (preview) {
if (url && /\.(jpe?g|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url)) {
preview.innerHTML = '';
} else {
preview.innerHTML = '';
}
}
var path = scope.querySelector('[data-media-path]');
if (path) {
path.innerHTML = url
? '' + this.esc(url) + ''
: '' + (isDoc ? 'Файл не выбран' : 'Изображение не выбрано') + '';
}
},
updateMediaListEmpty: function (list) {
if (!list) { return; }
var empty = list.querySelector('[data-document-media-empty]');
var count = list.querySelectorAll('[data-document-media-row]').length;
if (empty) { empty.hidden = count > 0; }
this.reindexMediaList(list);
},
reindexMediaLists: function () {
var self = this;
if (!this.form) { return; }
this.form.querySelectorAll('[data-document-media-list]').forEach(function (list) { self.reindexMediaList(list); });
},
reindexMediaList: function (list) {
if (!list) { return; }
var fieldId = list.getAttribute('data-field-id') || '';
list.querySelectorAll('[data-document-media-row]').forEach(function (row, index) {
row.querySelectorAll('[data-media-key]').forEach(function (input) {
input.name = 'fields[' + fieldId + '][items][' + index + '][' + input.getAttribute('data-media-key') + ']';
});
});
},
// ---- drag-and-drop сортировка строк медиа/значений ----
rowDragStart: function (e) {
var handle = e.target.closest('[data-doc-drag]');
if (!handle) { return; }
var row = handle.closest('[data-document-media-row], [data-document-value-row]');
if (!row) { return; }
this.dragRow = row;
this.dragContainer = row.parentNode;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', ''); } catch (err) {}
setTimeout(function () { row.classList.add('documents-row-dragging'); }, 0);
},
rowDragOver: function (e) {
if (!this.dragRow) { return; }
var over = e.target.closest('[data-document-media-row], [data-document-value-row]');
if (!over || over === this.dragRow || over.parentNode !== this.dragContainer) { return; }
e.preventDefault();
var rect = over.getBoundingClientRect();
var after;
if (Math.abs(e.clientY - (rect.top + rect.height / 2)) > rect.height / 2) {
after = e.clientY > rect.top + rect.height / 2;
} else {
after = e.clientX > rect.left + rect.width / 2;
}
this.dragContainer.insertBefore(this.dragRow, after ? over.nextSibling : over);
},
rowDragEnd: function () {
if (!this.dragRow) { return; }
this.dragRow.classList.remove('documents-row-dragging');
var mediaList = this.dragRow.closest('[data-document-media-list]');
var valueList = this.dragRow.closest('[data-document-value-list]');
this.dragRow = null;
this.dragContainer = null;
if (mediaList) { this.reindexMediaList(mediaList); }
if (valueList) { this.reindexValueList(valueList); }
this.setDirty(true);
},
mediaRowHtml: function (fieldId, type, index) {
var prefix = 'fields[' + this.esc(fieldId) + '][items][' + this.esc(index) + ']';
var isImage = type !== 'doc_files';
var thumb = '';
var path = '
' + (isImage ? 'Изображение не выбрано' : 'Файл не выбран') + '
';
var actions = '
'
+ ''
+ ''
+ ''
+ ''
+ '
';
var picker = function (name, placeholder, icon) {
var pickerType = name === 'link' || type === 'doc_files' ? 'all' : 'image';
return '';
};
var linkField = function () {
return '
';
},
openRelationPicker: function (target, opts) {
var self = this;
opts = opts || {};
var subtitle = opts.insert === 'linkAlias'
? 'В поле-ссылку будет записан адрес документа.'
: 'В поле будет записан ID документа.';
var overlay = document.createElement('div');
overlay.className = 'overlay documents-picker-overlay';
overlay.innerHTML = '
'
+ '
Выбрать документ
' + subtitle + '
'
+ '
Загрузка...
'
+ ''
+ '
';
document.body.appendChild(overlay);
requestAnimationFrame(function () { overlay.classList.add('show'); });
this.bindRelationPicker(overlay, target, opts);
},
bindRelationPicker: function (overlay, target, opts) {
var self = this;
opts = opts || {};
var list = overlay.querySelector('[data-relation-list]');
var status = overlay.querySelector('[data-relation-status]');
var search = overlay.querySelector('[data-relation-search]');
var count = overlay.querySelector('[data-relation-count]');
var close = function () {
overlay.classList.remove('show');
setTimeout(function () { overlay.remove(); document.removeEventListener('keydown', onKey); }, 160);
};
var render = function (items) {
list.innerHTML = '';
(items || []).forEach(function (item) {
list.insertAdjacentHTML('beforeend', '');
});
status.hidden = items && items.length > 0;
status.textContent = search.value.trim() ? 'Ничего не найдено' : 'Начните вводить название или ID';
count.textContent = items && items.length ? 'Документов: ' + items.length : '';
};
var rubricEl = target ? (target.querySelector && target.querySelector('[data-document-picker-rubric]')) : null;
if (!rubricEl && target && target.matches && target.matches('[data-document-picker-rubric]')) { rubricEl = target; }
var rubric = rubricEl ? (rubricEl.getAttribute('data-document-picker-rubric') || '') : '';
var load = function () {
var params = new URLSearchParams();
params.set('q', search.value.trim());
params.set('limit', 20);
if (rubric) { params.set('rubric_id', rubric); }
status.textContent = 'Загрузка...';
status.hidden = false;
fetch(self.base() + '/documents/picker?' + params.toString(), { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(self.json)
.then(function (payload) { render((payload.data || {}).items || []); })
.catch(function () { list.innerHTML = ''; status.textContent = 'Не удалось загрузить документы'; status.hidden = false; });
};
var timer = null;
var onKey = function (e) { if (e.key === 'Escape') { close(); } };
overlay.addEventListener('click', function (e) {
if (e.target === overlay || e.target.closest('[data-relation-close]')) { close(); return; }
var item = e.target.closest('[data-relation-id]');
if (item) {
if (opts.insert === 'linkAlias' && opts.input) {
var alias = (item.getAttribute('data-relation-alias') || '').replace(/^\/+/, '');
opts.input.value = alias ? '/' + alias : '';
opts.input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
var isMultiple = !!(target && target.querySelector('[data-document-relation-list]'));
self.applyPickedRelation(
target,
item.getAttribute('data-relation-id'),
item.getAttribute('data-relation-alias'),
item.querySelector('b') ? item.querySelector('b').textContent : ''
);
if (isMultiple) { item.classList.add('is-selected'); return; }
}
close();
}
});
search.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(load, 220);
});
document.addEventListener('keydown', onKey);
load();
search.focus();
},
applyPickedRelation: function (target, id, alias, title) {
var multiple = target ? target.querySelector('[data-document-relation-list]') : null;
if (multiple) {
this.appendPickedRelation(multiple, id, title);
return;
}
var input = target ? (target.querySelector('[data-document-relation-id]') || target.querySelector('[data-document-parent-id]')) : null;
if (input) { input.value = id || ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
// Одиночная связь «Документ из рубрики»: показываем заголовок выбранного документа, а не ID.
var single = target ? target.querySelector('[data-document-relation-single]') : null;
if (single) { this.fillRelationSingle(single, id, title); }
// Выбор родительского документа перестраивает ЧПУ по иерархии: <алиас-родителя>/.
if (input && input.hasAttribute('data-document-parent-id')) {
this.rebuildAliasFromParent(alias);
}
},
fillRelationSingle: function (single, id, title) {
if (!single) { return; }
id = String(id || '').replace(/[^0-9]/g, '');
var titleEl = single.querySelector('[data-relation-title]');
var idEl = single.querySelector('[data-relation-idlabel]');
var clear = single.querySelector('[data-document-relation-clear]');
if (titleEl) { titleEl.textContent = id ? (title || ('Документ #' + id)) : 'Документ не выбран'; }
if (idEl) { idEl.textContent = id ? '#' + id : 'нажмите, чтобы выбрать'; }
single.classList.toggle('is-empty', !id);
if (clear) { clear.hidden = !id; }
},
appendPickedRelation: function (list, id, title) {
id = String(id || '').replace(/[^0-9]/g, '');
if (!list || !id || list.querySelector('[data-document-relation-token][data-relation-id="' + id + '"]')) { return; }
var fieldId = list.getAttribute('data-field-id') || '';
var tokens = list.querySelector('[data-document-relation-tokens]');
if (!tokens) { return; }
tokens.insertAdjacentHTML('beforeend', ''
+ '' + this.esc(title || ('Документ #' + id)) + '#' + this.esc(id) + ''
+ ''
+ '');
var empty = list.querySelector('[data-document-relation-empty]');
if (empty) { empty.hidden = true; }
this.setDirty(true);
},
removePickedRelation: function (token) {
if (!token) { return; }
var list = token.closest('[data-document-relation-list]');
token.remove();
var empty = list ? list.querySelector('[data-document-relation-empty]') : null;
if (empty) { empty.hidden = !!list.querySelector('[data-document-relation-token]'); }
this.setDirty(true);
},
rebuildAliasFromParent: function (parentAlias) {
var aliasInput = this.field('document_alias');
if (!aliasInput) { return; }
var current = String(aliasInput.value || '').replace(/^\/+|\/+$/g, '');
var leaf = current.indexOf('/') >= 0 ? current.split('/').pop() : current;
if (!leaf) {
var title = this.field('document_title');
leaf = title ? this.slugify(title.value) : '';
}
var parent = String(parentAlias || '').replace(/^\/+|\/+$/g, '');
aliasInput.value = parent ? (parent + '/' + leaf) : leaf;
this.aliasTouched = true;
this.checkAlias(false);
this.updateSeo();
},
openRemarks: function () {
if (!this.form) { return; }
var id = parseInt(this.field('id').value, 10) || 0;
if (!id) { return; }
this.currentRemarkDocumentId = id;
var subtitle = document.querySelector('[data-document-remarks-subtitle]');
if (subtitle) { subtitle.textContent = '#' + id + ' · ' + (this.field('document_title') ? this.field('document_title').value : 'Документ'); }
if (Adminx.Drawer) { Adminx.Drawer.open('documentRemarksDrawer'); }
this.refreshRemarks();
},
refreshRemarks: function () {
if (!this.currentRemarkDocumentId) { return; }
var self = this;
var list = document.querySelector('[data-document-remark-list]');
if (list) { list.innerHTML = '
Загрузка...
'; }
fetch(this.base() + '/documents/' + this.currentRemarkDocumentId + '/remarks', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(this.json)
.then(function (payload) { self.renderRemarks(((payload.data || {}).remarks) || []); })
.catch(function () { Adminx.Toast.show('Не удалось загрузить заметки', 'error'); });
},
renderRemarks: function (items) {
var list = document.querySelector('[data-document-remark-list]');
var self = this;
if (!list) { return; }
var badge = document.querySelector('[data-document-remarks-badge]');
if (badge) { badge.textContent = items.length; }
if (!items.length) { list.innerHTML = '