diff --git a/static/app.js b/static/app.js
index 5c56db0..bcdf04e 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,429 +1,25 @@
// 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();
-}
-
-// 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 keyboard shortcuts
- 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;
-}
-
-// ============================================
-// Validation Functions
-// ============================================
-
-function validateRequired(value, fieldName) {
- if (!value || value.trim() === '') {
- return `${fieldName} is required`;
- }
- return null;
-}
-
-function validateUsername(username) {
- if (!username || username.trim() === '') {
- return 'Username is required';
- }
- if (username.length < 3) {
- return 'Username must be at least 3 characters';
- }
- if (username.length > 50) {
- return 'Username must be less than 50 characters';
- }
- if (!/^[a-zA-Z0-9_]+$/.test(username)) {
- return 'Username can only contain letters, numbers, and underscores';
- }
- return null;
-}
-
-function validatePassword(password) {
- if (!password || password.trim() === '') {
- return 'Password is required';
- }
- if (password.length < 6) {
- return 'Password must be at least 6 characters';
- }
- return null;
-}
-
-function showFieldError(field, error) {
- field.classList.add('error');
- let errorDiv = field.nextElementSibling;
- if (!errorDiv || !errorDiv.classList.contains('field-error')) {
- errorDiv = document.createElement('div');
- errorDiv.className = 'field-error';
- field.parentNode.insertBefore(errorDiv, field.nextSibling);
- }
- errorDiv.textContent = error;
-}
-
-function clearFieldError(field) {
- field.classList.remove('error');
- const errorDiv = field.nextElementSibling;
- if (errorDiv && errorDiv.classList.contains('field-error')) {
- errorDiv.remove();
- }
-}
-
-function autoResizeTextarea(textarea) {
- textarea.style.height = 'auto';
- textarea.style.height = textarea.scrollHeight + 'px';
-}
-
-function addCharacterCounter(textarea, maxLength) {
- const counter = document.createElement('div');
- counter.className = 'char-counter';
- textarea.parentNode.insertBefore(counter, textarea.nextSibling);
-
- const updateCounter = () => {
- const length = textarea.value.length;
- counter.textContent = `${length}/${maxLength}`;
- if (length > maxLength * 0.9) {
- counter.classList.add('warning');
- } else {
- counter.classList.remove('warning');
- }
- };
-
- textarea.addEventListener('input', updateCounter);
- updateCounter();
-}
-
function initRelativeTimestamps() {
// This function can be used to show relative timestamps
// For now, it's just a placeholder
}
+
+function initApp() {
+ initRelativeTimestamps();
+ if (typeof initKeyboardShortcuts === 'function') {
+ initKeyboardShortcuts();
+ }
+ if (typeof initFormHandling === 'function') {
+ initFormHandling();
+ }
+ if (typeof initLikeButtons === 'function') {
+ initLikeButtons();
+ }
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initApp);
+} else {
+ initApp();
+}
diff --git a/static/drafts.js b/static/drafts.js
new file mode 100644
index 0000000..23c831a
--- /dev/null
+++ b/static/drafts.js
@@ -0,0 +1,78 @@
+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);
+ }
+}
+
+function loadDraft(key) {
+ try {
+ return localStorage.getItem(key);
+ } catch (e) {
+ console.error('Failed to load draft:', e);
+ return null;
+ }
+}
+
+function clearDraft(key) {
+ try {
+ localStorage.removeItem(key);
+ localStorage.removeItem(key + '_timestamp');
+ } catch (e) {
+ console.error('Failed to clear draft:', e);
+ }
+}
+
+function getDraftTimestamp(key) {
+ try {
+ const timestamp = localStorage.getItem(key + '_timestamp');
+ return timestamp ? parseInt(timestamp, 10) : null;
+ } catch (e) {
+ console.error('Failed to get draft timestamp:', e);
+ return null;
+ }
+}
+
+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';
+}
+
+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;
+}
diff --git a/static/forms.js b/static/forms.js
new file mode 100644
index 0000000..cb21ff4
--- /dev/null
+++ b/static/forms.js
@@ -0,0 +1,212 @@
+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);
+}
+
+function handleFormSubmit(form, button) {
+ if (button) {
+ button.disabled = true;
+ button.classList.add('loading');
+ }
+
+ const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
+ submitButtons.forEach(btn => {
+ btn.disabled = true;
+ btn.classList.add('loading');
+ });
+}
+
+function removeLoadingState(form) {
+ const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
+ submitButtons.forEach(btn => {
+ btn.disabled = false;
+ btn.classList.remove('loading');
+ });
+}
+
+function enableEnterToSubmit(input, form) {
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ form.requestSubmit();
+ }
+ });
+}
+
+function autoResizeTextarea(textarea) {
+ textarea.style.height = 'auto';
+ textarea.style.height = textarea.scrollHeight + 'px';
+}
+
+function addCharacterCounter(textarea, maxLength) {
+ const counter = document.createElement('div');
+ counter.className = 'char-counter';
+ textarea.parentNode.insertBefore(counter, textarea.nextSibling);
+
+ const updateCounter = () => {
+ const length = textarea.value.length;
+ counter.textContent = `${length}/${maxLength}`;
+ if (length > maxLength * 0.9) {
+ counter.classList.add('warning');
+ } else {
+ counter.classList.remove('warning');
+ }
+ };
+
+ textarea.addEventListener('input', updateCounter);
+ updateCounter();
+}
+
+function initFormHandling() {
+ document.querySelectorAll('form').forEach(form => {
+ form.addEventListener('submit', () => {
+ const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
+ handleFormSubmit(form, submitButton);
+ });
+ });
+
+ document.querySelectorAll('textarea').forEach(textarea => {
+ textarea.addEventListener('input', () => autoResizeTextarea(textarea));
+
+ if (textarea.id === 'content' || textarea.name === 'content') {
+ addCharacterCounter(textarea, 10000);
+ }
+ if (textarea.id === 'bio' || textarea.name === 'bio') {
+ addCharacterCounter(textarea, 500);
+ }
+ });
+
+ const loginForm = document.querySelector('form[action*="login"]');
+ if (loginForm) {
+ const passwordInput = loginForm.querySelector('input[type="password"]');
+ if (passwordInput) {
+ enableEnterToSubmit(passwordInput, loginForm);
+ }
+ }
+
+ 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);
+ }
+ });
+ }
+
+ 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');
+ }
+ });
+ }
+
+ 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/likes.js b/static/likes.js
new file mode 100644
index 0000000..eff8075
--- /dev/null
+++ b/static/likes.js
@@ -0,0 +1,11 @@
+function initLikeButtons() {
+ document.querySelectorAll('form[action*="/like/"]').forEach(form => {
+ form.addEventListener('submit', () => {
+ const button = form.querySelector('button[type="submit"]');
+ if (button) {
+ button.style.opacity = '0.5';
+ button.textContent = button.textContent + ' ✓';
+ }
+ });
+ });
+}
diff --git a/static/shortcuts.js b/static/shortcuts.js
new file mode 100644
index 0000000..9b7cd0e
--- /dev/null
+++ b/static/shortcuts.js
@@ -0,0 +1,25 @@
+function initKeyboardShortcuts() {
+ document.addEventListener('keydown', (e) => {
+ 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();
+ }
+ }
+ }
+
+ if (e.key === 'Escape') {
+ if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
+ document.activeElement.blur();
+ }
+
+ document.querySelectorAll('.notification').forEach(notif => {
+ notif.classList.add('hiding');
+ setTimeout(() => notif.remove(), 300);
+ });
+ }
+ });
+}
diff --git a/static/validation.js b/static/validation.js
new file mode 100644
index 0000000..add236e
--- /dev/null
+++ b/static/validation.js
@@ -0,0 +1,51 @@
+function validateRequired(value, fieldName) {
+ if (!value || value.trim() === '') {
+ return `${fieldName} is required`;
+ }
+ return null;
+}
+
+function validateUsername(username) {
+ if (!username || username.trim() === '') {
+ return 'Username is required';
+ }
+ if (username.length < 3) {
+ return 'Username must be at least 3 characters';
+ }
+ if (username.length > 50) {
+ return 'Username must be less than 50 characters';
+ }
+ if (!/^[a-zA-Z0-9_]+$/.test(username)) {
+ return 'Username can only contain letters, numbers, and underscores';
+ }
+ return null;
+}
+
+function validatePassword(password) {
+ if (!password || password.trim() === '') {
+ return 'Password is required';
+ }
+ if (password.length < 6) {
+ return 'Password must be at least 6 characters';
+ }
+ return null;
+}
+
+function showFieldError(field, error) {
+ field.classList.add('error');
+ let errorDiv = field.nextElementSibling;
+ if (!errorDiv || !errorDiv.classList.contains('field-error')) {
+ errorDiv = document.createElement('div');
+ errorDiv.className = 'field-error';
+ field.parentNode.insertBefore(errorDiv, field.nextSibling);
+ }
+ errorDiv.textContent = error;
+}
+
+function clearFieldError(field) {
+ field.classList.remove('error');
+ const errorDiv = field.nextElementSibling;
+ if (errorDiv && errorDiv.classList.contains('field-error')) {
+ errorDiv.remove();
+ }
+}
diff --git a/templates/base.html b/templates/base.html
index 10b8efa..822f64d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -4,6 +4,11 @@
{{.Title}}
+
+
+
+
+