Files
tourrism_site/public/js/image-editor.js
Andrey K. Choi b4e513e996 🚀 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
2025-11-30 00:53:15 +09:00

690 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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