489 lines
16 KiB
JavaScript
489 lines
16 KiB
JavaScript
// 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 = `
|
|
<div class="draft-indicator-content">
|
|
<span class="draft-indicator-icon">📝</span>
|
|
<span class="draft-indicator-text">Draft from ${formatTimeAgo(timestamp)}</span>
|
|
<button type="button" class="draft-indicator-button restore">Restore</button>
|
|
<button type="button" class="draft-indicator-button discard">Discard</button>
|
|
</div>
|
|
`;
|
|
|
|
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, '<code>$1</code>');
|
|
|
|
// Bold: **text** or __text__
|
|
line = line.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
|
|
line = line.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
|
|
// Italic: *text* or _text_
|
|
line = line.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
|
|
line = line.replace(/_([^_]+)_/g, '<em>$1</em>');
|
|
|
|
// Mentions: @username
|
|
line = line.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
|
|
|
|
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 = `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
|
|
} else {
|
|
renderedBlock = `<pre><code>${escapedCode}</code></pre>`;
|
|
}
|
|
const placeholder = `<!--CODEBLOCK_${codeBlockCounter}-->`;
|
|
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 += '</ul>\n'; inList = false; }
|
|
html += '<h3>' + processInlineMarkdown(trimmedLine.substring(4)) + '</h3>\n';
|
|
continue;
|
|
} else if (trimmedLine.startsWith('## ')) {
|
|
if (inList) { html += '</ul>\n'; inList = false; }
|
|
html += '<h2>' + processInlineMarkdown(trimmedLine.substring(3)) + '</h2>\n';
|
|
continue;
|
|
} else if (trimmedLine.startsWith('# ')) {
|
|
if (inList) { html += '</ul>\n'; inList = false; }
|
|
html += '<h1>' + processInlineMarkdown(trimmedLine.substring(2)) + '</h1>\n';
|
|
continue;
|
|
}
|
|
|
|
// Lists
|
|
if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
|
|
if (!inList) {
|
|
html += '<ul>\n';
|
|
inList = true;
|
|
}
|
|
const listContent = trimmedLine.substring(2);
|
|
html += '<li>' + processInlineMarkdown(listContent) + '</li>\n';
|
|
continue;
|
|
}
|
|
|
|
// Close list if we're not in a list item anymore
|
|
if (inList) {
|
|
html += '</ul>\n';
|
|
inList = false;
|
|
}
|
|
|
|
// Regular paragraphs
|
|
if (trimmedLine !== '') {
|
|
html += '<p>' + processInlineMarkdown(trimmedLine) + '</p>\n';
|
|
} else {
|
|
html += '\n';
|
|
}
|
|
}
|
|
|
|
// Close list if still open
|
|
if (inList) {
|
|
html += '</ul>\n';
|
|
}
|
|
|
|
// Replace code block placeholders
|
|
codeBlocks.forEach((block, index) => {
|
|
html = html.replace(`<!--CODEBLOCK_${index}-->`, block);
|
|
});
|
|
|
|
// Clean up excessive newlines
|
|
html = html.replace(/\n{3,}/g, '\n\n');
|
|
|
|
return html;
|
|
}
|