🚀 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:
2025-11-30 00:53:15 +09:00
parent ed871fc4d1
commit b4e513e996
36 changed files with 6894 additions and 239 deletions

View File

@@ -1,5 +1,96 @@
/* Korea Tourism Agency Admin Panel Custom Scripts */
// Функция для открытия редактора изображений
function openImageEditor(fieldName, currentValue) {
const editorUrl = `/image-editor.html?field=${fieldName}&current=${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) {

View 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>

View 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}&current=${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);
})();

View 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}&current=${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
View 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">&times;</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();
});
};