Files
tourrism_site/public/image-editor-compact.html
Andrey K. Choi b4e513e996 🚀 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
2025-11-30 00:53:15 +09:00

543 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>