a3546b4c01
- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore) - Add CSRF token generation and validation on all state-changing requests - Replace random.choices with secrets.choice for all secure code generation - Add session cookie security headers (HttpOnly, Secure, SameSite) - Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning - Add email communications system: templates per event, custom email sending, sent email tracking - Add staff/organizer profile pages with staff code display - New DB tables: event_email_templates, sent_emails, user_communications - New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code - Expand English translation strings across all new features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
// NetEvent - Main JavaScript
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// ----- CSRF Token Injection -----
|
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
|
if (csrfToken) {
|
|
document.querySelectorAll('form[method="POST"], form[method="post"]').forEach(function(form) {
|
|
if (!form.querySelector('input[name="_csrf_token"]')) {
|
|
var input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = '_csrf_token';
|
|
input.value = csrfToken;
|
|
form.appendChild(input);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ----- Toast Notification System -----
|
|
const toastContainer = document.getElementById('toast-container');
|
|
|
|
function showToast(message, type = 'info', duration = 5000) {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
|
|
const iconMap = {
|
|
success: 'check-circle',
|
|
error: 'x-circle',
|
|
warning: 'alert-triangle',
|
|
info: 'info'
|
|
};
|
|
|
|
toast.innerHTML = `
|
|
<div class="toast-icon">
|
|
<i data-lucide="${iconMap[type] || 'info'}"></i>
|
|
</div>
|
|
<div class="toast-content">
|
|
<p class="toast-message">${message}</p>
|
|
</div>
|
|
<button class="toast-close" aria-label="Close">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
`;
|
|
|
|
toastContainer.appendChild(toast);
|
|
lucide.createIcons({ icons: { 'check-circle': lucide.icons['check-circle'], 'x-circle': lucide.icons['x-circle'], 'alert-triangle': lucide.icons['alert-triangle'], 'info': lucide.icons['info'], 'x': lucide.icons['x'] } });
|
|
|
|
// Close button handler
|
|
toast.querySelector('.toast-close').addEventListener('click', () => {
|
|
dismissToast(toast);
|
|
});
|
|
|
|
// Auto dismiss
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
dismissToast(toast);
|
|
}, duration);
|
|
}
|
|
|
|
return toast;
|
|
}
|
|
|
|
function dismissToast(toast) {
|
|
toast.classList.add('toast-out');
|
|
setTimeout(() => {
|
|
if (toast.parentNode) {
|
|
toast.parentNode.removeChild(toast);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
// Convert existing flash messages to toasts
|
|
const flashMessages = document.querySelectorAll('.flash-message');
|
|
flashMessages.forEach(msg => {
|
|
const message = msg.textContent.trim();
|
|
const typeMatch = message.toLowerCase().match(/\b(success|error|warning|info)\b/);
|
|
const type = typeMatch ? typeMatch[1] : 'info';
|
|
|
|
// Create toast (no auto-dismiss for flash messages, they persist)
|
|
showToast(message, type, 0);
|
|
|
|
// Remove original flash message
|
|
msg.remove();
|
|
});
|
|
|
|
// Remove flash-messages container if empty
|
|
const flashContainer = document.querySelector('.flash-messages');
|
|
if (flashContainer && flashContainer.children.length === 0) {
|
|
flashContainer.remove();
|
|
}
|
|
|
|
// ----- Hamburger Menu Toggle -----
|
|
const hamburger = document.querySelector('.hamburger');
|
|
const navLinks = document.querySelector('.nav-links');
|
|
|
|
if (hamburger && navLinks) {
|
|
hamburger.addEventListener('click', function() {
|
|
navLinks.classList.toggle('active');
|
|
|
|
const isExpanded = navLinks.classList.contains('active');
|
|
hamburger.setAttribute('aria-expanded', isExpanded);
|
|
|
|
// Toggle icon visibility using CSS classes
|
|
const menuIcon = hamburger.querySelector('.hamburger-icon');
|
|
const closeIcon = hamburger.querySelector('.hamburger-close-icon');
|
|
|
|
if (isExpanded) {
|
|
menuIcon.classList.add('d-none');
|
|
closeIcon.classList.remove('d-none');
|
|
} else {
|
|
menuIcon.classList.remove('d-none');
|
|
closeIcon.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
// Close menu when clicking a link (for mobile)
|
|
navLinks.querySelectorAll('a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
navLinks.classList.remove('active');
|
|
hamburger.setAttribute('aria-expanded', 'false');
|
|
hamburger.querySelector('.hamburger-icon').classList.remove('d-none');
|
|
hamburger.querySelector('.hamburger-close-icon').classList.add('d-none');
|
|
});
|
|
});
|
|
|
|
// Close menu when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
if (!hamburger.contains(e.target) && !navLinks.contains(e.target)) {
|
|
navLinks.classList.remove('active');
|
|
hamburger.setAttribute('aria-expanded', 'false');
|
|
const menuIcon = hamburger.querySelector('.hamburger-icon');
|
|
const closeIcon = hamburger.querySelector('.hamburger-close-icon');
|
|
if (menuIcon) menuIcon.classList.remove('d-none');
|
|
if (closeIcon) closeIcon.classList.add('d-none');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ----- Form Validation with Toast -----
|
|
const forms = document.querySelectorAll('form');
|
|
forms.forEach(form => {
|
|
form.addEventListener('submit', function(e) {
|
|
// Skip if already handled or has data-no-validate
|
|
if (e.target.dataset.noValidate) return;
|
|
|
|
const requiredInputs = form.querySelectorAll('[required]');
|
|
let isValid = true;
|
|
let firstInvalid = null;
|
|
|
|
requiredInputs.forEach(input => {
|
|
if (!input.value.trim()) {
|
|
isValid = false;
|
|
input.style.borderColor = 'var(--danger-color)';
|
|
input.style.boxShadow = '0 0 0 3px var(--danger-light)';
|
|
if (!firstInvalid) firstInvalid = input;
|
|
} else {
|
|
input.style.borderColor = '';
|
|
input.style.boxShadow = '';
|
|
}
|
|
});
|
|
|
|
if (!isValid) {
|
|
e.preventDefault();
|
|
showToast('Please fill in all required fields.', 'error');
|
|
if (firstInvalid) firstInvalid.focus();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----- Photo Upload Form -----
|
|
const photoForm = document.querySelector('.photo-upload-form');
|
|
if (photoForm) {
|
|
const submitBtn = photoForm.querySelector('button[type="submit"]');
|
|
|
|
photoForm.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
// Add loading state
|
|
if (submitBtn) {
|
|
submitBtn.classList.add('btn-loading');
|
|
submitBtn.disabled = true;
|
|
}
|
|
|
|
const formData = new FormData(this);
|
|
|
|
try {
|
|
const response = await fetch(this.action, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast('Photo updated successfully!', 'success');
|
|
// Reload page to show new photo
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showToast(data.error || 'Error uploading photo', 'error');
|
|
if (submitBtn) {
|
|
submitBtn.classList.remove('btn-loading');
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast('Error uploading photo', 'error');
|
|
if (submitBtn) {
|
|
submitBtn.classList.remove('btn-loading');
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ----- Connection Request Buttons -----
|
|
const connectForms = document.querySelectorAll('.connect-form');
|
|
connectForms.forEach(form => {
|
|
form.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const submitBtn = this.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.classList.add('btn-loading');
|
|
submitBtn.disabled = true;
|
|
}
|
|
|
|
const formData = new FormData(this);
|
|
|
|
try {
|
|
const response = await fetch(this.action, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const card = this.closest('.attendee-card');
|
|
const actionsDiv = card.querySelector('.attendee-actions');
|
|
actionsDiv.innerHTML = '<span class="badge badge-pending"><i data-lucide="clock"></i> Request Pending</span>';
|
|
lucide.createIcons();
|
|
|
|
showToast('Connection request sent!', 'success');
|
|
} else {
|
|
showToast(data.error || 'Error sending connection request', 'error');
|
|
if (submitBtn) {
|
|
submitBtn.classList.remove('btn-loading');
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast('Error sending connection request', 'error');
|
|
if (submitBtn) {
|
|
submitBtn.classList.remove('btn-loading');
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----- Button Loading States on Form Submit -----
|
|
document.querySelectorAll('form').forEach(form => {
|
|
form.addEventListener('submit', function(e) {
|
|
// Skip if already loading or novalidate
|
|
if (this.dataset.noValidate) return;
|
|
|
|
const submitBtn = this.querySelector('button[type="submit"]:not([data-no-loading])');
|
|
if (submitBtn && !submitBtn.classList.contains('btn-loading')) {
|
|
submitBtn.classList.add('btn-loading');
|
|
submitBtn.disabled = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----- Toggle Switch Initialization -----
|
|
const toggles = document.querySelectorAll('.toggle input');
|
|
toggles.forEach(toggle => {
|
|
toggle.addEventListener('change', function() {
|
|
const form = this.closest('form');
|
|
if (form) {
|
|
form.submit();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----- Modal Close on Escape -----
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
const modals = document.querySelectorAll('.modal-overlay');
|
|
modals.forEach(modal => {
|
|
const closeBtn = modal.querySelector('.modal-close');
|
|
if (closeBtn) closeBtn.click();
|
|
});
|
|
}
|
|
});
|
|
|
|
// ----- Copy Link Utility (shared across templates) -----
|
|
window.copyLink = function(url) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(url).then(function() {
|
|
showToast('Link copied!', 'success');
|
|
}).catch(function() {
|
|
fallbackCopy(url);
|
|
});
|
|
} else {
|
|
fallbackCopy(url);
|
|
}
|
|
};
|
|
|
|
window.fallbackCopy = function(url) {
|
|
var textArea = document.createElement('textarea');
|
|
textArea.value = url;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-9999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
showToast('Link copied!', 'success');
|
|
} catch (err) {
|
|
showToast('Failed to copy link.', 'error');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
};
|
|
|
|
// ----- Smooth scroll for anchor links -----
|
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
|
anchor.addEventListener('click', function(e) {
|
|
const targetId = this.getAttribute('href');
|
|
if (targetId === '#') return;
|
|
|
|
const target = document.querySelector(targetId);
|
|
if (target) {
|
|
e.preventDefault();
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ----- Custom Confirmation Modal (replaces native confirm()) -----
|
|
const confirmModal = document.getElementById('confirm-modal');
|
|
const confirmTitle = document.getElementById('confirm-title');
|
|
const confirmMessage = document.getElementById('confirm-message');
|
|
const confirmOk = document.getElementById('confirm-ok');
|
|
const confirmCancel = document.getElementById('confirm-cancel');
|
|
let confirmCallback = null;
|
|
|
|
function showConfirm(message, callback, title) {
|
|
confirmMessage.textContent = message;
|
|
confirmTitle.textContent = title || 'Confirm';
|
|
confirmModal.style.display = 'flex';
|
|
confirmCallback = callback;
|
|
confirmOk.focus();
|
|
}
|
|
|
|
function hideConfirm() {
|
|
confirmModal.style.display = 'none';
|
|
confirmCallback = null;
|
|
}
|
|
|
|
if (confirmOk) {
|
|
confirmOk.addEventListener('click', function() {
|
|
if (confirmCallback) confirmCallback();
|
|
hideConfirm();
|
|
});
|
|
}
|
|
|
|
if (confirmCancel) {
|
|
confirmCancel.addEventListener('click', hideConfirm);
|
|
}
|
|
|
|
if (confirmModal) {
|
|
confirmModal.addEventListener('click', function(e) {
|
|
if (e.target === confirmModal) hideConfirm();
|
|
});
|
|
}
|
|
|
|
// Intercept data-confirm on forms and buttons
|
|
document.addEventListener('click', function(e) {
|
|
const target = e.target.closest('[data-confirm]');
|
|
if (!target) return;
|
|
|
|
const message = target.getAttribute('data-confirm');
|
|
if (!message) return;
|
|
|
|
e.preventDefault();
|
|
showConfirm(message, function() {
|
|
if (target.tagName === 'FORM') {
|
|
target.submit();
|
|
} else if (target.tagName === 'A') {
|
|
window.location.href = target.getAttribute('href');
|
|
} else {
|
|
// For buttons, remove data-confirm and re-click
|
|
target.removeAttribute('data-confirm');
|
|
target.click();
|
|
target.setAttribute('data-confirm', message);
|
|
}
|
|
});
|
|
}, true);
|
|
}); |