✨ 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
543 lines
20 KiB
HTML
543 lines
20 KiB
HTML
<!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> |