🚀 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:
448
public/image-editor.html
Normal file
448
public/image-editor.html
Normal file
@@ -0,0 +1,448 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Редактор изображений - Korea Tourism</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.image-preview {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.image-gallery {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-item {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.gallery-item.selected {
|
||||
border: 3px solid #007bff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.upload-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.upload-area:hover, .upload-area.drag-over {
|
||||
border-color: #007bff;
|
||||
background: #e3f2fd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-images me-2"></i>
|
||||
Редактор изображений
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Вкладки -->
|
||||
<ul class="nav nav-tabs" id="imageEditorTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload" type="button">
|
||||
<i class="fas fa-upload me-2"></i>Загрузить
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="gallery-tab" data-bs-toggle="tab" data-bs-target="#gallery" type="button">
|
||||
<i class="fas fa-images me-2"></i>Галерея
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button">
|
||||
<i class="fas fa-link me-2"></i>По ссылке
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="imageEditorTabsContent">
|
||||
<!-- Вкладка загрузки -->
|
||||
<div class="tab-pane fade show active" id="upload" role="tabpanel">
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
|
||||
<p class="mb-2">Перетащите файлы сюда или нажмите для выбора</p>
|
||||
<small class="text-muted">Поддерживаются: JPG, PNG, GIF (макс. 5МБ)</small>
|
||||
<input type="file" id="fileInput" class="d-none" multiple accept="image/*">
|
||||
</div>
|
||||
|
||||
<!-- Прогресс загрузки -->
|
||||
<div id="uploadProgress" class="mt-3" style="display: none;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="text-center">
|
||||
<h6>Предварительный просмотр</h6>
|
||||
<img id="uploadPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
|
||||
<div class="mt-2">
|
||||
<small id="imageInfo" class="text-muted"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка галереи -->
|
||||
<div class="tab-pane fade" id="gallery" role="tabpanel">
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6>Загруженные изображения</h6>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="routes">
|
||||
<i class="fas fa-route me-1"></i>Маршруты
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="guides">
|
||||
<i class="fas fa-user-tie me-1"></i>Гиды
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="articles">
|
||||
<i class="fas fa-newspaper me-1"></i>Статьи
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm active" data-folder="all">
|
||||
<i class="fas fa-images me-1"></i>Все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-gallery">
|
||||
<div class="row" id="galleryImages">
|
||||
<!-- Изображения будут загружены динамически -->
|
||||
<div class="col-12 text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||
<p class="mt-2 text-muted">Загрузка галереи...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка URL -->
|
||||
<div class="tab-pane fade" id="url" role="tabpanel">
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-8">
|
||||
<label for="imageUrl" class="form-label">URL изображения</label>
|
||||
<div class="input-group">
|
||||
<input type="url" class="form-control" id="imageUrl" placeholder="https://example.com/image.jpg">
|
||||
<button class="btn btn-primary" type="button" id="loadUrlImage">
|
||||
<i class="fas fa-download me-1"></i>Загрузить
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">Введите прямую ссылку на изображение</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h6>Предварительный просмотр</h6>
|
||||
<img id="urlPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>Выбранное изображение:</strong>
|
||||
<span id="selectedImagePath" class="text-muted ms-2">Не выбрано</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary me-2" onclick="closeImageEditor()">
|
||||
<i class="fas fa-times me-1"></i>Отмена
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmSelection" disabled>
|
||||
<i class="fas fa-check me-1"></i>Выбрать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
let selectedImagePath = null;
|
||||
let targetField = null;
|
||||
|
||||
// Инициализация редактора
|
||||
function initImageEditor() {
|
||||
setupUploadEvents();
|
||||
setupGalleryEvents();
|
||||
setupUrlEvents();
|
||||
loadGallery('all');
|
||||
}
|
||||
|
||||
// Настройка событий загрузки
|
||||
function setupUploadEvents() {
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('drag-over');
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFiles(e.target.files);
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка файлов
|
||||
function handleFiles(files) {
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (validateFile(file)) {
|
||||
showPreview(file);
|
||||
uploadFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация файла
|
||||
function validateFile(file) {
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert('Неподдерживаемый тип файла. Используйте JPG, PNG или GIF.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
alert('Файл слишком большой. Максимальный размер: 5МБ.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Показ предварительного просмотра
|
||||
function showPreview(file) {
|
||||
const reader = new FileReader();
|
||||
const preview = document.getElementById('uploadPreview');
|
||||
const info = document.getElementById('imageInfo');
|
||||
|
||||
reader.onload = (e) => {
|
||||
preview.src = e.target.result;
|
||||
info.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Загрузка файла на сервер
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const progress = document.getElementById('uploadProgress');
|
||||
const progressBar = progress.querySelector('.progress-bar');
|
||||
|
||||
progress.style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
selectedImagePath = result.data.path;
|
||||
updateSelectedImage(selectedImagePath);
|
||||
showSuccessMessage('Изображение успешно загружено!');
|
||||
// Обновляем галерею
|
||||
setTimeout(() => loadGallery('all'), 500);
|
||||
} else {
|
||||
alert('Ошибка загрузки: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки: ' + error.message);
|
||||
} finally {
|
||||
progress.style.display = 'none';
|
||||
progressBar.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка событий галереи
|
||||
function setupGalleryEvents() {
|
||||
document.querySelectorAll('[data-folder]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('[data-folder]').forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
loadGallery(e.target.dataset.folder);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузка галереи
|
||||
async function loadGallery(folder) {
|
||||
const galleryContainer = document.getElementById('galleryImages');
|
||||
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-spinner fa-spin fa-2x text-muted"></i><p class="mt-2 text-muted">Загрузка галереи...</p></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/images/gallery?folder=${folder}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
renderGallery(result.data);
|
||||
} else {
|
||||
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка загрузки галереи</p></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка: ' + error.message + '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Отображение галереи
|
||||
function renderGallery(images) {
|
||||
const galleryContainer = document.getElementById('galleryImages');
|
||||
|
||||
if (images.length === 0) {
|
||||
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Изображения не найдены</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
galleryContainer.innerHTML = images.map(img => `
|
||||
<div class="col-md-2 col-sm-3 col-4 mb-3">
|
||||
<div class="gallery-item" data-path="${img.path}" onclick="selectGalleryImage('${img.path}')">
|
||||
<img src="${img.path}" class="img-fluid rounded" alt="${img.name}" title="${img.name}">
|
||||
<small class="d-block text-center text-muted mt-1">${img.name}</small>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Выбор изображения из галереи
|
||||
function selectGalleryImage(path) {
|
||||
document.querySelectorAll('.gallery-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
document.querySelector(`[data-path="${path}"]`).classList.add('selected');
|
||||
selectedImagePath = path;
|
||||
updateSelectedImage(path);
|
||||
}
|
||||
|
||||
// Настройка событий URL
|
||||
function setupUrlEvents() {
|
||||
const urlInput = document.getElementById('imageUrl');
|
||||
const loadBtn = document.getElementById('loadUrlImage');
|
||||
const urlPreview = document.getElementById('urlPreview');
|
||||
|
||||
loadBtn.addEventListener('click', () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (url) {
|
||||
urlPreview.src = url;
|
||||
selectedImagePath = url;
|
||||
updateSelectedImage(url);
|
||||
}
|
||||
});
|
||||
|
||||
urlInput.addEventListener('input', (e) => {
|
||||
const url = e.target.value.trim();
|
||||
if (url && isValidUrl(url)) {
|
||||
urlPreview.src = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Проверка валидности URL
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление выбранного изображения
|
||||
function updateSelectedImage(path) {
|
||||
document.getElementById('selectedImagePath').textContent = path;
|
||||
document.getElementById('confirmSelection').disabled = false;
|
||||
}
|
||||
|
||||
// Подтверждение выбора
|
||||
document.getElementById('confirmSelection').addEventListener('click', () => {
|
||||
if (selectedImagePath && window.opener) {
|
||||
// Передаем путь в родительское окно
|
||||
window.opener.postMessage({
|
||||
type: 'imageSelected',
|
||||
path: selectedImagePath,
|
||||
targetField: new URLSearchParams(window.location.search).get('field')
|
||||
}, '*');
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Закрытие редактора
|
||||
function closeImageEditor() {
|
||||
window.close();
|
||||
}
|
||||
|
||||
// Показ сообщения об успехе
|
||||
function showSuccessMessage(message) {
|
||||
// Простое уведомление
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
|
||||
alert.style.zIndex = '9999';
|
||||
alert.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.body.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.parentNode.removeChild(alert);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Инициализация при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', initImageEditor);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user