🚀 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

@@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Выбор изображения</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
background: #fff;
color: #333;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
}
.tabs {
display: flex;
border-bottom: 2px solid #eee;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
font-size: 14px;
color: #666;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab.active {
color: #007bff;
border-bottom-color: #007bff;
}
.tab-content {
display: none;
min-height: 300px;
}
.tab-content.active {
display: block;
}
/* Загрузка */
.upload-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
background: #fafafa;
transition: all 0.2s;
cursor: pointer;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #007bff;
background: #f0f8ff;
}
.upload-icon {
font-size: 48px;
color: #ccc;
margin-bottom: 10px;
}
.file-input {
display: none;
}
/* Галерея */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
}
.gallery-item {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.gallery-item:hover {
border-color: #ddd;
}
.gallery-item.selected {
border-color: #007bff;
box-shadow: 0 0 10px rgba(0,123,255,0.3);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* URL ввод */
.url-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
margin-bottom: 10px;
}
/* Превью */
.preview {
text-align: center;
margin: 20px 0;
}
.preview img {
max-width: 200px;
max-height: 150px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #ddd;
}
/* Кнопки */
.actions {
display: flex;
gap: 10px;
padding-top: 20px;
border-top: 1px solid #eee;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn:hover {
background: #f0f0f0;
}
.btn-primary:hover {
background: #0056b3;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="tabs">
<button class="tab active" onclick="switchTab('upload')">📤 Загрузить</button>
<button class="tab" onclick="switchTab('gallery')">🖼️ Галерея</button>
<button class="tab" onclick="switchTab('url')">🔗 По ссылке</button>
</div>
<!-- Загрузка -->
<div id="upload-tab" class="tab-content active">
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<p><strong>Выберите файл</strong> или перетащите сюда</p>
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
</div>
<input type="file" id="fileInput" class="file-input" accept="image/*">
</div>
<!-- Галерея -->
<div id="gallery-tab" class="tab-content">
<div id="galleryContent" class="loading">Загрузка галереи...</div>
</div>
<!-- URL -->
<div id="url-tab" class="tab-content">
<input type="url" id="urlInput" class="url-input" placeholder="Вставьте ссылку на изображение">
<button class="btn" onclick="previewFromUrl()">Предварительный просмотр</button>
</div>
<!-- Превью -->
<div id="preview" class="preview" style="display: none;">
<img id="previewImage" src="" alt="Превью">
</div>
<!-- Действия -->
<div class="actions">
<button class="btn" onclick="closeEditor()">Отмена</button>
<button class="btn btn-primary" onclick="selectImage()">Выбрать</button>
</div>
</div>
<script>
let selectedImageUrl = '';
let currentField = null;
// Получаем параметры из URL
const urlParams = new URLSearchParams(window.location.search);
const fieldName = urlParams.get('field') || 'image';
const currentValue = urlParams.get('current') || '';
// Настройка обработчиков загрузки
function setupUploadHandlers() {
// Обработка загрузки файла
const fileInput = document.getElementById('fileInput');
if (fileInput) {
fileInput.removeEventListener('change', handleFileSelect); // Убираем предыдущий
fileInput.addEventListener('change', handleFileSelect);
}
// Drag & Drop
const uploadZone = document.querySelector('.upload-zone');
if (uploadZone) {
// Убираем предыдущие обработчики
uploadZone.removeEventListener('dragover', handleDragOver);
uploadZone.removeEventListener('dragleave', handleDragLeave);
uploadZone.removeEventListener('drop', handleDrop);
// Добавляем новые
uploadZone.addEventListener('dragover', handleDragOver);
uploadZone.addEventListener('dragleave', handleDragLeave);
uploadZone.addEventListener('drop', handleDrop);
}
}
// Обработчики событий
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой. Максимум 5МБ.');
return;
}
uploadFile(file);
}
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add('dragover');
}
function handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove('dragover');
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой. Максимум 5МБ.');
return;
}
uploadFile(file);
} else {
showError('Пожалуйста, выберите изображение (JPG, PNG, GIF)');
}
}
}
// Показываем текущее изображение если есть
if (currentValue) {
showPreview(currentValue);
selectedImageUrl = currentValue;
}
// Инициализируем обработчики загрузки
setupUploadHandlers();
// Переключение табов
function switchTab(tabName) {
// Убираем активные классы
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// Добавляем активные классы
event.target.classList.add('active');
document.getElementById(tabName + '-tab').classList.add('active');
// Загружаем галерею при первом открытии
if (tabName === 'gallery') {
loadGallery();
}
}
// Загрузка файла на сервер
async function uploadFile(file) {
const formData = new FormData();
formData.append('image', file);
formData.append('folder', getFolderName());
try {
showLoading('Загрузка изображения...');
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
console.log('Upload result:', result);
if (result.success) {
selectedImageUrl = result.url;
showPreview(result.url);
hideLoading();
showSuccess('Изображение успешно загружено!');
} else {
throw new Error(result.error || 'Ошибка загрузки');
}
} catch (error) {
console.error('Upload error:', error);
hideLoading();
showError('Ошибка загрузки: ' + error.message);
}
}
// Загрузка галереи
async function loadGallery() {
const galleryContent = document.getElementById('galleryContent');
galleryContent.innerHTML = '<div class="loading">Загрузка галереи...</div>';
try {
const response = await fetch('/api/images/gallery');
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
console.log('Gallery result:', result);
if (result.success) {
const gallery = document.createElement('div');
gallery.className = 'gallery';
const images = result.data || result.images || [];
if (images.length === 0) {
gallery.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #666;">Нет изображений в галерее</p>';
} else {
images.forEach(image => {
const item = document.createElement('div');
item.className = 'gallery-item';
item.innerHTML = `<img src="${image.path}" alt="${image.name}" onerror="this.parentNode.style.display='none'">`;
item.onclick = () => selectFromGallery(image.path, item);
gallery.appendChild(item);
});
}
galleryContent.innerHTML = '';
galleryContent.appendChild(gallery);
} else {
throw new Error(result.error || 'Ошибка получения галереи');
}
} catch (error) {
console.error('Gallery error:', error);
galleryContent.innerHTML = `<div class="error">Ошибка загрузки галереи: ${error.message}</div>`;
}
}
// Выбор из галереи
function selectFromGallery(url, element) {
// Убираем выделение с других элементов
document.querySelectorAll('.gallery-item').forEach(item => {
item.classList.remove('selected');
});
// Выделяем выбранный элемент
element.classList.add('selected');
selectedImageUrl = url;
showPreview(url);
}
// Предпросмотр по URL
function previewFromUrl() {
const url = document.getElementById('urlInput').value.trim();
if (url) {
// Проверяем, что это валидный URL
try {
new URL(url);
selectedImageUrl = url;
showPreview(url);
showSuccess('URL изображения установлен!');
} catch (e) {
showError('Пожалуйста, введите корректный URL');
}
} else {
showError('Пожалуйста, введите URL изображения');
}
}
// Показать превью
function showPreview(url) {
const preview = document.getElementById('preview');
const previewImage = document.getElementById('previewImage');
previewImage.src = url;
preview.style.display = 'block';
previewImage.onload = function() {
console.log('Image loaded successfully:', url);
};
previewImage.onerror = function() {
showError('Не удалось загрузить изображение по указанному URL');
preview.style.display = 'none';
selectedImageUrl = '';
};
}
// Выбрать изображение
function selectImage() {
if (selectedImageUrl) {
// Отправляем выбранный URL родительскому окну
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'imageSelected',
url: selectedImageUrl,
field: fieldName
}, '*');
}
closeEditor();
} else {
showError('Выберите изображение');
}
}
// Закрыть редактор
function closeEditor() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'editorClosed'
}, '*');
}
}
// Получить имя папки из поля
function getFolderName() {
if (fieldName.includes('route') || fieldName.includes('tour')) return 'routes';
if (fieldName.includes('guide')) return 'guides';
if (fieldName.includes('article')) return 'articles';
return 'general';
}
// Показать ошибку
function showError(message) {
hideLoading();
removeMessages();
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.actions'));
setTimeout(() => errorDiv.remove(), 5000);
}
// Показать успех
function showSuccess(message) {
removeMessages();
const successDiv = document.createElement('div');
successDiv.className = 'success';
successDiv.style.cssText = `
color: #155724;
background: #d4edda;
border: 1px solid #c3e6cb;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
`;
successDiv.textContent = message;
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.actions'));
setTimeout(() => successDiv.remove(), 3000);
}
// Удалить все сообщения
function removeMessages() {
document.querySelectorAll('.error, .success').forEach(el => el.remove());
}
// Показать загрузку
function showLoading(message) {
const uploadTab = document.getElementById('upload-tab');
uploadTab.innerHTML = `<div class="loading">${message}</div>`;
}
// Скрыть загрузку
function hideLoading() {
const uploadTab = document.getElementById('upload-tab');
uploadTab.innerHTML = `
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<p><strong>Выберите файл</strong> или перетащите сюда</p>
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
</div>
<input type="file" id="fileInput" class="file-input" accept="image/*">
`;
// Переподключаем обработчики
setupUploadHandlers();
}
// Enter в поле URL
document.getElementById('urlInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
previewFromUrl();
}
});
</script>
</body>
</html>