Initial commit: Korea Tourism Agency website with AdminJS

- Full-stack Node.js/Express application with PostgreSQL
- Modern ES modules architecture
- AdminJS admin panel with Sequelize ORM
- Tourism routes, guides, articles, bookings management
- Responsive Bootstrap 5 frontend
- Docker containerization with docker-compose
- Complete database schema with migrations
- Authentication system for admin panel
- Dynamic placeholder images for tour categories
This commit is contained in:
2025-11-29 18:13:17 +09:00
commit 409e6c146b
53 changed files with 16195 additions and 0 deletions

288
public/js/admin-custom.js Normal file
View File

@@ -0,0 +1,288 @@
/* Korea Tourism Agency Admin Panel Custom Scripts */
$(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);
// 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">&times;</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();
}
};

391
public/js/main.js Normal file
View File

@@ -0,0 +1,391 @@
/**
* Korea Tourism Agency - Main JavaScript
* Основные интерактивные функции для сайта
*/
document.addEventListener('DOMContentLoaded', function() {
// ==========================================
// Инициализация AOS (Animate On Scroll)
// ==========================================
if (typeof AOS !== 'undefined') {
AOS.init({
duration: 800,
easing: 'ease-in-out',
once: true,
offset: 100
});
}
// ==========================================
// Навигация и мобильное меню
// ==========================================
const navbar = document.querySelector('.navbar');
// Добавление класса при скролле
window.addEventListener('scroll', function() {
if (window.scrollY > 100) {
if (navbar) navbar.classList.add('scrolled');
} else {
if (navbar) navbar.classList.remove('scrolled');
}
});
// ==========================================
// Поиск по сайту
// ==========================================
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (searchInput && searchResults) {
let searchTimeout;
searchInput.addEventListener('input', function() {
const query = this.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.style.display = 'none';
return;
}
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
});
// Скрытие результатов при клике вне поиска
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.style.display = 'none';
}
});
}
async function performSearch(query) {
try {
const response = await fetch('/api/search?q=' + encodeURIComponent(query));
const data = await response.json();
displaySearchResults(data);
} catch (error) {
console.error('Search error:', error);
}
}
function displaySearchResults(results) {
if (!searchResults) return;
let html = '';
if (results.routes && results.routes.length > 0) {
html += '<div class="search-category"><h6>투어</h6>';
results.routes.forEach(route => {
html += '<div class="search-item">';
html += '<a href="/routes/' + route.id + '">';
html += '<strong>' + (route.name_ko || route.name_en) + '</strong>';
html += '<small class="text-muted d-block">' + route.location + '</small>';
html += '</a></div>';
});
html += '</div>';
}
if (results.guides && results.guides.length > 0) {
html += '<div class="search-category"><h6>가이드</h6>';
results.guides.forEach(guide => {
html += '<div class="search-item">';
html += '<a href="/guides/' + guide.id + '">';
html += '<strong>' + guide.name + '</strong>';
html += '<small class="text-muted d-block">' + guide.specialization + '</small>';
html += '</a></div>';
});
html += '</div>';
}
if (results.articles && results.articles.length > 0) {
html += '<div class="search-category"><h6>기사</h6>';
results.articles.forEach(article => {
html += '<div class="search-item">';
html += '<a href="/articles/' + article.id + '">';
html += '<strong>' + (article.title_ko || article.title_en) + '</strong>';
html += '</a></div>';
});
html += '</div>';
}
if (!html) {
html = '<div class="search-item text-muted">검색 결과가 없습니다</div>';
}
searchResults.innerHTML = html;
searchResults.style.display = 'block';
}
// ==========================================
// Фильтрация туров
// ==========================================
const routeFilters = document.querySelectorAll('.route-filter');
const routeCards = document.querySelectorAll('.route-card');
routeFilters.forEach(filter => {
filter.addEventListener('click', function() {
const category = this.dataset.category;
// Обновление активного фильтра
routeFilters.forEach(f => f.classList.remove('active'));
this.classList.add('active');
// Фильтрация карточек
routeCards.forEach(card => {
if (category === 'all' || card.dataset.category === category) {
card.style.display = 'block';
card.classList.add('fade-in');
} else {
card.style.display = 'none';
card.classList.remove('fade-in');
}
});
});
});
// ==========================================
// Форма бронирования
// ==========================================
const bookingForm = document.getElementById('booking-form');
if (bookingForm) {
bookingForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 전송 중...';
try {
const formData = new FormData(this);
const response = await fetch('/api/booking', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', '예약 요청이 성공적으로 전송되었습니다!');
this.reset();
} else {
showAlert('danger', result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error('Booking error:', error);
showAlert('danger', '네트워크 오류가 발생했습니다.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// ==========================================
// Форма контактов
// ==========================================
const contactForm = document.getElementById('contact-form');
if (contactForm) {
contactForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 전송 중...';
try {
const formData = new FormData(this);
const response = await fetch('/api/contact', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', '메시지가 성공적으로 전송되었습니다!');
this.reset();
} else {
showAlert('danger', result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error('Contact error:', error);
showAlert('danger', '네트워크 오류가 발생했습니다.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// ==========================================
// Галерея изображений
// ==========================================
const galleryItems = document.querySelectorAll('.gallery-item');
galleryItems.forEach(item => {
item.addEventListener('click', function() {
const img = this.querySelector('img');
if (img) {
showImageModal(img.src, img.alt || 'Gallery Image');
}
});
});
function showImageModal(src, alt) {
const modal = document.createElement('div');
modal.className = 'image-modal';
modal.innerHTML =
'<div class="image-modal-backdrop">' +
'<div class="image-modal-content">' +
'<button class="image-modal-close" type="button">' +
'<i class="fas fa-times"></i>' +
'</button>' +
'<img src="' + src + '" alt="' + alt + '" class="img-fluid">' +
'</div>' +
'</div>';
document.body.appendChild(modal);
setTimeout(() => modal.classList.add('show'), 10);
// Закрытие модального окна
const closeModal = () => {
modal.classList.remove('show');
setTimeout(() => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
}, 300);
};
modal.querySelector('.image-modal-close').addEventListener('click', closeModal);
modal.querySelector('.image-modal-backdrop').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
}
// ==========================================
// Плавная прокрутка к секциям
// ==========================================
const scrollLinks = document.querySelectorAll('a[href^="#"]');
scrollLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// ==========================================
// Валидация форм
// ==========================================
const forms = document.querySelectorAll('.needs-validation');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
});
});
// ==========================================
// Tooltips и Popovers
// ==========================================
if (typeof bootstrap !== 'undefined') {
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
}
// ==========================================
// Lazy loading изображений
// ==========================================
const lazyImages = document.querySelectorAll('img[data-src]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
imageObserver.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
} else {
// Fallback для старых браузеров
lazyImages.forEach(img => {
img.src = img.dataset.src;
img.classList.remove('lazy');
});
}
// ==========================================
// Утилитарные функции
// ==========================================
function showAlert(type, message) {
const alertContainer = document.getElementById('alert-container') || createAlertContainer();
const alert = document.createElement('div');
alert.className = 'alert alert-' + type + ' alert-dismissible fade show';
alert.innerHTML =
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>';
alertContainer.appendChild(alert);
// Автоскрытие через 5 секунд
setTimeout(() => {
if (alert.parentNode) {
alert.remove();
}
}, 5000);
}
function createAlertContainer() {
const container = document.createElement('div');
container.id = 'alert-container';
container.className = 'position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
document.body.appendChild(container);
return container;
}
console.log('Korea Tourism Agency - JavaScript loaded successfully! 🇰🇷');
});