🚀 Korea Tourism Agency - Complete implementation
✨ Features: - Modern tourism website with responsive design - AdminJS admin panel with image editor integration - PostgreSQL database with comprehensive schema - Docker containerization - Image upload and gallery management 🛠 Tech Stack: - Backend: Node.js + Express.js - Database: PostgreSQL 13+ - Frontend: HTML/CSS/JS with responsive design - Admin: AdminJS with custom components - Deployment: Docker + Docker Compose - Image Processing: Sharp with optimization 📱 Admin Features: - Routes/Tours management (city, mountain, fishing) - Guides profiles with specializations - Articles and blog system - Image editor with upload/gallery/URL options - User management and authentication - Responsive admin interface 🎨 Design: - Korean tourism focused branding - Mobile-first responsive design - Custom CSS with modern aesthetics - Image optimization and gallery - SEO-friendly structure 🔒 Security: - Helmet.js security headers - bcrypt password hashing - Input validation and sanitization - CORS protection - Environment variables
This commit is contained in:
@@ -1,5 +1,96 @@
|
||||
/* Korea Tourism Agency Admin Panel Custom Scripts */
|
||||
|
||||
// Функция для открытия редактора изображений
|
||||
function openImageEditor(fieldName, currentValue) {
|
||||
const editorUrl = `/image-editor.html?field=${fieldName}¤t=${encodeURIComponent(currentValue || '')}`;
|
||||
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||
|
||||
// Слушаем сообщения от редактора
|
||||
const messageHandler = (event) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||||
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||||
if (field) {
|
||||
field.value = event.data.path;
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
// Обновляем превью если есть
|
||||
updateImagePreview(fieldName, event.data.path);
|
||||
}
|
||||
|
||||
window.removeEventListener('message', messageHandler);
|
||||
editorWindow.close();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Очистка обработчика при закрытии окна
|
||||
const checkClosed = setInterval(() => {
|
||||
if (editorWindow.closed) {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
clearInterval(checkClosed);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Функция обновления превью изображения
|
||||
function updateImagePreview(fieldName, imagePath) {
|
||||
const previewId = `${fieldName}_preview`;
|
||||
let preview = document.getElementById(previewId);
|
||||
|
||||
if (!preview) {
|
||||
// Создаем превью если его нет
|
||||
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||||
if (field) {
|
||||
preview = document.createElement('img');
|
||||
preview.id = previewId;
|
||||
preview.className = 'img-thumbnail mt-2';
|
||||
preview.style.maxWidth = '200px';
|
||||
preview.style.maxHeight = '200px';
|
||||
field.parentNode.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
preview.src = imagePath || '/images/placeholders/no-image.png';
|
||||
preview.alt = 'Preview';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция добавления кнопки редактора к полю
|
||||
function addImageEditorButton(field) {
|
||||
const fieldName = field.name || field.id;
|
||||
if (!fieldName) return;
|
||||
|
||||
// Проверяем, не добавлена ли уже кнопка
|
||||
if (field.parentNode.querySelector('.image-editor-btn')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'input-group';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'btn btn-outline-secondary image-editor-btn';
|
||||
button.innerHTML = '<i class="fas fa-images"></i> Выбрать';
|
||||
button.onclick = () => openImageEditor(fieldName, field.value);
|
||||
|
||||
const buttonWrapper = document.createElement('div');
|
||||
buttonWrapper.className = 'input-group-append';
|
||||
buttonWrapper.appendChild(button);
|
||||
|
||||
// Перестраиваем структуру
|
||||
field.parentNode.insertBefore(wrapper, field);
|
||||
wrapper.appendChild(field);
|
||||
wrapper.appendChild(buttonWrapper);
|
||||
|
||||
// Добавляем превью если есть значение
|
||||
if (field.value) {
|
||||
updateImagePreview(fieldName, field.value);
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize tooltips
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
@@ -11,6 +102,33 @@ $(document).ready(function() {
|
||||
setTimeout(function() {
|
||||
$('.alert').fadeOut('slow');
|
||||
}, 5000);
|
||||
|
||||
// Добавляем кнопки редактора к полям изображений
|
||||
$('input[type="text"], input[type="url"]').each(function() {
|
||||
const field = this;
|
||||
const fieldName = field.name || field.id || '';
|
||||
|
||||
// Проверяем, относится ли поле к изображениям
|
||||
if (fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner') ||
|
||||
$(field).closest('label').text().toLowerCase().includes('изображение') ||
|
||||
$(field).closest('label').text().toLowerCase().includes('картинка') ||
|
||||
$(field).closest('label').text().toLowerCase().includes('фото')) {
|
||||
addImageEditorButton(field);
|
||||
}
|
||||
});
|
||||
|
||||
// Обработчик для динамически добавляемых полей
|
||||
$(document).on('focus', 'input[type="text"], input[type="url"]', function() {
|
||||
const field = this;
|
||||
const fieldName = field.name || field.id || '';
|
||||
|
||||
if ((fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner')) &&
|
||||
!field.parentNode.querySelector('.image-editor-btn')) {
|
||||
addImageEditorButton(field);
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm delete actions
|
||||
$('.btn-delete').on('click', function(e) {
|
||||
|
||||
13
public/js/admin-image-loader.js
Normal file
13
public/js/admin-image-loader.js
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- Скрипт для AdminJS страниц -->
|
||||
<script>
|
||||
// Проверяем, находимся ли мы в админ-панели
|
||||
if (window.location.pathname.startsWith('/admin')) {
|
||||
// Загружаем редактор изображений для AdminJS
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/image-editor.js';
|
||||
script.onload = () => {
|
||||
console.log('Image Editor loaded for AdminJS');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
314
public/js/admin-image-selector-fixed.js
Normal file
314
public/js/admin-image-selector-fixed.js
Normal file
@@ -0,0 +1,314 @@
|
||||
// JavaScript для интеграции редактора изображений в AdminJS
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Функция для открытия редактора изображений
|
||||
function openImageEditor(inputField, fieldName) {
|
||||
const currentValue = inputField.value || '';
|
||||
const editorUrl = `/image-editor-compact.html?field=${fieldName}¤t=${encodeURIComponent(currentValue)}`;
|
||||
|
||||
// Убираем предыдущие модальные окна
|
||||
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
|
||||
|
||||
// Создаем модальное окно
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'image-editor-modal';
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
height: 80%;
|
||||
max-height: 600px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = editorUrl;
|
||||
iframe.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
// Обработчик сообщений от iframe
|
||||
const messageHandler = function(event) {
|
||||
if (event.data.type === 'imageSelected' && event.data.field === fieldName) {
|
||||
console.log('🖼️ Изображение выбрано:', event.data.url);
|
||||
inputField.value = event.data.url;
|
||||
updateImagePreview(inputField, event.data.url);
|
||||
|
||||
// Триггерим событие change для обновления формы
|
||||
const changeEvent = new Event('change', { bubbles: true });
|
||||
inputField.dispatchEvent(changeEvent);
|
||||
|
||||
// Триггерим input событие
|
||||
const inputEvent = new Event('input', { bubbles: true });
|
||||
inputField.dispatchEvent(inputEvent);
|
||||
|
||||
modal.remove();
|
||||
window.removeEventListener('message', messageHandler);
|
||||
} else if (event.data.type === 'editorClosed') {
|
||||
modal.remove();
|
||||
window.removeEventListener('message', messageHandler);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
closeBtn.onclick = function() {
|
||||
modal.remove();
|
||||
window.removeEventListener('message', messageHandler);
|
||||
};
|
||||
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) {
|
||||
modal.remove();
|
||||
window.removeEventListener('message', messageHandler);
|
||||
}
|
||||
};
|
||||
|
||||
content.appendChild(closeBtn);
|
||||
content.appendChild(iframe);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Функция обновления превью изображения
|
||||
function updateImagePreview(inputField, imagePath) {
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||
if (!fieldContainer) return;
|
||||
|
||||
// Находим или создаем превью
|
||||
let preview = fieldContainer.querySelector('.image-preview');
|
||||
|
||||
if (!preview) {
|
||||
preview = document.createElement('img');
|
||||
preview.className = 'image-preview';
|
||||
preview.style.cssText = `
|
||||
display: block;
|
||||
max-width: 180px;
|
||||
max-height: 120px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
// Вставляем превью после кнопки
|
||||
const button = fieldContainer.querySelector('.image-editor-btn');
|
||||
if (button) {
|
||||
const buttonContainer = button.parentNode;
|
||||
buttonContainer.parentNode.insertBefore(preview, buttonContainer.nextSibling);
|
||||
} else {
|
||||
fieldContainer.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath && imagePath.trim()) {
|
||||
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
|
||||
preview.style.display = 'block';
|
||||
preview.onerror = () => {
|
||||
preview.style.display = 'none';
|
||||
};
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция добавления кнопки редактора к полю
|
||||
function addImageEditorButton(inputField) {
|
||||
const fieldName = inputField.name || inputField.id || 'image';
|
||||
|
||||
// Проверяем, не добавлена ли уже кнопка
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||
if (fieldContainer.querySelector('.image-editor-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем контейнер для кнопки
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.cssText = `
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
// Создаем кнопку
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'image-editor-btn';
|
||||
button.innerHTML = '📷 Выбрать';
|
||||
button.style.cssText = `
|
||||
padding: 6px 12px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openImageEditor(inputField, fieldName);
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(button);
|
||||
|
||||
// Добавляем контейнер после поля ввода
|
||||
if (inputField.nextSibling) {
|
||||
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
|
||||
} else {
|
||||
inputField.parentNode.appendChild(buttonContainer);
|
||||
}
|
||||
|
||||
// Добавляем превью если есть значение
|
||||
if (inputField.value && inputField.value.trim()) {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
}
|
||||
|
||||
// Слушаем изменения в поле для обновления превью
|
||||
inputField.addEventListener('input', () => {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
});
|
||||
|
||||
inputField.addEventListener('change', () => {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Функция проверки, является ли поле полем изображения
|
||||
function isImageField(inputField) {
|
||||
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
|
||||
let labelText = '';
|
||||
|
||||
// Ищем label для поля
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||
if (fieldContainer) {
|
||||
const label = fieldContainer.querySelector('label, .property-label, h3');
|
||||
if (label) {
|
||||
labelText = label.textContent.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем по имени поля или тексту label
|
||||
return fieldName.includes('image') ||
|
||||
fieldName.includes('photo') ||
|
||||
fieldName.includes('avatar') ||
|
||||
fieldName.includes('picture') ||
|
||||
fieldName.includes('banner') ||
|
||||
fieldName.includes('thumbnail') ||
|
||||
(fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение'))) ||
|
||||
labelText.includes('изображение') ||
|
||||
labelText.includes('картинка') ||
|
||||
labelText.includes('фото') ||
|
||||
labelText.includes('image') ||
|
||||
labelText.includes('picture');
|
||||
}
|
||||
|
||||
// Функция сканирования и добавления кнопок к полям изображений
|
||||
function scanAndAddImageButtons() {
|
||||
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
|
||||
|
||||
// Более широкий поиск полей ввода
|
||||
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
|
||||
|
||||
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
|
||||
|
||||
inputFields.forEach((inputField, index) => {
|
||||
const fieldName = inputField.name || inputField.id || `field_${index}`;
|
||||
const isImage = isImageField(inputField);
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||
const hasButton = fieldContainer.querySelector('.image-editor-btn');
|
||||
|
||||
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${!!hasButton}`);
|
||||
|
||||
if (isImage && !hasButton) {
|
||||
console.log(`➕ Добавляем кнопку для поля "${fieldName}"`);
|
||||
addImageEditorButton(inputField);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
function initialize() {
|
||||
console.log('🚀 Инициализация селектора изображений AdminJS');
|
||||
|
||||
scanAndAddImageButtons();
|
||||
|
||||
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldScan = false;
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === 1 && (node.tagName === 'INPUT' || node.querySelector('input'))) {
|
||||
shouldScan = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldScan) {
|
||||
setTimeout(scanAndAddImageButtons, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Ждем загрузки DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
// Также запускаем через задержки для AdminJS
|
||||
setTimeout(initialize, 1000);
|
||||
setTimeout(initialize, 3000);
|
||||
setTimeout(initialize, 5000);
|
||||
|
||||
})();
|
||||
300
public/js/admin-image-selector.js
Normal file
300
public/js/admin-image-selector.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// JavaScript для интеграции редактора изображений в AdminJS
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Функция для открытия редактора изображений
|
||||
function openImageEditor(inputField, fieldName) {
|
||||
const currentValue = inputField.value || '';
|
||||
const editorUrl = `/image-editor-compact.html?field=${fieldName}¤t=${encodeURIComponent(currentValue)}`;
|
||||
|
||||
// Убираем предыдущие модальные окна
|
||||
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
|
||||
|
||||
// Создаем модальное окно
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'image-editor-modal';
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
height: 80%;
|
||||
max-height: 600px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = editorUrl;
|
||||
iframe.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
// Обработчик закрытия
|
||||
const closeModal = () => {
|
||||
document.body.removeChild(modal);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
|
||||
closeBtn.onclick = closeModal;
|
||||
|
||||
// Обработчик сообщений от редактора
|
||||
const handleMessage = (event) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||||
inputField.value = event.data.path;
|
||||
|
||||
// Триггерим события изменения
|
||||
inputField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inputField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
// Обновляем превью если есть
|
||||
updateImagePreview(inputField, event.data.path);
|
||||
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
content.appendChild(closeBtn);
|
||||
content.appendChild(iframe);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Закрытие по клику на фон
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Функция обновления превью изображения
|
||||
function updateImagePreview(inputField, imagePath) {
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||
if (!fieldContainer) return;
|
||||
|
||||
// Находим или создаем превью
|
||||
let preview = fieldContainer.querySelector('.image-preview');
|
||||
|
||||
if (!preview) {
|
||||
preview = document.createElement('img');
|
||||
preview.className = 'image-preview';
|
||||
preview.style.cssText = `
|
||||
display: block;
|
||||
max-width: 180px;
|
||||
max-height: 120px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
// Вставляем превью после кнопки
|
||||
const button = fieldContainer.querySelector('.image-editor-btn');
|
||||
if (button && button.nextSibling) {
|
||||
button.parentNode.insertBefore(preview, button.nextSibling);
|
||||
} else {
|
||||
inputField.parentNode.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath && imagePath.trim()) {
|
||||
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
|
||||
preview.style.display = 'block';
|
||||
preview.onerror = () => {
|
||||
preview.style.display = 'none';
|
||||
};
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция добавления кнопки редактора к полю
|
||||
function addImageEditorButton(inputField) {
|
||||
const fieldName = inputField.name || inputField.id || 'image';
|
||||
|
||||
// Проверяем, не добавлена ли уже кнопка
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||
if (fieldContainer.querySelector('.image-editor-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем контейнер для кнопки и превью
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.style.cssText = `
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
// Создаем кнопку
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'image-editor-btn';
|
||||
button.innerHTML = '📷 Выбрать';
|
||||
button.style.cssText = `
|
||||
padding: 6px 12px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openImageEditor(inputField, fieldName);
|
||||
};
|
||||
|
||||
buttonContainer.appendChild(button);
|
||||
|
||||
// Добавляем контейнер после поля ввода
|
||||
if (inputField.nextSibling) {
|
||||
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
|
||||
} else {
|
||||
inputField.parentNode.appendChild(buttonContainer);
|
||||
}
|
||||
|
||||
// Добавляем превью если есть значение
|
||||
if (inputField.value && inputField.value.trim()) {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
}
|
||||
|
||||
// Слушаем изменения в поле для обновления превью
|
||||
inputField.addEventListener('input', () => {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
});
|
||||
|
||||
inputField.addEventListener('change', () => {
|
||||
updateImagePreview(inputField, inputField.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Функция проверки, является ли поле полем изображения
|
||||
function isImageField(inputField) {
|
||||
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
|
||||
let labelText = '';
|
||||
|
||||
// Ищем label для поля
|
||||
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||
if (fieldContainer) {
|
||||
const label = fieldContainer.querySelector('label, .property-label, h3');
|
||||
if (label) {
|
||||
labelText = label.textContent.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем по имени поля или тексту label
|
||||
return fieldName.includes('image') ||
|
||||
fieldName.includes('photo') ||
|
||||
fieldName.includes('avatar') ||
|
||||
fieldName.includes('picture') ||
|
||||
fieldName.includes('banner') ||
|
||||
fieldName.includes('thumbnail') ||
|
||||
fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение')) ||
|
||||
labelText.includes('изображение') ||
|
||||
labelText.includes('картинка') ||
|
||||
labelText.includes('фото') ||
|
||||
labelText.includes('image') ||
|
||||
labelText.includes('picture');
|
||||
}
|
||||
|
||||
// Функция сканирования и добавления кнопок к полям изображений
|
||||
function scanAndAddImageButtons() {
|
||||
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
|
||||
|
||||
// Более широкий поиск полей ввода
|
||||
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
|
||||
|
||||
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
|
||||
|
||||
inputFields.forEach((inputField, index) => {
|
||||
const fieldName = inputField.name || inputField.id || `field_${index}`;
|
||||
const isImage = isImageField(inputField);
|
||||
const hasButton = inputField.parentNode.querySelector('.image-editor-btn');
|
||||
|
||||
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${hasButton}`);
|
||||
|
||||
if (isImage && !hasButton) {
|
||||
console.log(`➕ Добавляем кнопку для поля "${fieldName}"`);
|
||||
addImageEditorButton(inputField);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
function initialize() {
|
||||
console.log('🚀 Инициализация селектора изображений AdminJS');
|
||||
|
||||
scanAndAddImageButtons();
|
||||
|
||||
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
|
||||
const observer = new MutationObserver(() => {
|
||||
scanAndAddImageButtons();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Периодическое сканирование для надежности
|
||||
setInterval(scanAndAddImageButtons, 2000);
|
||||
}
|
||||
|
||||
// Ждем загрузки DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
// Также запускаем через задержки для AdminJS
|
||||
setTimeout(initialize, 1000);
|
||||
setTimeout(initialize, 3000);
|
||||
setTimeout(initialize, 5000);
|
||||
|
||||
})();
|
||||
690
public/js/image-editor.js
Normal file
690
public/js/image-editor.js
Normal file
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* Image Editor Modal Component
|
||||
* Предоставляет интерфейс для загрузки, обрезки и редактирования изображений
|
||||
*/
|
||||
|
||||
class ImageEditor {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
targetFolder: 'routes',
|
||||
aspectRatio: null, // null = свободная обрезка, или например 16/9
|
||||
maxWidth: 1200,
|
||||
maxHeight: 800,
|
||||
...options
|
||||
};
|
||||
|
||||
this.modal = null;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.image = null;
|
||||
this.imageData = null;
|
||||
this.cropBox = null;
|
||||
this.isDragging = false;
|
||||
this.lastMousePos = { x: 0, y: 0 };
|
||||
this.rotation = 0;
|
||||
this.flipHorizontal = false;
|
||||
this.flipVertical = false;
|
||||
|
||||
this.onSave = options.onSave || (() => {});
|
||||
this.onCancel = options.onCancel || (() => {});
|
||||
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
createModal() {
|
||||
// Создаем модальное окно
|
||||
const modalHTML = `
|
||||
<div class="image-editor-overlay">
|
||||
<div class="image-editor-modal">
|
||||
<div class="image-editor-header">
|
||||
<h3>Редактор изображений</h3>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="image-editor-body">
|
||||
<!-- Область загрузки -->
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">📷</div>
|
||||
<p>Перетащите изображение сюда или <button class="btn-link" id="selectFileBtn">выберите файл</button></p>
|
||||
<input type="file" id="fileInput" accept="image/*" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Область редактирования -->
|
||||
<div class="editor-area" id="editorArea" style="display: none;">
|
||||
<div class="editor-toolbar">
|
||||
<button class="tool-btn" id="rotateLeftBtn" title="Повернуть влево">↺</button>
|
||||
<button class="tool-btn" id="rotateRightBtn" title="Повернуть вправо">↻</button>
|
||||
<button class="tool-btn" id="flipHorizontalBtn" title="Отразить горизонтально">⟷</button>
|
||||
<button class="tool-btn" id="flipVerticalBtn" title="Отразить вертикально">↕</button>
|
||||
<button class="tool-btn" id="resetCropBtn" title="Сбросить обрезку">⌕</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas id="editorCanvas"></canvas>
|
||||
<div class="crop-overlay" id="cropOverlay">
|
||||
<div class="crop-box" id="cropBox">
|
||||
<div class="crop-handle nw"></div>
|
||||
<div class="crop-handle ne"></div>
|
||||
<div class="crop-handle sw"></div>
|
||||
<div class="crop-handle se"></div>
|
||||
<div class="crop-handle n"></div>
|
||||
<div class="crop-handle s"></div>
|
||||
<div class="crop-handle e"></div>
|
||||
<div class="crop-handle w"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<span id="imageInfo">Размер: 0x0</span>
|
||||
<span id="cropInfo">Обрезка: не выбрана</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-editor-footer">
|
||||
<button class="btn btn-secondary" id="cancelBtn">Отмена</button>
|
||||
<button class="btn btn-primary" id="saveBtn" disabled>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Добавляем стили
|
||||
if (!document.getElementById('image-editor-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'image-editor-styles';
|
||||
styles.textContent = `
|
||||
.image-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.image-editor-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-editor-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-editor-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.image-editor-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: #f9f9f9;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #007bff;
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.upload-content .upload-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-content p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #007bff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#editorCanvas {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.crop-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crop-box {
|
||||
position: absolute;
|
||||
border: 2px solid #007bff;
|
||||
background: rgba(0, 123, 255, 0.1);
|
||||
pointer-events: all;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.crop-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #007bff;
|
||||
border: 1px solid white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.crop-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
|
||||
.crop-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
|
||||
.crop-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
|
||||
.crop-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
|
||||
.crop-handle.n { top: -5px; left: 50%; margin-left: -5px; cursor: n-resize; }
|
||||
.crop-handle.s { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; }
|
||||
.crop-handle.e { top: 50%; right: -5px; margin-top: -5px; cursor: e-resize; }
|
||||
.crop-handle.w { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; }
|
||||
|
||||
.image-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.image-editor-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
|
||||
// Добавляем модальное окно в DOM
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
this.modal = document.querySelector('.image-editor-overlay');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Закрытие модального окна
|
||||
this.modal.querySelector('.close-btn').addEventListener('click', () => this.close());
|
||||
this.modal.querySelector('#cancelBtn').addEventListener('click', () => this.close());
|
||||
|
||||
// Клик по overlay для закрытия
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
if (e.target === this.modal) this.close();
|
||||
});
|
||||
|
||||
// Загрузка файла
|
||||
const fileInput = this.modal.querySelector('#fileInput');
|
||||
const selectFileBtn = this.modal.querySelector('#selectFileBtn');
|
||||
const uploadArea = this.modal.querySelector('#uploadArea');
|
||||
|
||||
selectFileBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||
|
||||
// Drag & Drop
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.loadImage(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Инструменты редактирования
|
||||
this.modal.querySelector('#rotateLeftBtn').addEventListener('click', () => this.rotate(-90));
|
||||
this.modal.querySelector('#rotateRightBtn').addEventListener('click', () => this.rotate(90));
|
||||
this.modal.querySelector('#flipHorizontalBtn').addEventListener('click', () => this.flipHorizontal = !this.flipHorizontal, this.redraw());
|
||||
this.modal.querySelector('#flipVerticalBtn').addEventListener('click', () => this.flipVertical = !this.flipVertical, this.redraw());
|
||||
this.modal.querySelector('#resetCropBtn').addEventListener('click', () => this.resetCrop());
|
||||
|
||||
// Сохранение
|
||||
this.modal.querySelector('#saveBtn').addEventListener('click', () => this.save());
|
||||
}
|
||||
|
||||
handleFileSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
this.loadImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
async loadImage(file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Пожалуйста, выберите изображение');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/images/upload-image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.imageData = result;
|
||||
this.showEditor();
|
||||
this.loadImageToCanvas(result.tempUrl);
|
||||
} else {
|
||||
alert(result.error || 'Ошибка загрузки изображения');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('Ошибка загрузки изображения');
|
||||
}
|
||||
}
|
||||
|
||||
loadImageToCanvas(imageUrl) {
|
||||
this.image = new Image();
|
||||
this.image.onload = () => {
|
||||
this.initCanvas();
|
||||
this.initCropBox();
|
||||
this.redraw();
|
||||
this.updateInfo();
|
||||
this.modal.querySelector('#saveBtn').disabled = false;
|
||||
};
|
||||
this.image.src = imageUrl;
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
this.modal.querySelector('#uploadArea').style.display = 'none';
|
||||
this.modal.querySelector('#editorArea').style.display = 'block';
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
this.canvas = this.modal.querySelector('#editorCanvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
|
||||
// Вычисляем размеры canvas
|
||||
const containerWidth = 600; // максимальная ширина
|
||||
const containerHeight = 400; // максимальная высота
|
||||
|
||||
const imageRatio = this.image.width / this.image.height;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
|
||||
if (imageRatio > containerRatio) {
|
||||
this.canvas.width = containerWidth;
|
||||
this.canvas.height = containerWidth / imageRatio;
|
||||
} else {
|
||||
this.canvas.width = containerHeight * imageRatio;
|
||||
this.canvas.height = containerHeight;
|
||||
}
|
||||
|
||||
this.scaleX = this.canvas.width / this.image.width;
|
||||
this.scaleY = this.canvas.height / this.image.height;
|
||||
}
|
||||
|
||||
initCropBox() {
|
||||
const overlay = this.modal.querySelector('#cropOverlay');
|
||||
const cropBox = this.modal.querySelector('#cropBox');
|
||||
|
||||
// Устанавливаем размеры overlay как у canvas
|
||||
const canvasRect = this.canvas.getBoundingClientRect();
|
||||
const containerRect = this.canvas.parentElement.getBoundingClientRect();
|
||||
|
||||
overlay.style.width = `${canvasRect.width}px`;
|
||||
overlay.style.height = `${canvasRect.height}px`;
|
||||
overlay.style.left = `${canvasRect.left - containerRect.left}px`;
|
||||
overlay.style.top = `${canvasRect.top - containerRect.top}px`;
|
||||
|
||||
// Инициализируем crop box (50% от центра)
|
||||
const boxWidth = canvasRect.width * 0.6;
|
||||
const boxHeight = canvasRect.height * 0.6;
|
||||
const boxLeft = (canvasRect.width - boxWidth) / 2;
|
||||
const boxTop = (canvasRect.height - boxHeight) / 2;
|
||||
|
||||
cropBox.style.width = `${boxWidth}px`;
|
||||
cropBox.style.height = `${boxHeight}px`;
|
||||
cropBox.style.left = `${boxLeft}px`;
|
||||
cropBox.style.top = `${boxTop}px`;
|
||||
|
||||
this.cropBox = {
|
||||
x: boxLeft / canvasRect.width,
|
||||
y: boxTop / canvasRect.height,
|
||||
width: boxWidth / canvasRect.width,
|
||||
height: boxHeight / canvasRect.height
|
||||
};
|
||||
|
||||
this.bindCropEvents(cropBox, overlay);
|
||||
}
|
||||
|
||||
bindCropEvents(cropBox, overlay) {
|
||||
let resizing = false;
|
||||
let moving = false;
|
||||
let startPos = { x: 0, y: 0 };
|
||||
let startBox = {};
|
||||
|
||||
// Обработчик для перемещения
|
||||
cropBox.addEventListener('mousedown', (e) => {
|
||||
if (e.target === cropBox) {
|
||||
moving = true;
|
||||
startPos = { x: e.clientX, y: e.clientY };
|
||||
startBox = { ...this.cropBox };
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Обработчик для изменения размера
|
||||
const handles = cropBox.querySelectorAll('.crop-handle');
|
||||
handles.forEach(handle => {
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
resizing = handle.className.replace('crop-handle ', '');
|
||||
startPos = { x: e.clientX, y: e.clientY };
|
||||
startBox = { ...this.cropBox };
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики движения и отпускания мыши
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (moving || resizing) {
|
||||
this.updateCropBox(e, startPos, startBox, moving ? 'move' : resizing, overlay);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
moving = false;
|
||||
resizing = false;
|
||||
});
|
||||
}
|
||||
|
||||
updateCropBox(e, startPos, startBox, action, overlay) {
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const deltaX = (e.clientX - startPos.x) / overlayRect.width;
|
||||
const deltaY = (e.clientY - startPos.y) / overlayRect.height;
|
||||
|
||||
let newBox = { ...startBox };
|
||||
|
||||
if (action === 'move') {
|
||||
newBox.x = Math.max(0, Math.min(1 - startBox.width, startBox.x + deltaX));
|
||||
newBox.y = Math.max(0, Math.min(1 - startBox.height, startBox.y + deltaY));
|
||||
} else {
|
||||
// Изменение размера в зависимости от handle
|
||||
if (action.includes('n')) newBox.y += deltaY, newBox.height -= deltaY;
|
||||
if (action.includes('s')) newBox.height += deltaY;
|
||||
if (action.includes('w')) newBox.x += deltaX, newBox.width -= deltaX;
|
||||
if (action.includes('e')) newBox.width += deltaX;
|
||||
|
||||
// Ограничиваем минимальные размеры
|
||||
if (newBox.width < 0.1) newBox.width = 0.1;
|
||||
if (newBox.height < 0.1) newBox.height = 0.1;
|
||||
|
||||
// Ограничиваем границы
|
||||
if (newBox.x < 0) newBox.x = 0;
|
||||
if (newBox.y < 0) newBox.y = 0;
|
||||
if (newBox.x + newBox.width > 1) newBox.width = 1 - newBox.x;
|
||||
if (newBox.y + newBox.height > 1) newBox.height = 1 - newBox.y;
|
||||
}
|
||||
|
||||
this.cropBox = newBox;
|
||||
this.updateCropBoxDisplay(overlay);
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
updateCropBoxDisplay(overlay) {
|
||||
const cropBoxElement = this.modal.querySelector('#cropBox');
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
|
||||
cropBoxElement.style.left = `${this.cropBox.x * overlayRect.width}px`;
|
||||
cropBoxElement.style.top = `${this.cropBox.y * overlayRect.height}px`;
|
||||
cropBoxElement.style.width = `${this.cropBox.width * overlayRect.width}px`;
|
||||
cropBoxElement.style.height = `${this.cropBox.height * overlayRect.height}px`;
|
||||
}
|
||||
|
||||
rotate(degrees) {
|
||||
this.rotation = (this.rotation + degrees) % 360;
|
||||
this.redraw();
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
this.ctx.save();
|
||||
|
||||
// Перемещаем к центру
|
||||
this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
|
||||
|
||||
// Поворот
|
||||
this.ctx.rotate(this.rotation * Math.PI / 180);
|
||||
|
||||
// Отражение
|
||||
this.ctx.scale(this.flipHorizontal ? -1 : 1, this.flipVertical ? -1 : 1);
|
||||
|
||||
// Рисуем изображение
|
||||
this.ctx.drawImage(
|
||||
this.image,
|
||||
-this.canvas.width / 2,
|
||||
-this.canvas.height / 2,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
resetCrop() {
|
||||
// Сброс crop box к исходному состоянию
|
||||
this.cropBox = { x: 0.2, y: 0.2, width: 0.6, height: 0.6 };
|
||||
this.updateCropBoxDisplay(this.modal.querySelector('#cropOverlay'));
|
||||
this.updateInfo();
|
||||
}
|
||||
|
||||
updateInfo() {
|
||||
const imageInfo = this.modal.querySelector('#imageInfo');
|
||||
const cropInfo = this.modal.querySelector('#cropInfo');
|
||||
|
||||
imageInfo.textContent = `Размер: ${this.image.width}x${this.image.height}`;
|
||||
|
||||
const cropWidth = Math.round(this.cropBox.width * this.image.width);
|
||||
const cropHeight = Math.round(this.cropBox.height * this.image.height);
|
||||
cropInfo.textContent = `Обрезка: ${cropWidth}x${cropHeight}`;
|
||||
}
|
||||
|
||||
async save() {
|
||||
const saveBtn = this.modal.querySelector('#saveBtn');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Сохранение...';
|
||||
|
||||
try {
|
||||
const cropData = {
|
||||
x: this.cropBox.x * this.image.width,
|
||||
y: this.cropBox.y * this.image.height,
|
||||
width: this.cropBox.width * this.image.width,
|
||||
height: this.cropBox.height * this.image.height
|
||||
};
|
||||
|
||||
const response = await fetch('/api/images/process-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tempId: this.imageData.tempId,
|
||||
rotation: this.rotation,
|
||||
flipHorizontal: this.flipHorizontal,
|
||||
flipVertical: this.flipVertical,
|
||||
cropData,
|
||||
targetFolder: this.options.targetFolder
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.onSave(result.url);
|
||||
this.close();
|
||||
} else {
|
||||
alert(result.error || 'Ошибка сохранения');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
alert('Ошибка сохранения изображения');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Сохранить';
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
// Удаляем временный файл
|
||||
if (this.imageData && this.imageData.tempId) {
|
||||
fetch(`/api/images/temp-image/${this.imageData.tempId}`, {
|
||||
method: 'DELETE'
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
this.onCancel();
|
||||
if (this.modal) {
|
||||
this.modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Глобально доступная функция для открытия редактора
|
||||
window.openImageEditor = function(options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const editor = new ImageEditor({
|
||||
...options,
|
||||
onSave: (url) => resolve(url),
|
||||
onCancel: () => reject(new Error('Canceled'))
|
||||
});
|
||||
editor.show();
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user