Compare commits
No commits in common. "2c7634da43cb43c44ca3912991a2358fd4083cbe" and "78a287595809d4dd2a11c85f6cf35daaa8d105e2" have entirely different histories.
2c7634da43
...
78a2875958
|
|
@ -18,8 +18,6 @@ type PageData struct {
|
||||||
BasePath string
|
BasePath string
|
||||||
StaticPath string
|
StaticPath string
|
||||||
CurrentURL string
|
CurrentURL string
|
||||||
ContentTemplate string
|
|
||||||
BodyClass string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
|
||||||
|
|
@ -238,8 +238,6 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
ContentTemplate: "chat-content",
|
|
||||||
BodyClass: "chat-page",
|
|
||||||
},
|
},
|
||||||
Board: *board,
|
Board: *board,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ func PreferencesHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
ContentTemplate: "preferences-content",
|
|
||||||
},
|
},
|
||||||
Preferences: prefs,
|
Preferences: prefs,
|
||||||
ShowSuccess: showSuccess,
|
ShowSuccess: showSuccess,
|
||||||
|
|
|
||||||
442
static/app.js
442
static/app.js
|
|
@ -1,25 +1,429 @@
|
||||||
// ThreadR UI Enhancement 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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() {
|
function initRelativeTimestamps() {
|
||||||
// This function can be used to show relative timestamps
|
// This function can be used to show relative timestamps
|
||||||
// For now, it's just a placeholder
|
// 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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
564
static/chat.js
564
static/chat.js
|
|
@ -1,564 +0,0 @@
|
||||||
(() => {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const chatContainer = document.querySelector('.chat-container');
|
|
||||||
if (!chatContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardId = chatContainer.dataset.boardId;
|
|
||||||
const basePath = chatContainer.dataset.basePath || '';
|
|
||||||
const currentUsername = chatContainer.dataset.currentUsername || '';
|
|
||||||
const usernamesScript = document.getElementById('chat-usernames');
|
|
||||||
let allUsernames = [];
|
|
||||||
if (usernamesScript) {
|
|
||||||
try {
|
|
||||||
allUsernames = JSON.parse(usernamesScript.textContent || '[]');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse chat usernames:', err);
|
|
||||||
allUsernames = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let ws;
|
|
||||||
let autocompleteActive = false;
|
|
||||||
let replyToId = -1;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
let reconnectTimeout;
|
|
||||||
let isUserAtBottom = true;
|
|
||||||
let unreadCount = 0;
|
|
||||||
const maxReconnectDelay = 30000;
|
|
||||||
const typingUsers = new Set();
|
|
||||||
|
|
||||||
function updateConnectionStatus(status) {
|
|
||||||
const dot = document.getElementById('connection-dot');
|
|
||||||
const text = document.getElementById('connection-text');
|
|
||||||
if (!dot || !text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dot.className = 'connection-dot ' + status;
|
|
||||||
|
|
||||||
if (status === 'connected') {
|
|
||||||
text.textContent = 'Connected';
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
} else if (status === 'connecting') {
|
|
||||||
text.textContent = 'Connecting...';
|
|
||||||
} else if (status === 'disconnected') {
|
|
||||||
text.textContent = 'Disconnected';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
if (!boardId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateConnectionStatus('connecting');
|
|
||||||
|
|
||||||
ws = new WebSocket('ws://' + window.location.host + basePath + '/chat/?ws=true&id=' + boardId);
|
|
||||||
|
|
||||||
ws.onopen = function() {
|
|
||||||
updateConnectionStatus('connected');
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (msg.type === 'typing') {
|
|
||||||
showTypingIndicator(msg.username);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMessage(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function() {
|
|
||||||
updateConnectionStatus('disconnected');
|
|
||||||
console.log('WebSocket closed, reconnecting...');
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
updateConnectionStatus('disconnected');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimeout) {
|
|
||||||
clearTimeout(reconnectTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
|
||||||
reconnectAttempts++;
|
|
||||||
|
|
||||||
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
|
|
||||||
reconnectTimeout = setTimeout(connectWebSocket, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMessage() {
|
|
||||||
const input = document.getElementById('chat-input-text');
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = input.value.trim();
|
|
||||||
if (content === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
type: 'message',
|
|
||||||
content: content,
|
|
||||||
replyTo: replyToId
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify(msg));
|
|
||||||
input.value = '';
|
|
||||||
cancelReply();
|
|
||||||
} else {
|
|
||||||
console.error('WebSocket is not open. Current state:', ws ? ws.readyState : 'undefined');
|
|
||||||
alert('Cannot send message: Not connected to chat server');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendTypingIndicator() {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: 'typing', username: currentUsername }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTypingIndicator(username) {
|
|
||||||
if (username === currentUsername) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
typingUsers.add(username);
|
|
||||||
updateTypingDisplay();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
typingUsers.delete(username);
|
|
||||||
updateTypingDisplay();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTypingDisplay() {
|
|
||||||
const indicator = document.getElementById('typing-indicator');
|
|
||||||
const usersSpan = document.getElementById('typing-users');
|
|
||||||
|
|
||||||
if (!indicator || !usersSpan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typingUsers.size === 0) {
|
|
||||||
indicator.classList.remove('visible');
|
|
||||||
} else {
|
|
||||||
const users = Array.from(typingUsers);
|
|
||||||
if (users.length === 1) {
|
|
||||||
usersSpan.textContent = `${users[0]} is typing`;
|
|
||||||
} else if (users.length === 2) {
|
|
||||||
usersSpan.textContent = `${users[0]} and ${users[1]} are typing`;
|
|
||||||
} else {
|
|
||||||
usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`;
|
|
||||||
}
|
|
||||||
indicator.classList.add('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMessage(msg) {
|
|
||||||
const messages = document.getElementById('chat-messages');
|
|
||||||
if (!messages) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.getElementById('msg-' + msg.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasAtBottom = isUserAtBottom;
|
|
||||||
|
|
||||||
const msgDiv = document.createElement('div');
|
|
||||||
let highlightClass = '';
|
|
||||||
if (msg.mentions && msg.mentions.includes(currentUsername)) {
|
|
||||||
highlightClass = ' chat-message-highlighted';
|
|
||||||
}
|
|
||||||
msgDiv.className = 'chat-message' + highlightClass;
|
|
||||||
msgDiv.id = 'msg-' + msg.id;
|
|
||||||
msgDiv.dataset.user = msg.username;
|
|
||||||
msgDiv.dataset.reply = msg.replyTo || 0;
|
|
||||||
|
|
||||||
let pfpHTML = '<div class="chat-message-pfp" style="background-color: #001858;"></div>';
|
|
||||||
if (msg.pfpFileId && msg.pfpFileId.Valid) {
|
|
||||||
pfpHTML = `<img src="${basePath}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
|
|
||||||
}
|
|
||||||
const replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
|
|
||||||
|
|
||||||
msgDiv.innerHTML = `
|
|
||||||
<div class="chat-message-header">
|
|
||||||
${pfpHTML}
|
|
||||||
<span class="chat-message-username">${msg.username}</span>
|
|
||||||
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
${replyHTML}
|
|
||||||
<div class="chat-message-content">${msg.content}</div>
|
|
||||||
<div class="post-actions">
|
|
||||||
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
messages.appendChild(msgDiv);
|
|
||||||
|
|
||||||
applyGrouping();
|
|
||||||
|
|
||||||
if (wasAtBottom) {
|
|
||||||
messages.scrollTop = messages.scrollHeight;
|
|
||||||
unreadCount = 0;
|
|
||||||
updateUnreadBadge();
|
|
||||||
} else if (msg.username !== currentUsername) {
|
|
||||||
unreadCount++;
|
|
||||||
updateUnreadBadge();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUnreadBadge() {
|
|
||||||
const badge = document.getElementById('unread-badge');
|
|
||||||
if (!badge) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (unreadCount > 0) {
|
|
||||||
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
|
||||||
badge.style.display = 'flex';
|
|
||||||
} else {
|
|
||||||
badge.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpToBottom() {
|
|
||||||
const messages = document.getElementById('chat-messages');
|
|
||||||
if (!messages) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
messages.scrollTo({
|
|
||||||
top: messages.scrollHeight,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
unreadCount = 0;
|
|
||||||
updateUnreadBadge();
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkScrollPosition() {
|
|
||||||
const messages = document.getElementById('chat-messages');
|
|
||||||
const jumpButton = document.getElementById('jump-to-bottom');
|
|
||||||
if (!messages || !jumpButton) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const threshold = 100;
|
|
||||||
|
|
||||||
isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
|
|
||||||
|
|
||||||
if (isUserAtBottom) {
|
|
||||||
jumpButton.classList.remove('visible');
|
|
||||||
unreadCount = 0;
|
|
||||||
updateUnreadBadge();
|
|
||||||
} else {
|
|
||||||
jumpButton.classList.add('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replyToMessage(id, username) {
|
|
||||||
replyToId = id;
|
|
||||||
const replyIndicator = document.getElementById('reply-indicator');
|
|
||||||
const replyUsernameSpan = document.getElementById('reply-username');
|
|
||||||
if (!replyIndicator || !replyUsernameSpan) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replyUsernameSpan.textContent = `Replying to ${username}`;
|
|
||||||
replyIndicator.style.display = 'flex';
|
|
||||||
const input = document.getElementById('chat-input-text');
|
|
||||||
if (input) {
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelReply() {
|
|
||||||
replyToId = -1;
|
|
||||||
const replyIndicator = document.getElementById('reply-indicator');
|
|
||||||
if (replyIndicator) {
|
|
||||||
replyIndicator.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToMessage(id) {
|
|
||||||
const msgElement = document.getElementById('msg-' + id);
|
|
||||||
if (msgElement) {
|
|
||||||
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
msgElement.style.transition = 'background-color 0.5s';
|
|
||||||
msgElement.style.backgroundColor = '#f582ae';
|
|
||||||
setTimeout(() => {
|
|
||||||
msgElement.style.backgroundColor = '';
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAutocompletePopup(suggestions, x, y) {
|
|
||||||
const popup = document.getElementById('autocomplete-popup');
|
|
||||||
if (!popup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
popup.innerHTML = '';
|
|
||||||
popup.style.position = 'fixed';
|
|
||||||
popup.style.left = x + 'px';
|
|
||||||
popup.style.top = y + 'px';
|
|
||||||
popup.style.display = 'block';
|
|
||||||
autocompleteActive = true;
|
|
||||||
suggestions.forEach(username => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'autocomplete-item';
|
|
||||||
item.textContent = username;
|
|
||||||
item.onmousedown = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
completeMention(username);
|
|
||||||
popup.style.display = 'none';
|
|
||||||
autocompleteActive = false;
|
|
||||||
};
|
|
||||||
popup.appendChild(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeMention(username) {
|
|
||||||
const input = document.getElementById('chat-input-text');
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = input.value;
|
|
||||||
const caretPos = input.selectionStart;
|
|
||||||
const textBeforeCaret = text.substring(0, caretPos);
|
|
||||||
const atIndex = textBeforeCaret.lastIndexOf('@');
|
|
||||||
|
|
||||||
if (atIndex !== -1) {
|
|
||||||
const prefix = text.substring(0, atIndex);
|
|
||||||
const suffix = text.substring(caretPos);
|
|
||||||
input.value = prefix + '@' + username + ' ' + suffix;
|
|
||||||
const newCaretPos = prefix.length + 1 + username.length + 1;
|
|
||||||
input.focus();
|
|
||||||
input.setSelectionRange(newCaretPos, newCaretPos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCaretCoordinates(element, position) {
|
|
||||||
const mirrorDivId = 'input-mirror-div';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.id = mirrorDivId;
|
|
||||||
document.body.appendChild(div);
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
|
|
||||||
properties.forEach(prop => {
|
|
||||||
div.style[prop] = style[prop];
|
|
||||||
});
|
|
||||||
div.style.position = 'absolute';
|
|
||||||
div.style.top = '-9999px';
|
|
||||||
div.style.left = '0px';
|
|
||||||
div.textContent = element.value.substring(0, position);
|
|
||||||
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.textContent = element.value.substring(position) || '.';
|
|
||||||
div.appendChild(span);
|
|
||||||
|
|
||||||
const coords = { top: span.offsetTop, left: span.offsetLeft };
|
|
||||||
document.body.removeChild(div);
|
|
||||||
return coords;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyGrouping() {
|
|
||||||
const container = document.getElementById('chat-messages');
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const msgs = Array.from(container.querySelectorAll('.chat-message')).filter(el => el.id.startsWith('msg-'));
|
|
||||||
|
|
||||||
for (let i = 0; i < msgs.length; i++) {
|
|
||||||
const curr = msgs[i];
|
|
||||||
|
|
||||||
curr.classList.remove('grouped');
|
|
||||||
|
|
||||||
if (i === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prev = msgs[i - 1];
|
|
||||||
const currUser = curr.dataset.user;
|
|
||||||
const prevUser = prev.dataset.user;
|
|
||||||
const currReply = parseInt(curr.dataset.reply, 10) || -1;
|
|
||||||
|
|
||||||
if (currUser === prevUser && (currReply <= 0)) {
|
|
||||||
curr.classList.add('grouped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAutocompleteInput(e) {
|
|
||||||
const input = e.target;
|
|
||||||
const text = input.value;
|
|
||||||
const caretPos = input.selectionStart;
|
|
||||||
const popup = document.getElementById('autocomplete-popup');
|
|
||||||
if (!popup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const textBeforeCaret = text.substring(0, caretPos);
|
|
||||||
const atIndex = textBeforeCaret.lastIndexOf('@');
|
|
||||||
|
|
||||||
if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) {
|
|
||||||
const query = textBeforeCaret.substring(atIndex + 1);
|
|
||||||
if (!/\s/.test(query)) {
|
|
||||||
const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
|
|
||||||
if (suggestions.length > 0 && query.length > 0) {
|
|
||||||
const coords = getCaretCoordinates(input, atIndex);
|
|
||||||
const rect = input.getBoundingClientRect();
|
|
||||||
showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20);
|
|
||||||
} else {
|
|
||||||
popup.style.display = 'none';
|
|
||||||
autocompleteActive = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
popup.style.display = 'none';
|
|
||||||
autocompleteActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAutocompleteBlur() {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.querySelector('.autocomplete-popup:hover')) {
|
|
||||||
const popup = document.getElementById('autocomplete-popup');
|
|
||||||
if (popup) {
|
|
||||||
popup.style.display = 'none';
|
|
||||||
}
|
|
||||||
autocompleteActive = false;
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMessageKeydown(e) {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDraftAutoSave(chatInput) {
|
|
||||||
if (!chatInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const draftKey = `draft_chat_${boardId}`;
|
|
||||||
let draftSaveTimeout;
|
|
||||||
|
|
||||||
const existingDraft = loadDraft(draftKey);
|
|
||||||
const draftTimestamp = getDraftTimestamp(draftKey);
|
|
||||||
if (existingDraft && draftTimestamp) {
|
|
||||||
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
|
||||||
if (draftTimestamp > sevenDaysAgo) {
|
|
||||||
const indicator = showDraftIndicator(
|
|
||||||
existingDraft,
|
|
||||||
draftTimestamp,
|
|
||||||
(content) => {
|
|
||||||
chatInput.value = content;
|
|
||||||
chatInput.focus();
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
clearDraft(draftKey);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const chatInputContainer = document.querySelector('.chat-input');
|
|
||||||
if (chatInputContainer && chatInputContainer.parentNode) {
|
|
||||||
chatInputContainer.parentNode.insertBefore(indicator, chatInputContainer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearDraft(draftKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatInput.addEventListener('input', () => {
|
|
||||||
clearTimeout(draftSaveTimeout);
|
|
||||||
draftSaveTimeout = setTimeout(() => {
|
|
||||||
const content = chatInput.value.trim();
|
|
||||||
if (content) {
|
|
||||||
saveDraft(draftKey, content);
|
|
||||||
} else {
|
|
||||||
clearDraft(draftKey);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupTypingIndicator(chatInput) {
|
|
||||||
if (!chatInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let lastTypingTime = 0;
|
|
||||||
chatInput.addEventListener('input', () => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastTypingTime > 2000) {
|
|
||||||
sendTypingIndicator();
|
|
||||||
lastTypingTime = now;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSendMessageDraftClear(chatInput) {
|
|
||||||
if (!chatInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const draftKey = `draft_chat_${boardId}`;
|
|
||||||
const originalSendMessage = window.sendMessage;
|
|
||||||
window.sendMessage = function() {
|
|
||||||
const content = chatInput.value.trim();
|
|
||||||
if (content !== '') {
|
|
||||||
clearDraft(draftKey);
|
|
||||||
}
|
|
||||||
if (typeof originalSendMessage === 'function') {
|
|
||||||
originalSendMessage();
|
|
||||||
} else {
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function initChat() {
|
|
||||||
connectWebSocket();
|
|
||||||
|
|
||||||
const messagesContainer = document.getElementById('chat-messages');
|
|
||||||
if (messagesContainer) {
|
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
||||||
applyGrouping();
|
|
||||||
messagesContainer.addEventListener('scroll', checkScrollPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpButton = document.getElementById('jump-to-bottom');
|
|
||||||
if (jumpButton) {
|
|
||||||
jumpButton.addEventListener('click', jumpToBottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatInput = document.getElementById('chat-input-text');
|
|
||||||
if (chatInput) {
|
|
||||||
chatInput.addEventListener('input', handleAutocompleteInput);
|
|
||||||
chatInput.addEventListener('blur', handleAutocompleteBlur);
|
|
||||||
chatInput.addEventListener('keydown', handleMessageKeydown);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupTypingIndicator(chatInput);
|
|
||||||
setupDraftAutoSave(chatInput);
|
|
||||||
setupSendMessageDraftClear(chatInput);
|
|
||||||
|
|
||||||
checkScrollPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.sendMessage = sendMessage;
|
|
||||||
window.replyToMessage = replyToMessage;
|
|
||||||
window.cancelReply = cancelReply;
|
|
||||||
window.scrollToMessage = scrollToMessage;
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initChat);
|
|
||||||
} else {
|
|
||||||
initChat();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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 = `
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
212
static/forms.js
212
static/forms.js
|
|
@ -1,212 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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 + ' ✓';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
433
static/style.css
433
static/style.css
|
|
@ -6,11 +6,6 @@ body {
|
||||||
color: #001858; /* blue */
|
color: #001858; /* blue */
|
||||||
}
|
}
|
||||||
|
|
||||||
body.chat-page {
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -18,12 +13,6 @@ main {
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.chat-page main {
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 3em;
|
|
||||||
height: calc(100vh - 3em);
|
|
||||||
}
|
|
||||||
|
|
||||||
main > header {
|
main > header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
|
@ -354,344 +343,6 @@ p.thread-info {
|
||||||
background-color: #ffe0f0; /* Light pink background */
|
background-color: #ffe0f0; /* Light pink background */
|
||||||
animation: highlight-fade 2s ease-out;
|
animation: highlight-fade 2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat page styles */
|
|
||||||
.chat-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background-color: #fef6e4;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
padding: 10px;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid #001858;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background-color: rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-dot.connected {
|
|
||||||
background-color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-dot.connecting {
|
|
||||||
background-color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-dot.disconnected {
|
|
||||||
background-color: #f87171;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-indicator {
|
|
||||||
padding: 8px 12px;
|
|
||||||
background-color: #f3d2c1;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-style: italic;
|
|
||||||
color: #001858;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-indicator.visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-dots {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-dots span {
|
|
||||||
animation: typing-blink 1.4s infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-dots span:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-dots span:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typing-blink {
|
|
||||||
0%, 60%, 100% { opacity: 0.3; }
|
|
||||||
30% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 70px;
|
|
||||||
right: 20px;
|
|
||||||
background-color: #001858;
|
|
||||||
color: #fef6e4;
|
|
||||||
border: 2px solid #001858;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.3em;
|
|
||||||
box-shadow: 0px 4px 12px rgba(0,0,0,0.3);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom.visible {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom:hover {
|
|
||||||
transform: translateY(2px);
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom .unread-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
right: -5px;
|
|
||||||
background-color: #f582ae;
|
|
||||||
color: #fef6e4;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.7em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-status {
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: #666;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-status.sending {
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-status.sent {
|
|
||||||
color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-status.failed {
|
|
||||||
color: #f87171;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb {
|
|
||||||
background-color: #f3d2c1;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid #001858;
|
|
||||||
font-size: 0.85em;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb a {
|
|
||||||
color: #001858;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb a:hover {
|
|
||||||
color: #f582ae;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb-separator {
|
|
||||||
margin: 0 6px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
max-width: 90%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-pfp {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-username {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #001858;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-timestamp {
|
|
||||||
font-size: 0.7em;
|
|
||||||
color: #666;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-content {
|
|
||||||
background-color: #f3d2c1;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-size: 0.9em;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-reply {
|
|
||||||
background-color: rgba(0,0,0,0.1);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-mention {
|
|
||||||
color: #f582ae;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
padding: 8px;
|
|
||||||
border-top: 1px solid #001858;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input textarea {
|
|
||||||
resize: none;
|
|
||||||
height: 50px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input button {
|
|
||||||
align-self: flex-end;
|
|
||||||
width: auto;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message:hover .post-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions a {
|
|
||||||
color: #001858;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.8em;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border: 1px solid #001858;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions a:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-popup {
|
|
||||||
position: absolute;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #001858;
|
|
||||||
border-radius: 5px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item {
|
|
||||||
padding: 6px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item:hover {
|
|
||||||
background-color: #f3d2c1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator {
|
|
||||||
background-color: #001858;
|
|
||||||
color: #fef6e4;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator span {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #fef6e4;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding: 0 5px;
|
|
||||||
margin: 0;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator button:hover {
|
|
||||||
background: none;
|
|
||||||
color: #f582ae;
|
|
||||||
}
|
|
||||||
/* Grouped messages (hide header for consecutive messages from same user) */
|
/* Grouped messages (hide header for consecutive messages from same user) */
|
||||||
.chat-message.grouped {
|
.chat-message.grouped {
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
|
|
@ -789,90 +440,6 @@ p.thread-info {
|
||||||
border-color: #f582ae;
|
border-color: #f582ae;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.chat-page {
|
|
||||||
background-color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb {
|
|
||||||
background-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-breadcrumb a {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing-indicator {
|
|
||||||
background-color: #555;
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom {
|
|
||||||
background-color: #fef6e4;
|
|
||||||
color: #001858;
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jump-to-bottom:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-username {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-timestamp {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message-content {
|
|
||||||
background-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-popup {
|
|
||||||
background-color: #444;
|
|
||||||
border-color: #fef6e4;
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autocomplete-item:hover {
|
|
||||||
background-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions a {
|
|
||||||
color: #fef6e4;
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-actions a:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
color: #001858;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator {
|
|
||||||
background-color: #222;
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator button {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-indicator button:hover {
|
|
||||||
color: #f582ae;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breadcrumb navigation */
|
/* Breadcrumb navigation */
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,18 +4,12 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
<script src="{{.StaticPath}}/validation.js" defer></script>
|
|
||||||
<script src="{{.StaticPath}}/drafts.js" defer></script>
|
|
||||||
<script src="{{.StaticPath}}/forms.js" defer></script>
|
|
||||||
<script src="{{.StaticPath}}/shortcuts.js" defer></script>
|
|
||||||
<script src="{{.StaticPath}}/likes.js" defer></script>
|
|
||||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
<script src="{{.StaticPath}}/chat.js" defer></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body{{if .BodyClass}} class="{{.BodyClass}}"{{end}}>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
{{template .ContentTemplate .}}
|
{{block "content" .}}{{end}} <!-- Define a block for content -->
|
||||||
</main>
|
</main>
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,422 @@
|
||||||
{{define "chat"}}{{template "base" .}}{{end}}
|
{{define "chat"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
{{define "chat-content"}}
|
<html>
|
||||||
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
|
<head>
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 3em; /* Space for navbar */
|
||||||
|
height: calc(100vh - 3em);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.chat-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: #fef6e4;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #001858;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.connection-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.connection-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.connection-dot.connected {
|
||||||
|
background-color: #4ade80;
|
||||||
|
}
|
||||||
|
.connection-dot.connecting {
|
||||||
|
background-color: #fbbf24;
|
||||||
|
}
|
||||||
|
.connection-dot.disconnected {
|
||||||
|
background-color: #f87171;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
.typing-indicator {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-style: italic;
|
||||||
|
color: #001858;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.typing-indicator.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.typing-dots {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.typing-dots span {
|
||||||
|
animation: typing-blink 1.4s infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.typing-dots span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
.typing-dots span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
@keyframes typing-blink {
|
||||||
|
0%, 60%, 100% { opacity: 0.3; }
|
||||||
|
30% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.jump-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 70px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #001858;
|
||||||
|
color: #fef6e4;
|
||||||
|
border: 2px solid #001858;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.3em;
|
||||||
|
box-shadow: 0px 4px 12px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.jump-to-bottom.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.jump-to-bottom:hover {
|
||||||
|
transform: translateY(2px);
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
}
|
||||||
|
.jump-to-bottom .unread-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
background-color: #f582ae;
|
||||||
|
color: #fef6e4;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.message-status {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.message-status.sending {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.message-status.sent {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.message-status.failed {
|
||||||
|
color: #f87171;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb {
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #001858;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb a {
|
||||||
|
color: #001858;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb a:hover {
|
||||||
|
color: #f582ae;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb-separator {
|
||||||
|
margin: 0 6px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-message {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
max-width: 90%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.chat-message-pfp {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.chat-message-username {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #001858;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.chat-message-timestamp {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.chat-message-content {
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.9em;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.chat-message-reply {
|
||||||
|
background-color: rgba(0,0,0,0.1);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-message-mention {
|
||||||
|
color: #f582ae;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid #001858;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-input textarea {
|
||||||
|
resize: none;
|
||||||
|
height: 50px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.chat-input button {
|
||||||
|
align-self: flex-end;
|
||||||
|
width: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.post-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.chat-message:hover .post-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.post-actions a {
|
||||||
|
color: #001858;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid #001858;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.post-actions a:hover {
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.autocomplete-popup {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #001858;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.autocomplete-item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.autocomplete-item:hover {
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
}
|
||||||
|
.reply-indicator {
|
||||||
|
background-color: #001858;
|
||||||
|
color: #fef6e4;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.reply-indicator span {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.reply-indicator button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fef6e4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.reply-indicator button:hover {
|
||||||
|
background: none;
|
||||||
|
color: #f582ae;
|
||||||
|
}
|
||||||
|
/* New style for highlighted messages */
|
||||||
|
.chat-message-highlighted {
|
||||||
|
border: 2px solid #f582ae; /* Pink border */
|
||||||
|
background-color: #ffe0f0; /* Light pink background */
|
||||||
|
animation: highlight-fade 2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes highlight-fade {
|
||||||
|
from {
|
||||||
|
background-color: #f582ae;
|
||||||
|
border-color: #f582ae;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-color: #ffe0f0;
|
||||||
|
border-color: #f582ae;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.chat-container {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
.chat-breadcrumb a {
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.typing-indicator {
|
||||||
|
background-color: #555;
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.jump-to-bottom {
|
||||||
|
background-color: #fef6e4;
|
||||||
|
color: #001858;
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.jump-to-bottom:hover {
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.chat-message-username {
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.chat-message-timestamp {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.chat-message-content {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.autocomplete-popup {
|
||||||
|
background-color: #444;
|
||||||
|
border-color: #fef6e4;
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.autocomplete-item:hover {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
.post-actions a {
|
||||||
|
color: #fef6e4;
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.post-actions a:hover {
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
color: #001858;
|
||||||
|
}
|
||||||
|
.reply-indicator {
|
||||||
|
background-color: #222;
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.reply-indicator button {
|
||||||
|
color: #fef6e4;
|
||||||
|
}
|
||||||
|
.reply-indicator button:hover {
|
||||||
|
color: #f582ae;
|
||||||
|
}
|
||||||
|
/* Dark mode highlight */
|
||||||
|
.chat-message-highlighted {
|
||||||
|
border: 2px solid #f582ae; /* Pink border */
|
||||||
|
background-color: #6a0e3f; /* Darker pink background */
|
||||||
|
animation: highlight-fade-dark 2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes highlight-fade-dark {
|
||||||
|
from {
|
||||||
|
background-color: #f582ae;
|
||||||
|
border-color: #f582ae;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-color: #6a0e3f;
|
||||||
|
border-color: #f582ae;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<main>
|
||||||
|
<div class="chat-container">
|
||||||
<div class="chat-breadcrumb">
|
<div class="chat-breadcrumb">
|
||||||
<a href="{{.BasePath}}/">Home</a>
|
<a href="{{.BasePath}}/">Home</a>
|
||||||
<span class="chat-breadcrumb-separator">›</span>
|
<span class="chat-breadcrumb-separator">›</span>
|
||||||
|
|
@ -42,19 +457,491 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom" type="button">
|
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
|
||||||
↓
|
↓
|
||||||
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
<div id="reply-indicator" class="reply-indicator">
|
<div id="reply-indicator" class="reply-indicator">
|
||||||
<span id="reply-username">Replying to </span>
|
<span id="reply-username">Replying to </span>
|
||||||
<button onclick="cancelReply()" type="button">X</button>
|
<button onclick="cancelReply()">X</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||||
<button onclick="sendMessage()" type="button">Send</button>
|
<button onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||||
<script type="application/json" id="chat-usernames">{{.AllUsernames}}</script>
|
</main>
|
||||||
|
{{template "cookie_banner" .}}
|
||||||
|
<script>
|
||||||
|
let ws;
|
||||||
|
let autocompleteActive = false;
|
||||||
|
let replyToId = -1;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let reconnectTimeout;
|
||||||
|
let typingTimeout;
|
||||||
|
let isUserAtBottom = true;
|
||||||
|
let unreadCount = 0;
|
||||||
|
const allUsernames = {{.AllUsernames}};
|
||||||
|
const currentUsername = "{{.CurrentUsername}}";
|
||||||
|
const maxReconnectDelay = 30000; // 30 seconds max
|
||||||
|
|
||||||
|
// Update connection status
|
||||||
|
function updateConnectionStatus(status) {
|
||||||
|
const dot = document.getElementById('connection-dot');
|
||||||
|
const text = document.getElementById('connection-text');
|
||||||
|
dot.className = 'connection-dot ' + status;
|
||||||
|
|
||||||
|
if (status === 'connected') {
|
||||||
|
text.textContent = 'Connected';
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
} else if (status === 'connecting') {
|
||||||
|
text.textContent = 'Connecting...';
|
||||||
|
} else if (status === 'disconnected') {
|
||||||
|
text.textContent = 'Disconnected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const boardID = {{.Board.ID}};
|
||||||
|
updateConnectionStatus('connecting');
|
||||||
|
|
||||||
|
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
updateConnectionStatus('connected');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Handle typing indicator
|
||||||
|
if (msg.type === 'typing') {
|
||||||
|
showTypingIndicator(msg.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular message
|
||||||
|
appendMessage(msg);
|
||||||
|
};
|
||||||
|
ws.onclose = function() {
|
||||||
|
updateConnectionStatus('disconnected');
|
||||||
|
console.log("WebSocket closed, reconnecting...");
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
ws.onerror = function(error) {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
updateConnectionStatus('disconnected');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect with exponential backoff
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||||||
|
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
||||||
|
reconnectAttempts++;
|
||||||
|
|
||||||
|
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
|
||||||
|
reconnectTimeout = setTimeout(connectWebSocket, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const input = document.getElementById('chat-input-text');
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (content === '') return;
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
type: 'message',
|
||||||
|
content: content,
|
||||||
|
replyTo: replyToId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
input.value = '';
|
||||||
|
cancelReply();
|
||||||
|
} else {
|
||||||
|
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||||||
|
alert("Cannot send message: Not connected to chat server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
let typingUsers = new Set();
|
||||||
|
|
||||||
|
function sendTypingIndicator() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'typing', username: currentUsername }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTypingIndicator(username) {
|
||||||
|
if (username === currentUsername) return;
|
||||||
|
|
||||||
|
typingUsers.add(username);
|
||||||
|
updateTypingDisplay();
|
||||||
|
|
||||||
|
// Clear after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
typingUsers.delete(username);
|
||||||
|
updateTypingDisplay();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTypingDisplay() {
|
||||||
|
const indicator = document.getElementById('typing-indicator');
|
||||||
|
const usersSpan = document.getElementById('typing-users');
|
||||||
|
|
||||||
|
if (typingUsers.size === 0) {
|
||||||
|
indicator.classList.remove('visible');
|
||||||
|
} else {
|
||||||
|
const users = Array.from(typingUsers);
|
||||||
|
if (users.length === 1) {
|
||||||
|
usersSpan.textContent = `${users[0]} is typing`;
|
||||||
|
} else if (users.length === 2) {
|
||||||
|
usersSpan.textContent = `${users[0]} and ${users[1]} are typing`;
|
||||||
|
} else {
|
||||||
|
usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`;
|
||||||
|
}
|
||||||
|
indicator.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(msg) {
|
||||||
|
const messages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
// Check if message already exists (prevent duplicates)
|
||||||
|
if (document.getElementById('msg-' + msg.id)) {
|
||||||
|
return; // Don't add duplicate messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is at bottom before adding message
|
||||||
|
const wasAtBottom = isUserAtBottom;
|
||||||
|
|
||||||
|
const msgDiv = document.createElement('div');
|
||||||
|
let highlightClass = '';
|
||||||
|
if (msg.mentions && msg.mentions.includes(currentUsername)) {
|
||||||
|
highlightClass = ' chat-message-highlighted';
|
||||||
|
}
|
||||||
|
msgDiv.className = 'chat-message' + highlightClass;
|
||||||
|
msgDiv.id = 'msg-' + msg.id;
|
||||||
|
msgDiv.dataset.user = msg.username;
|
||||||
|
msgDiv.dataset.reply = msg.replyTo || 0;
|
||||||
|
let pfpHTML = `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
|
||||||
|
if (msg.pfpFileId && msg.pfpFileId.Valid) {
|
||||||
|
pfpHTML = `<img src="{{.BasePath}}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
|
||||||
|
}
|
||||||
|
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
|
||||||
|
|
||||||
|
msgDiv.innerHTML = `
|
||||||
|
<div class="chat-message-header">
|
||||||
|
${pfpHTML}
|
||||||
|
<span class="chat-message-username">${msg.username}</span>
|
||||||
|
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
${replyHTML}
|
||||||
|
<div class="chat-message-content">${msg.content}</div>
|
||||||
|
<div class="post-actions">
|
||||||
|
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
messages.appendChild(msgDiv);
|
||||||
|
|
||||||
|
// Apply grouping
|
||||||
|
applyGrouping();
|
||||||
|
|
||||||
|
// Scroll handling
|
||||||
|
if (wasAtBottom) {
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
unreadCount = 0;
|
||||||
|
updateUnreadBadge();
|
||||||
|
} else {
|
||||||
|
// User is not at bottom, increment unread
|
||||||
|
if (msg.username !== currentUsername) {
|
||||||
|
unreadCount++;
|
||||||
|
updateUnreadBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump to bottom functionality
|
||||||
|
function updateUnreadBadge() {
|
||||||
|
const badge = document.getElementById('unread-badge');
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||||||
|
badge.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToBottom() {
|
||||||
|
const messages = document.getElementById('chat-messages');
|
||||||
|
messages.scrollTo({
|
||||||
|
top: messages.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
unreadCount = 0;
|
||||||
|
updateUnreadBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if user is at bottom
|
||||||
|
function checkScrollPosition() {
|
||||||
|
const messages = document.getElementById('chat-messages');
|
||||||
|
const jumpButton = document.getElementById('jump-to-bottom');
|
||||||
|
const threshold = 100;
|
||||||
|
|
||||||
|
isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
|
||||||
|
|
||||||
|
if (isUserAtBottom) {
|
||||||
|
jumpButton.classList.remove('visible');
|
||||||
|
unreadCount = 0;
|
||||||
|
updateUnreadBadge();
|
||||||
|
} else {
|
||||||
|
jumpButton.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function replyToMessage(id, username) {
|
||||||
|
replyToId = id;
|
||||||
|
const replyIndicator = document.getElementById('reply-indicator');
|
||||||
|
const replyUsernameSpan = document.getElementById('reply-username');
|
||||||
|
replyUsernameSpan.textContent = `Replying to ${username}`;
|
||||||
|
replyIndicator.style.display = 'flex';
|
||||||
|
document.getElementById('chat-input-text').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyToId = -1;
|
||||||
|
const replyIndicator = document.getElementById('reply-indicator');
|
||||||
|
replyIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToMessage(id) {
|
||||||
|
const msgElement = document.getElementById('msg-' + id);
|
||||||
|
if (msgElement) {
|
||||||
|
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
msgElement.style.transition = 'background-color 0.5s';
|
||||||
|
msgElement.style.backgroundColor = '#f582ae';
|
||||||
|
setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAutocompletePopup(suggestions, x, y) {
|
||||||
|
const popup = document.getElementById('autocomplete-popup');
|
||||||
|
popup.innerHTML = '';
|
||||||
|
popup.style.position = 'fixed';
|
||||||
|
popup.style.left = x + 'px';
|
||||||
|
popup.style.top = y + 'px';
|
||||||
|
popup.style.display = 'block';
|
||||||
|
autocompleteActive = true;
|
||||||
|
suggestions.forEach(username => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'autocomplete-item';
|
||||||
|
item.textContent = username;
|
||||||
|
item.onmousedown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
completeMention(username);
|
||||||
|
popup.style.display = 'none';
|
||||||
|
autocompleteActive = false;
|
||||||
|
};
|
||||||
|
popup.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeMention(username) {
|
||||||
|
const input = document.getElementById('chat-input-text');
|
||||||
|
const text = input.value;
|
||||||
|
const caretPos = input.selectionStart;
|
||||||
|
const textBeforeCaret = text.substring(0, caretPos);
|
||||||
|
const atIndex = textBeforeCaret.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
const prefix = text.substring(0, atIndex);
|
||||||
|
const suffix = text.substring(caretPos);
|
||||||
|
input.value = prefix + '@' + username + ' ' + suffix;
|
||||||
|
const newCaretPos = prefix.length + 1 + username.length + 1;
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(newCaretPos, newCaretPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaretCoordinates(element, position) {
|
||||||
|
const mirrorDivId = 'input-mirror-div';
|
||||||
|
let div = document.createElement('div');
|
||||||
|
div.id = mirrorDivId;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
|
||||||
|
properties.forEach(prop => { div.style[prop] = style[prop]; });
|
||||||
|
div.style.position = 'absolute';
|
||||||
|
div.style.top = '-9999px';
|
||||||
|
div.style.left = '0px';
|
||||||
|
div.textContent = element.value.substring(0, position);
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = element.value.substring(position) || '.';
|
||||||
|
div.appendChild(span);
|
||||||
|
|
||||||
|
const coords = { top: span.offsetTop, left: span.offsetLeft };
|
||||||
|
document.body.removeChild(div);
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chat-input-text').addEventListener('input', (e) => {
|
||||||
|
const input = e.target;
|
||||||
|
const text = input.value;
|
||||||
|
const caretPos = input.selectionStart;
|
||||||
|
const popup = document.getElementById('autocomplete-popup');
|
||||||
|
const textBeforeCaret = text.substring(0, caretPos);
|
||||||
|
const atIndex = textBeforeCaret.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) {
|
||||||
|
const query = textBeforeCaret.substring(atIndex + 1);
|
||||||
|
if (!/\s/.test(query)) {
|
||||||
|
const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
|
||||||
|
if (suggestions.length > 0 && query.length > 0) {
|
||||||
|
const coords = getCaretCoordinates(input, atIndex);
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20);
|
||||||
|
} else {
|
||||||
|
popup.style.display = 'none';
|
||||||
|
autocompleteActive = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
popup.style.display = 'none';
|
||||||
|
autocompleteActive = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('chat-input-text').addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.querySelector('.autocomplete-popup:hover')) {
|
||||||
|
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||||
|
autocompleteActive = false;
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onload = function() {
|
||||||
|
connectWebSocket();
|
||||||
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
|
||||||
|
// Apply message grouping on load
|
||||||
|
applyGrouping();
|
||||||
|
|
||||||
|
// Add scroll event listener
|
||||||
|
messagesContainer.addEventListener('scroll', checkScrollPosition);
|
||||||
|
|
||||||
|
// Add jump to bottom button click handler
|
||||||
|
document.getElementById('jump-to-bottom').addEventListener('click', jumpToBottom);
|
||||||
|
|
||||||
|
// Add typing indicator on input
|
||||||
|
const chatInput = document.getElementById('chat-input-text');
|
||||||
|
let lastTypingTime = 0;
|
||||||
|
chatInput.addEventListener('input', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingTime > 2000) {
|
||||||
|
sendTypingIndicator();
|
||||||
|
lastTypingTime = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draft auto-save functionality
|
||||||
|
const draftKey = 'draft_chat_{{.Board.ID}}';
|
||||||
|
let draftSaveTimeout;
|
||||||
|
|
||||||
|
// Load existing draft on page load
|
||||||
|
const existingDraft = loadDraft(draftKey);
|
||||||
|
const draftTimestamp = getDraftTimestamp(draftKey);
|
||||||
|
if (existingDraft && draftTimestamp) {
|
||||||
|
// Check if draft is less than 7 days old
|
||||||
|
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
||||||
|
if (draftTimestamp > sevenDaysAgo) {
|
||||||
|
const indicator = showDraftIndicator(
|
||||||
|
existingDraft,
|
||||||
|
draftTimestamp,
|
||||||
|
(content) => {
|
||||||
|
chatInput.value = content;
|
||||||
|
chatInput.focus();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
clearDraft(draftKey);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Insert indicator before chat input
|
||||||
|
const chatInputContainer = document.querySelector('.chat-input');
|
||||||
|
chatInputContainer.parentNode.insertBefore(indicator, chatInputContainer);
|
||||||
|
} else {
|
||||||
|
// Draft is too old, clear it
|
||||||
|
clearDraft(draftKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save draft on input (debounced)
|
||||||
|
chatInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(draftSaveTimeout);
|
||||||
|
draftSaveTimeout = setTimeout(() => {
|
||||||
|
const content = chatInput.value.trim();
|
||||||
|
if (content) {
|
||||||
|
saveDraft(draftKey, content);
|
||||||
|
} else {
|
||||||
|
clearDraft(draftKey);
|
||||||
|
}
|
||||||
|
}, 2000); // Save after 2 seconds of inactivity
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear draft after successful message send
|
||||||
|
const originalSendMessage = window.sendMessage;
|
||||||
|
window.sendMessage = function() {
|
||||||
|
const input = document.getElementById('chat-input-text');
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (content !== '') {
|
||||||
|
clearDraft(draftKey);
|
||||||
|
}
|
||||||
|
originalSendMessage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check for scroll position
|
||||||
|
checkScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyGrouping() {
|
||||||
|
const container = document.getElementById('chat-messages');
|
||||||
|
const msgs = Array.from(container.querySelectorAll('.chat-message')).filter(el => el.id.startsWith('msg-'));
|
||||||
|
|
||||||
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
|
const curr = msgs[i];
|
||||||
|
|
||||||
|
// Remove grouped class first
|
||||||
|
curr.classList.remove('grouped');
|
||||||
|
|
||||||
|
if (i === 0) continue; // First message is never grouped
|
||||||
|
|
||||||
|
const prev = msgs[i - 1];
|
||||||
|
const currUser = curr.dataset.user;
|
||||||
|
const prevUser = prev.dataset.user;
|
||||||
|
const currReply = parseInt(curr.dataset.reply) || -1;
|
||||||
|
|
||||||
|
// Group if same user and not a reply (reply is -1 or 0 when there's no reply)
|
||||||
|
if (currUser === prevUser && (currReply <= 0)) {
|
||||||
|
curr.classList.add('grouped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
{{define "preferences"}}{{template "base" .}}{{end}}
|
{{define "preferences"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
{{define "preferences-content"}}
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<main>
|
||||||
<header>
|
<header>
|
||||||
<h2>Preferences</h2>
|
<h2>Preferences</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -23,4 +31,8 @@
|
||||||
<input type="submit" value="Save Preferences" style="margin-top: 2em;">
|
<input type="submit" value="Save Preferences" style="margin-top: 2em;">
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
{{template "cookie_banner" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue