🚀 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

448
public/image-editor.html Normal file
View 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>