// 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(); } }); } // Optimistic UI for like/dislike buttons document.querySelectorAll('form[action*="/like/"]').forEach(form => { form.addEventListener('submit', (e) => { const button = form.querySelector('button[type="submit"]'); if (button) { button.style.opacity = '0.5'; button.textContent = button.textContent + ' ✓'; } }); }); } // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initRelativeTimestamps); } else { initRelativeTimestamps(); } // Scroll to top button functionality function initScrollToTop() { const scrollButton = document.createElement('button'); scrollButton.className = 'scroll-to-top'; scrollButton.innerHTML = '↑'; scrollButton.setAttribute('aria-label', 'Scroll to top'); scrollButton.title = 'Scroll to top'; document.body.appendChild(scrollButton); // Show/hide button based on scroll position window.addEventListener('scroll', () => { if (window.pageYOffset > 300) { scrollButton.classList.add('visible'); } else { scrollButton.classList.remove('visible'); } }); // Scroll to top when clicked scrollButton.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); } // Keyboard shortcuts function initKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Ctrl+Enter or Cmd+Enter to submit forms if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { const activeElement = document.activeElement; if (activeElement && activeElement.tagName === 'TEXTAREA') { const form = activeElement.closest('form'); if (form) { e.preventDefault(); form.requestSubmit(); } } } // Escape to cancel/close things if (e.key === 'Escape') { // Clear any active focus from textareas if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') { document.activeElement.blur(); } // Hide notifications document.querySelectorAll('.notification').forEach(notif => { notif.classList.add('hiding'); setTimeout(() => notif.remove(), 300); }); } }); } // Add form submission handlers document.addEventListener('DOMContentLoaded', () => { // Initialize scroll to top and keyboard shortcuts initScrollToTop(); initKeyboardShortcuts(); // 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); } }); }); }); // ============================================ // Draft Auto-Save Functions // ============================================ // Save draft to localStorage function saveDraft(key, content) { try { localStorage.setItem(key, content); localStorage.setItem(key + '_timestamp', Date.now().toString()); } catch (e) { console.error('Failed to save draft:', e); } } // Load draft from localStorage function loadDraft(key) { try { return localStorage.getItem(key); } catch (e) { console.error('Failed to load draft:', e); return null; } } // Clear draft from localStorage function clearDraft(key) { try { localStorage.removeItem(key); localStorage.removeItem(key + '_timestamp'); } catch (e) { console.error('Failed to clear draft:', e); } } // Get draft timestamp function getDraftTimestamp(key) { try { const timestamp = localStorage.getItem(key + '_timestamp'); return timestamp ? parseInt(timestamp) : null; } catch (e) { console.error('Failed to get draft timestamp:', e); return null; } } // Format time ago function formatTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; return 'just now'; } // Show draft restoration indicator function showDraftIndicator(content, timestamp, onRestore, onDiscard) { const indicator = document.createElement('div'); indicator.className = 'draft-indicator'; indicator.innerHTML = `
📝 Draft from ${formatTimeAgo(timestamp)}
`; const restoreBtn = indicator.querySelector('.restore'); const discardBtn = indicator.querySelector('.discard'); restoreBtn.addEventListener('click', () => { onRestore(content); indicator.remove(); }); discardBtn.addEventListener('click', () => { onDiscard(); indicator.remove(); }); return indicator; } // ============================================ // Markdown Preview Functions // ============================================ // Escape HTML to prevent XSS function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Process inline markdown (bold, italic, code, mentions) function processInlineMarkdown(line) { // Inline code first (to avoid processing markdown inside code) line = line.replace(/`([^`]+)`/g, '$1'); // Bold: **text** or __text__ line = line.replace(/\*\*([^\*]+)\*\*/g, '$1'); line = line.replace(/__([^_]+)__/g, '$1'); // Italic: *text* or _text_ line = line.replace(/\*([^\*]+)\*/g, '$1'); line = line.replace(/_([^_]+)_/g, '$1'); // Mentions: @username line = line.replace(/@(\w+)/g, '@$1'); return line; } // Render markdown to HTML (matching Go implementation) function renderMarkdownPreview(content) { let html = ''; // Extract code blocks first and replace with placeholders const codeBlocks = []; let codeBlockCounter = 0; content = content.replace(/```(\w*)\n([\s\S]*?)\n```/g, (match, lang, code) => { const escapedCode = escapeHTML(code); let renderedBlock; if (lang) { renderedBlock = `
${escapedCode}
`; } else { renderedBlock = `
${escapedCode}
`; } const placeholder = ``; codeBlocks[codeBlockCounter] = renderedBlock; codeBlockCounter++; return placeholder; }); // Process lines const lines = content.split('\n'); let inList = false; for (const line of lines) { const trimmedLine = line.trim(); // Headers if (trimmedLine.startsWith('### ')) { if (inList) { html += '\n'; inList = false; } html += '

' + processInlineMarkdown(trimmedLine.substring(4)) + '

\n'; continue; } else if (trimmedLine.startsWith('## ')) { if (inList) { html += '\n'; inList = false; } html += '

' + processInlineMarkdown(trimmedLine.substring(3)) + '

\n'; continue; } else if (trimmedLine.startsWith('# ')) { if (inList) { html += '\n'; inList = false; } html += '

' + processInlineMarkdown(trimmedLine.substring(2)) + '

\n'; continue; } // Lists if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) { if (!inList) { html += '\n'; inList = false; } // Regular paragraphs if (trimmedLine !== '') { html += '

' + processInlineMarkdown(trimmedLine) + '

\n'; } else { html += '\n'; } } // Close list if still open if (inList) { html += '\n'; } // Replace code block placeholders codeBlocks.forEach((block, index) => { html = html.replace(``, block); }); // Clean up excessive newlines html = html.replace(/\n{3,}/g, '\n\n'); return html; }