diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..82d3605 --- /dev/null +++ b/static/app.js @@ -0,0 +1,327 @@ +// ThreadR UI Enhancement JavaScript + +// Show notification toast +function showNotification(message, type = 'info', duration = 3000) { + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.textContent = message; + document.body.appendChild(notification); + + setTimeout(() => { + notification.classList.add('hiding'); + setTimeout(() => { + document.body.removeChild(notification); + }, 300); + }, duration); +} + +// Add loading state to form submission +function handleFormSubmit(form, button) { + if (button) { + button.disabled = true; + button.classList.add('loading'); + } + + // Find all submit buttons in the form and disable them + const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]'); + submitButtons.forEach(btn => { + btn.disabled = true; + btn.classList.add('loading'); + }); +} + +// Remove loading state +function removeLoadingState(form) { + const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]'); + submitButtons.forEach(btn => { + btn.disabled = false; + btn.classList.remove('loading'); + }); +} + +// Enable Enter-to-submit for single-line inputs +function enableEnterToSubmit(input, form) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + form.requestSubmit(); + } + }); +} + +// Auto-resize textarea as user types +function autoResizeTextarea(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = textarea.scrollHeight + 'px'; +} + +// Add character counter to textarea +function addCharacterCounter(textarea, maxLength) { + const counter = document.createElement('div'); + counter.className = 'char-counter'; + textarea.parentNode.insertBefore(counter, textarea.nextSibling); + + function updateCounter() { + const length = textarea.value.length; + counter.textContent = `${length}${maxLength ? '/' + maxLength : ''} characters`; + + if (maxLength && length > maxLength * 0.9) { + counter.classList.add('warning'); + } else { + counter.classList.remove('warning'); + } + } + + textarea.addEventListener('input', updateCounter); + updateCounter(); +} + +// Client-side validation helpers +function validateUsername(username) { + if (username.length < 3) { + return 'Username must be at least 3 characters'; + } + if (username.length > 30) { + return 'Username must be less than 30 characters'; + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return 'Username can only contain letters, numbers, underscores, and hyphens'; + } + return null; +} + +function validatePassword(password) { + if (password.length < 8) { + return 'Password must be at least 8 characters'; + } + if (password.length > 128) { + return 'Password is too long'; + } + return null; +} + +function validateRequired(value, fieldName) { + if (!value || value.trim() === '') { + return `${fieldName} is required`; + } + return null; +} + +// Show field error +function showFieldError(input, message) { + input.classList.add('error'); + + // Remove existing error message + const existingError = input.parentNode.querySelector('.field-error'); + if (existingError) { + existingError.remove(); + } + + if (message) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'field-error'; + errorDiv.textContent = message; + input.parentNode.insertBefore(errorDiv, input.nextSibling); + } +} + +// Clear field error +function clearFieldError(input) { + input.classList.remove('error'); + const errorDiv = input.parentNode.querySelector('.field-error'); + if (errorDiv) { + errorDiv.remove(); + } +} + +// Relative time formatting +function formatRelativeTime(date) { + const now = new Date(); + const diff = now - date; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (seconds < 60) return 'just now'; + if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; + if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`; + if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`; + return `${years} year${years !== 1 ? 's' : ''} ago`; +} + +// Convert timestamps to relative time +function initRelativeTimestamps() { + document.querySelectorAll('[data-timestamp]').forEach(element => { + const timestamp = element.getAttribute('data-timestamp'); + const date = new Date(timestamp); + const originalText = element.textContent; + + element.textContent = formatRelativeTime(date); + element.title = originalText; + element.style.cursor = 'help'; + }); +} + +// Initialize on DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initRelativeTimestamps); +} else { + initRelativeTimestamps(); +} + +// Add form submission handlers +document.addEventListener('DOMContentLoaded', () => { + // Handle all form submissions + document.querySelectorAll('form').forEach(form => { + form.addEventListener('submit', (e) => { + const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); + handleFormSubmit(form, submitButton); + }); + }); + + // Auto-resize textareas + document.querySelectorAll('textarea').forEach(textarea => { + textarea.addEventListener('input', () => autoResizeTextarea(textarea)); + + // Add character counter for content fields + if (textarea.id === 'content' || textarea.name === 'content') { + addCharacterCounter(textarea, 10000); + } + if (textarea.id === 'bio' || textarea.name === 'bio') { + addCharacterCounter(textarea, 500); + } + }); + + // Enable Enter-to-submit for single-line forms (login, etc.) + const loginForm = document.querySelector('form[action*="login"]'); + if (loginForm) { + const passwordInput = loginForm.querySelector('input[type="password"]'); + if (passwordInput) { + enableEnterToSubmit(passwordInput, loginForm); + } + } + + // Add validation to login form + const loginUsername = document.querySelector('input[name="username"]'); + const loginPassword = document.querySelector('input[name="password"]'); + + if (loginUsername && loginPassword) { + loginUsername.addEventListener('blur', () => { + const error = validateRequired(loginUsername.value, 'Username'); + if (error) { + showFieldError(loginUsername, error); + } else { + clearFieldError(loginUsername); + } + }); + + loginPassword.addEventListener('blur', () => { + const error = validateRequired(loginPassword.value, 'Password'); + if (error) { + showFieldError(loginPassword, error); + } else { + clearFieldError(loginPassword); + } + }); + } + + // Add validation to signup form + const signupForm = document.querySelector('form[action*="signup"]'); + if (signupForm) { + const usernameInput = signupForm.querySelector('input[name="username"]'); + const passwordInput = signupForm.querySelector('input[name="password"]'); + const confirmInput = signupForm.querySelector('input[name="password_confirm"]'); + + if (usernameInput) { + usernameInput.addEventListener('blur', () => { + const error = validateUsername(usernameInput.value); + if (error) { + showFieldError(usernameInput, error); + } else { + clearFieldError(usernameInput); + } + }); + } + + if (passwordInput) { + passwordInput.addEventListener('blur', () => { + const error = validatePassword(passwordInput.value); + if (error) { + showFieldError(passwordInput, error); + } else { + clearFieldError(passwordInput); + } + }); + } + + if (confirmInput && passwordInput) { + confirmInput.addEventListener('blur', () => { + if (confirmInput.value !== passwordInput.value) { + showFieldError(confirmInput, 'Passwords do not match'); + } else { + clearFieldError(confirmInput); + } + }); + } + + signupForm.addEventListener('submit', (e) => { + let hasError = false; + + if (usernameInput) { + const error = validateUsername(usernameInput.value); + if (error) { + showFieldError(usernameInput, error); + hasError = true; + } + } + + if (passwordInput) { + const error = validatePassword(passwordInput.value); + if (error) { + showFieldError(passwordInput, error); + hasError = true; + } + } + + if (confirmInput && passwordInput && confirmInput.value !== passwordInput.value) { + showFieldError(confirmInput, 'Passwords do not match'); + hasError = true; + } + + if (hasError) { + e.preventDefault(); + removeLoadingState(signupForm); + showNotification('Please fix the errors before submitting', 'error'); + } + }); + } + + // Add validation to thread/post forms + document.querySelectorAll('input[name="title"]').forEach(input => { + input.addEventListener('blur', () => { + const error = validateRequired(input.value, 'Title'); + if (error) { + showFieldError(input, error); + } else if (input.value.length > 255) { + showFieldError(input, 'Title is too long (max 255 characters)'); + } else { + clearFieldError(input); + } + }); + }); + + document.querySelectorAll('textarea[name="content"]').forEach(textarea => { + textarea.addEventListener('blur', () => { + const error = validateRequired(textarea.value, 'Content'); + if (error) { + showFieldError(textarea, error); + } else { + clearFieldError(textarea); + } + }); + }); +}); diff --git a/static/style.css b/static/style.css index eda23dd..c13d44d 100644 --- a/static/style.css +++ b/static/style.css @@ -422,6 +422,175 @@ p.thread-info { } } +/* Loading spinner */ +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 3px solid #fef6e4; + border-top: 3px solid #001858; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 8px; + vertical-align: middle; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Loading state for buttons */ +button:disabled, input[type="submit"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button.loading, input[type="submit"].loading { + position: relative; + color: transparent; +} + +button.loading::after, input[type="submit"].loading::after { + content: ""; + position: absolute; + width: 16px; + height: 16px; + top: 50%; + left: 50%; + margin-left: -8px; + margin-top: -8px; + border: 3px solid #fef6e4; + border-top: 3px solid transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Success/error message notifications */ +.notification { + position: fixed; + top: 80px; + right: 20px; + padding: 14px 20px; + border-radius: 5px; + box-shadow: 0px 4px 12px rgba(0,0,0,0.3); + z-index: 1001; + animation: slideIn 0.3s ease-out; + max-width: 400px; + font-family: monospace; +} + +.notification.success { + background-color: #8bd3dd; + color: #001858; + border: 1px solid #001858; +} + +.notification.error { + background-color: #f582ae; + color: #fef6e4; + border: 1px solid #001858; +} + +.notification.info { + background-color: #f3d2c1; + color: #001858; + border: 1px solid #001858; +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(400px); + opacity: 0; + } +} + +.notification.hiding { + animation: slideOut 0.3s ease-out forwards; +} + +/* Form validation styles */ +input.error, textarea.error, select.error { + border-color: #f582ae; + border-width: 2px; +} + +.field-error { + color: #f582ae; + font-size: 0.9em; + margin-top: 4px; + margin-bottom: 8px; + display: block; +} + +/* Character counter */ +.char-counter { + text-align: right; + font-size: 0.85em; + color: #001858; + opacity: 0.7; + margin-top: 4px; +} + +.char-counter.warning { + color: #f582ae; + font-weight: bold; +} + +@media (prefers-color-scheme: dark) { + .spinner { + border-color: #444; + border-top-color: #fef6e4; + } + + button.loading::after, input[type="submit"].loading::after { + border-color: #001858; + border-top-color: transparent; + } + + .notification.success { + background-color: #8bd3dd; + color: #001858; + } + + .notification.error { + background-color: #f582ae; + color: #001858; + } + + .notification.info { + background-color: #555; + color: #fef6e4; + } + + input.error, textarea.error, select.error { + border-color: #f582ae; + } + + .field-error { + color: #f582ae; + } + + .char-counter { + color: #fef6e4; + } +} + @media (max-width: 600px) { ul.topnav li { float: none; @@ -441,4 +610,10 @@ p.thread-info { .thread-posts { width: 95%; } + + .notification { + right: 10px; + left: 10px; + max-width: none; + } } \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 85b8843..a41938a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,6 +4,7 @@
Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}
+Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}