✨ 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
406 lines
13 KiB
JavaScript
406 lines
13 KiB
JavaScript
/* Korea Tourism Agency Admin Panel Custom Scripts */
|
||
|
||
// Функция для открытия редактора изображений
|
||
function openImageEditor(fieldName, currentValue) {
|
||
const editorUrl = `/image-editor.html?field=${fieldName}¤t=${encodeURIComponent(currentValue || '')}`;
|
||
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||
|
||
// Слушаем сообщения от редактора
|
||
const messageHandler = (event) => {
|
||
if (event.origin !== window.location.origin) return;
|
||
|
||
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||
if (field) {
|
||
field.value = event.data.path;
|
||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
||
// Обновляем превью если есть
|
||
updateImagePreview(fieldName, event.data.path);
|
||
}
|
||
|
||
window.removeEventListener('message', messageHandler);
|
||
editorWindow.close();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', messageHandler);
|
||
|
||
// Очистка обработчика при закрытии окна
|
||
const checkClosed = setInterval(() => {
|
||
if (editorWindow.closed) {
|
||
window.removeEventListener('message', messageHandler);
|
||
clearInterval(checkClosed);
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// Функция обновления превью изображения
|
||
function updateImagePreview(fieldName, imagePath) {
|
||
const previewId = `${fieldName}_preview`;
|
||
let preview = document.getElementById(previewId);
|
||
|
||
if (!preview) {
|
||
// Создаем превью если его нет
|
||
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||
if (field) {
|
||
preview = document.createElement('img');
|
||
preview.id = previewId;
|
||
preview.className = 'img-thumbnail mt-2';
|
||
preview.style.maxWidth = '200px';
|
||
preview.style.maxHeight = '200px';
|
||
field.parentNode.appendChild(preview);
|
||
}
|
||
}
|
||
|
||
if (preview) {
|
||
preview.src = imagePath || '/images/placeholders/no-image.png';
|
||
preview.alt = 'Preview';
|
||
}
|
||
}
|
||
|
||
// Функция добавления кнопки редактора к полю
|
||
function addImageEditorButton(field) {
|
||
const fieldName = field.name || field.id;
|
||
if (!fieldName) return;
|
||
|
||
// Проверяем, не добавлена ли уже кнопка
|
||
if (field.parentNode.querySelector('.image-editor-btn')) return;
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'input-group';
|
||
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'btn btn-outline-secondary image-editor-btn';
|
||
button.innerHTML = '<i class="fas fa-images"></i> Выбрать';
|
||
button.onclick = () => openImageEditor(fieldName, field.value);
|
||
|
||
const buttonWrapper = document.createElement('div');
|
||
buttonWrapper.className = 'input-group-append';
|
||
buttonWrapper.appendChild(button);
|
||
|
||
// Перестраиваем структуру
|
||
field.parentNode.insertBefore(wrapper, field);
|
||
wrapper.appendChild(field);
|
||
wrapper.appendChild(buttonWrapper);
|
||
|
||
// Добавляем превью если есть значение
|
||
if (field.value) {
|
||
updateImagePreview(fieldName, field.value);
|
||
}
|
||
}
|
||
|
||
$(document).ready(function() {
|
||
// Initialize tooltips
|
||
$('[data-toggle="tooltip"]').tooltip();
|
||
|
||
// Initialize popovers
|
||
$('[data-toggle="popover"]').popover();
|
||
|
||
// Auto-hide alerts after 5 seconds
|
||
setTimeout(function() {
|
||
$('.alert').fadeOut('slow');
|
||
}, 5000);
|
||
|
||
// Добавляем кнопки редактора к полям изображений
|
||
$('input[type="text"], input[type="url"]').each(function() {
|
||
const field = this;
|
||
const fieldName = field.name || field.id || '';
|
||
|
||
// Проверяем, относится ли поле к изображениям
|
||
if (fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner') ||
|
||
$(field).closest('label').text().toLowerCase().includes('изображение') ||
|
||
$(field).closest('label').text().toLowerCase().includes('картинка') ||
|
||
$(field).closest('label').text().toLowerCase().includes('фото')) {
|
||
addImageEditorButton(field);
|
||
}
|
||
});
|
||
|
||
// Обработчик для динамически добавляемых полей
|
||
$(document).on('focus', 'input[type="text"], input[type="url"]', function() {
|
||
const field = this;
|
||
const fieldName = field.name || field.id || '';
|
||
|
||
if ((fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner')) &&
|
||
!field.parentNode.querySelector('.image-editor-btn')) {
|
||
addImageEditorButton(field);
|
||
}
|
||
});
|
||
|
||
// Confirm delete actions
|
||
$('.btn-delete').on('click', function(e) {
|
||
e.preventDefault();
|
||
const item = $(this).data('item') || 'item';
|
||
const url = $(this).attr('href') || $(this).data('url');
|
||
|
||
if (confirm(`Are you sure you want to delete this ${item}?`)) {
|
||
if ($(this).data('method') === 'DELETE') {
|
||
// AJAX delete
|
||
$.ajax({
|
||
url: url,
|
||
method: 'DELETE',
|
||
success: function(response) {
|
||
if (response.success) {
|
||
location.reload();
|
||
} else {
|
||
alert('Error: ' + response.message);
|
||
}
|
||
},
|
||
error: function() {
|
||
alert('An error occurred while deleting.');
|
||
}
|
||
});
|
||
} else {
|
||
// Regular form submission or redirect
|
||
window.location.href = url;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Form validation enhancement
|
||
$('form').on('submit', function() {
|
||
const submitBtn = $(this).find('button[type="submit"]');
|
||
submitBtn.prop('disabled', true);
|
||
submitBtn.html('<i class="fas fa-spinner fa-spin"></i> Processing...');
|
||
});
|
||
|
||
// Image preview functionality
|
||
function readURL(input, target) {
|
||
if (input.files && input.files[0]) {
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
$(target).attr('src', e.target.result).show();
|
||
};
|
||
reader.readAsDataURL(input.files[0]);
|
||
}
|
||
}
|
||
|
||
$('input[type="file"]').on('change', function() {
|
||
const targetImg = $(this).closest('.form-group').find('.img-preview');
|
||
if (targetImg.length) {
|
||
readURL(this, targetImg);
|
||
}
|
||
});
|
||
|
||
// Auto-save draft functionality for forms
|
||
let autoSaveTimer;
|
||
$('textarea, input[type="text"]').on('input', function() {
|
||
clearTimeout(autoSaveTimer);
|
||
autoSaveTimer = setTimeout(function() {
|
||
// Auto-save logic here
|
||
console.log('Auto-saving draft...');
|
||
}, 2000);
|
||
});
|
||
|
||
// Enhanced DataTables configuration
|
||
if (typeof $.fn.dataTable !== 'undefined') {
|
||
$('.data-table').each(function() {
|
||
$(this).DataTable({
|
||
responsive: true,
|
||
lengthChange: false,
|
||
autoWidth: false,
|
||
pageLength: 25,
|
||
language: {
|
||
search: "Search:",
|
||
lengthMenu: "Show _MENU_ entries",
|
||
info: "Showing _START_ to _END_ of _TOTAL_ entries",
|
||
paginate: {
|
||
first: "First",
|
||
last: "Last",
|
||
next: "Next",
|
||
previous: "Previous"
|
||
}
|
||
},
|
||
dom: '<"row"<"col-sm-6"l><"col-sm-6"f>>' +
|
||
'<"row"<"col-sm-12"tr>>' +
|
||
'<"row"<"col-sm-5"i><"col-sm-7"p>>',
|
||
});
|
||
});
|
||
}
|
||
|
||
// Status toggle functionality
|
||
$('.status-toggle').on('change', function() {
|
||
const checkbox = $(this);
|
||
const id = checkbox.data('id');
|
||
const type = checkbox.data('type');
|
||
const field = checkbox.data('field');
|
||
const isChecked = checkbox.is(':checked');
|
||
|
||
$.ajax({
|
||
url: `/admin/${type}/${id}/toggle`,
|
||
method: 'POST',
|
||
data: {
|
||
field: field,
|
||
value: isChecked
|
||
},
|
||
success: function(response) {
|
||
if (response.success) {
|
||
showNotification('Status updated successfully!', 'success');
|
||
} else {
|
||
checkbox.prop('checked', !isChecked);
|
||
showNotification('Error updating status: ' + response.message, 'error');
|
||
}
|
||
},
|
||
error: function() {
|
||
checkbox.prop('checked', !isChecked);
|
||
showNotification('Error updating status', 'error');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Notification system
|
||
function showNotification(message, type = 'info') {
|
||
const alertClass = type === 'success' ? 'alert-success' :
|
||
type === 'error' ? 'alert-danger' :
|
||
type === 'warning' ? 'alert-warning' : 'alert-info';
|
||
|
||
const notification = `
|
||
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="position: fixed; top: 20px; right: 20px; z-index: 1050; min-width: 300px;">
|
||
${message}
|
||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
$('body').append(notification);
|
||
|
||
// Auto-hide after 3 seconds
|
||
setTimeout(function() {
|
||
$('.alert').last().fadeOut('slow', function() {
|
||
$(this).remove();
|
||
});
|
||
}, 3000);
|
||
}
|
||
|
||
// Make notification function globally available
|
||
window.showNotification = showNotification;
|
||
|
||
// Quick search functionality
|
||
$('#quick-search').on('input', function() {
|
||
const searchTerm = $(this).val().toLowerCase();
|
||
const searchableElements = $('.searchable');
|
||
|
||
if (searchTerm === '') {
|
||
searchableElements.show();
|
||
} else {
|
||
searchableElements.each(function() {
|
||
const text = $(this).text().toLowerCase();
|
||
if (text.includes(searchTerm)) {
|
||
$(this).show();
|
||
} else {
|
||
$(this).hide();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Bulk actions functionality
|
||
$('#select-all').on('change', function() {
|
||
$('.item-checkbox').prop('checked', $(this).is(':checked'));
|
||
updateBulkActionButtons();
|
||
});
|
||
|
||
$('.item-checkbox').on('change', function() {
|
||
updateBulkActionButtons();
|
||
|
||
// Update select-all checkbox state
|
||
const totalCheckboxes = $('.item-checkbox').length;
|
||
const checkedCheckboxes = $('.item-checkbox:checked').length;
|
||
|
||
if (checkedCheckboxes === 0) {
|
||
$('#select-all').prop('indeterminate', false).prop('checked', false);
|
||
} else if (checkedCheckboxes === totalCheckboxes) {
|
||
$('#select-all').prop('indeterminate', false).prop('checked', true);
|
||
} else {
|
||
$('#select-all').prop('indeterminate', true);
|
||
}
|
||
});
|
||
|
||
function updateBulkActionButtons() {
|
||
const checkedItems = $('.item-checkbox:checked').length;
|
||
if (checkedItems > 0) {
|
||
$('.bulk-actions').show();
|
||
$('.bulk-count').text(checkedItems);
|
||
} else {
|
||
$('.bulk-actions').hide();
|
||
}
|
||
}
|
||
|
||
// Image upload preview
|
||
$('.image-upload').on('change', function() {
|
||
const file = this.files[0];
|
||
const preview = $(this).siblings('.image-preview');
|
||
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
preview.html(`<img src="${e.target.result}" class="img-thumbnail" style="max-width: 200px; max-height: 200px;">`);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
});
|
||
|
||
// Chart initialization (if Chart.js is available)
|
||
if (typeof Chart !== 'undefined' && $('#dashboard-chart').length) {
|
||
const ctx = document.getElementById('dashboard-chart').getContext('2d');
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||
datasets: [{
|
||
label: 'Bookings',
|
||
data: [12, 19, 3, 5, 2, 3],
|
||
borderColor: '#3b82f6',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||
tension: 0.4
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: {
|
||
position: 'top',
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Global utility functions
|
||
window.AdminUtils = {
|
||
// Format currency
|
||
formatCurrency: function(amount) {
|
||
return '₩' + new Intl.NumberFormat('ko-KR').format(amount);
|
||
},
|
||
|
||
// Format date
|
||
formatDate: function(date) {
|
||
return new Date(date).toLocaleDateString('ko-KR');
|
||
},
|
||
|
||
// Validate email
|
||
isValidEmail: function(email) {
|
||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return re.test(email);
|
||
},
|
||
|
||
// Show loading spinner
|
||
showLoading: function(element) {
|
||
$(element).addClass('loading').append('<div class="spinner"></div>');
|
||
},
|
||
|
||
// Hide loading spinner
|
||
hideLoading: function(element) {
|
||
$(element).removeClass('loading').find('.spinner').remove();
|
||
}
|
||
}; |