✨ 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
448 lines
21 KiB
HTML
448 lines
21 KiB
HTML
<!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> |