Compare commits
3 Commits
78a2875958
...
2c7634da43
| Author | SHA1 | Date |
|---|---|---|
|
|
2c7634da43 | |
|
|
56416b78ec | |
|
|
5553a8af01 |
|
|
@ -18,6 +18,8 @@ type PageData struct {
|
|||
BasePath string
|
||||
StaticPath string
|
||||
CurrentURL string
|
||||
ContentTemplate string
|
||||
BodyClass string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,8 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
ContentTemplate: "chat-content",
|
||||
BodyClass: "chat-page",
|
||||
},
|
||||
Board: *board,
|
||||
Messages: messages,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func PreferencesHandler(app *App) http.HandlerFunc {
|
|||
BasePath: app.Config.ThreadrDir,
|
||||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
ContentTemplate: "preferences-content",
|
||||
},
|
||||
Preferences: prefs,
|
||||
ShowSuccess: showSuccess,
|
||||
|
|
|
|||
442
static/app.js
442
static/app.js
|
|
@ -1,429 +1,25 @@
|
|||
// ThreadR UI Enhancement JavaScript
|
||||
|
||||
// Show notification toast
|
||||
function showNotification(message, type = 'info', duration = 3000) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('hiding');
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Add loading state to form submission
|
||||
function handleFormSubmit(form, button) {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.classList.add('loading');
|
||||
}
|
||||
|
||||
// Find all submit buttons in the form and disable them
|
||||
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
|
||||
submitButtons.forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove loading state
|
||||
function removeLoadingState(form) {
|
||||
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
|
||||
submitButtons.forEach(btn => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
});
|
||||
}
|
||||
|
||||
// Enable Enter-to-submit for single-line inputs
|
||||
function enableEnterToSubmit(input, form) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Optimistic UI for like/dislike buttons
|
||||
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.style.opacity = '0.5';
|
||||
button.textContent = button.textContent + ' ✓';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initRelativeTimestamps);
|
||||
} else {
|
||||
initRelativeTimestamps();
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
function initKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+Enter or Cmd+Enter to submit forms
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && activeElement.tagName === 'TEXTAREA') {
|
||||
const form = activeElement.closest('form');
|
||||
if (form) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel/close things
|
||||
if (e.key === 'Escape') {
|
||||
// Clear any active focus from textareas
|
||||
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
// Hide notifications
|
||||
document.querySelectorAll('.notification').forEach(notif => {
|
||||
notif.classList.add('hiding');
|
||||
setTimeout(() => notif.remove(), 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add form submission handlers
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize keyboard shortcuts
|
||||
initKeyboardShortcuts();
|
||||
|
||||
// Handle all form submissions
|
||||
document.querySelectorAll('form').forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||
handleFormSubmit(form, submitButton);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-resize textareas
|
||||
document.querySelectorAll('textarea').forEach(textarea => {
|
||||
textarea.addEventListener('input', () => autoResizeTextarea(textarea));
|
||||
|
||||
// Add character counter for content fields
|
||||
if (textarea.id === 'content' || textarea.name === 'content') {
|
||||
addCharacterCounter(textarea, 10000);
|
||||
}
|
||||
if (textarea.id === 'bio' || textarea.name === 'bio') {
|
||||
addCharacterCounter(textarea, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable Enter-to-submit for single-line forms (login, etc.)
|
||||
const loginForm = document.querySelector('form[action*="login"]');
|
||||
if (loginForm) {
|
||||
const passwordInput = loginForm.querySelector('input[type="password"]');
|
||||
if (passwordInput) {
|
||||
enableEnterToSubmit(passwordInput, loginForm);
|
||||
}
|
||||
}
|
||||
|
||||
// Add validation to login form
|
||||
const loginUsername = document.querySelector('input[name="username"]');
|
||||
const loginPassword = document.querySelector('input[name="password"]');
|
||||
|
||||
if (loginUsername && loginPassword) {
|
||||
loginUsername.addEventListener('blur', () => {
|
||||
const error = validateRequired(loginUsername.value, 'Username');
|
||||
if (error) {
|
||||
showFieldError(loginUsername, error);
|
||||
} else {
|
||||
clearFieldError(loginUsername);
|
||||
}
|
||||
});
|
||||
|
||||
loginPassword.addEventListener('blur', () => {
|
||||
const error = validateRequired(loginPassword.value, 'Password');
|
||||
if (error) {
|
||||
showFieldError(loginPassword, error);
|
||||
} else {
|
||||
clearFieldError(loginPassword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add validation to signup form
|
||||
const signupForm = document.querySelector('form[action*="signup"]');
|
||||
if (signupForm) {
|
||||
const usernameInput = signupForm.querySelector('input[name="username"]');
|
||||
const passwordInput = signupForm.querySelector('input[name="password"]');
|
||||
const confirmInput = signupForm.querySelector('input[name="password_confirm"]');
|
||||
|
||||
if (usernameInput) {
|
||||
usernameInput.addEventListener('blur', () => {
|
||||
const error = validateUsername(usernameInput.value);
|
||||
if (error) {
|
||||
showFieldError(usernameInput, error);
|
||||
} else {
|
||||
clearFieldError(usernameInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('blur', () => {
|
||||
const error = validatePassword(passwordInput.value);
|
||||
if (error) {
|
||||
showFieldError(passwordInput, error);
|
||||
} else {
|
||||
clearFieldError(passwordInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmInput && passwordInput) {
|
||||
confirmInput.addEventListener('blur', () => {
|
||||
if (confirmInput.value !== passwordInput.value) {
|
||||
showFieldError(confirmInput, 'Passwords do not match');
|
||||
} else {
|
||||
clearFieldError(confirmInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
signupForm.addEventListener('submit', (e) => {
|
||||
let hasError = false;
|
||||
|
||||
if (usernameInput) {
|
||||
const error = validateUsername(usernameInput.value);
|
||||
if (error) {
|
||||
showFieldError(usernameInput, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
const error = validatePassword(passwordInput.value);
|
||||
if (error) {
|
||||
showFieldError(passwordInput, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmInput && passwordInput && confirmInput.value !== passwordInput.value) {
|
||||
showFieldError(confirmInput, 'Passwords do not match');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
e.preventDefault();
|
||||
removeLoadingState(signupForm);
|
||||
showNotification('Please fix the errors before submitting', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add validation to thread/post forms
|
||||
document.querySelectorAll('input[name="title"]').forEach(input => {
|
||||
input.addEventListener('blur', () => {
|
||||
const error = validateRequired(input.value, 'Title');
|
||||
if (error) {
|
||||
showFieldError(input, error);
|
||||
} else if (input.value.length > 255) {
|
||||
showFieldError(input, 'Title is too long (max 255 characters)');
|
||||
} else {
|
||||
clearFieldError(input);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('textarea[name="content"]').forEach(textarea => {
|
||||
textarea.addEventListener('blur', () => {
|
||||
const error = validateRequired(textarea.value, 'Content');
|
||||
if (error) {
|
||||
showFieldError(textarea, error);
|
||||
} else {
|
||||
clearFieldError(textarea);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Draft Auto-Save Functions
|
||||
// ============================================
|
||||
|
||||
// Save draft to localStorage
|
||||
function saveDraft(key, content) {
|
||||
try {
|
||||
localStorage.setItem(key, content);
|
||||
localStorage.setItem(key + '_timestamp', Date.now().toString());
|
||||
} catch (e) {
|
||||
console.error('Failed to save draft:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load draft from localStorage
|
||||
function loadDraft(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
console.error('Failed to load draft:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear draft from localStorage
|
||||
function clearDraft(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(key + '_timestamp');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear draft:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get draft timestamp
|
||||
function getDraftTimestamp(key) {
|
||||
try {
|
||||
const timestamp = localStorage.getItem(key + '_timestamp');
|
||||
return timestamp ? parseInt(timestamp) : null;
|
||||
} catch (e) {
|
||||
console.error('Failed to get draft timestamp:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format time ago
|
||||
function formatTimeAgo(timestamp) {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
// Show draft restoration indicator
|
||||
function showDraftIndicator(content, timestamp, onRestore, onDiscard) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'draft-indicator';
|
||||
indicator.innerHTML = `
|
||||
<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() {
|
||||
// This function can be used to show relative timestamps
|
||||
// For now, it's just a placeholder
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
initRelativeTimestamps();
|
||||
if (typeof initKeyboardShortcuts === 'function') {
|
||||
initKeyboardShortcuts();
|
||||
}
|
||||
if (typeof initFormHandling === 'function') {
|
||||
initFormHandling();
|
||||
}
|
||||
if (typeof initLikeButtons === 'function') {
|
||||
initLikeButtons();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
} else {
|
||||
initApp();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,564 @@
|
|||
(() => {
|
||||
'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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
function saveDraft(key, content) {
|
||||
try {
|
||||
localStorage.setItem(key, content);
|
||||
localStorage.setItem(key + '_timestamp', Date.now().toString());
|
||||
} catch (e) {
|
||||
console.error('Failed to save draft:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadDraft(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
console.error('Failed to load draft:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(key + '_timestamp');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear draft:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getDraftTimestamp(key) {
|
||||
try {
|
||||
const timestamp = localStorage.getItem(key + '_timestamp');
|
||||
return timestamp ? parseInt(timestamp, 10) : null;
|
||||
} catch (e) {
|
||||
console.error('Failed to get draft timestamp:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
function showDraftIndicator(content, timestamp, onRestore, onDiscard) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'draft-indicator';
|
||||
indicator.innerHTML = `
|
||||
<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;
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
function showNotification(message, type = 'info', duration = 3000) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('hiding');
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function handleFormSubmit(form, button) {
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.classList.add('loading');
|
||||
}
|
||||
|
||||
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
|
||||
submitButtons.forEach(btn => {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
});
|
||||
}
|
||||
|
||||
function removeLoadingState(form) {
|
||||
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
|
||||
submitButtons.forEach(btn => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
});
|
||||
}
|
||||
|
||||
function enableEnterToSubmit(input, form) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function autoResizeTextarea(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
function addCharacterCounter(textarea, maxLength) {
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'char-counter';
|
||||
textarea.parentNode.insertBefore(counter, textarea.nextSibling);
|
||||
|
||||
const updateCounter = () => {
|
||||
const length = textarea.value.length;
|
||||
counter.textContent = `${length}/${maxLength}`;
|
||||
if (length > maxLength * 0.9) {
|
||||
counter.classList.add('warning');
|
||||
} else {
|
||||
counter.classList.remove('warning');
|
||||
}
|
||||
};
|
||||
|
||||
textarea.addEventListener('input', updateCounter);
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function initFormHandling() {
|
||||
document.querySelectorAll('form').forEach(form => {
|
||||
form.addEventListener('submit', () => {
|
||||
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||
handleFormSubmit(form, submitButton);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('textarea').forEach(textarea => {
|
||||
textarea.addEventListener('input', () => autoResizeTextarea(textarea));
|
||||
|
||||
if (textarea.id === 'content' || textarea.name === 'content') {
|
||||
addCharacterCounter(textarea, 10000);
|
||||
}
|
||||
if (textarea.id === 'bio' || textarea.name === 'bio') {
|
||||
addCharacterCounter(textarea, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const loginForm = document.querySelector('form[action*="login"]');
|
||||
if (loginForm) {
|
||||
const passwordInput = loginForm.querySelector('input[type="password"]');
|
||||
if (passwordInput) {
|
||||
enableEnterToSubmit(passwordInput, loginForm);
|
||||
}
|
||||
}
|
||||
|
||||
const loginUsername = document.querySelector('input[name="username"]');
|
||||
const loginPassword = document.querySelector('input[name="password"]');
|
||||
|
||||
if (loginUsername && loginPassword) {
|
||||
loginUsername.addEventListener('blur', () => {
|
||||
const error = validateRequired(loginUsername.value, 'Username');
|
||||
if (error) {
|
||||
showFieldError(loginUsername, error);
|
||||
} else {
|
||||
clearFieldError(loginUsername);
|
||||
}
|
||||
});
|
||||
|
||||
loginPassword.addEventListener('blur', () => {
|
||||
const error = validateRequired(loginPassword.value, 'Password');
|
||||
if (error) {
|
||||
showFieldError(loginPassword, error);
|
||||
} else {
|
||||
clearFieldError(loginPassword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const signupForm = document.querySelector('form[action*="signup"]');
|
||||
if (signupForm) {
|
||||
const usernameInput = signupForm.querySelector('input[name="username"]');
|
||||
const passwordInput = signupForm.querySelector('input[name="password"]');
|
||||
const confirmInput = signupForm.querySelector('input[name="password_confirm"]');
|
||||
|
||||
if (usernameInput) {
|
||||
usernameInput.addEventListener('blur', () => {
|
||||
const error = validateUsername(usernameInput.value);
|
||||
if (error) {
|
||||
showFieldError(usernameInput, error);
|
||||
} else {
|
||||
clearFieldError(usernameInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('blur', () => {
|
||||
const error = validatePassword(passwordInput.value);
|
||||
if (error) {
|
||||
showFieldError(passwordInput, error);
|
||||
} else {
|
||||
clearFieldError(passwordInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmInput && passwordInput) {
|
||||
confirmInput.addEventListener('blur', () => {
|
||||
if (confirmInput.value !== passwordInput.value) {
|
||||
showFieldError(confirmInput, 'Passwords do not match');
|
||||
} else {
|
||||
clearFieldError(confirmInput);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
signupForm.addEventListener('submit', (e) => {
|
||||
let hasError = false;
|
||||
|
||||
if (usernameInput) {
|
||||
const error = validateUsername(usernameInput.value);
|
||||
if (error) {
|
||||
showFieldError(usernameInput, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
const error = validatePassword(passwordInput.value);
|
||||
if (error) {
|
||||
showFieldError(passwordInput, error);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmInput && passwordInput && confirmInput.value !== passwordInput.value) {
|
||||
showFieldError(confirmInput, 'Passwords do not match');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
e.preventDefault();
|
||||
removeLoadingState(signupForm);
|
||||
showNotification('Please fix the errors before submitting', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="title"]').forEach(input => {
|
||||
input.addEventListener('blur', () => {
|
||||
const error = validateRequired(input.value, 'Title');
|
||||
if (error) {
|
||||
showFieldError(input, error);
|
||||
} else if (input.value.length > 255) {
|
||||
showFieldError(input, 'Title is too long (max 255 characters)');
|
||||
} else {
|
||||
clearFieldError(input);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('textarea[name="content"]').forEach(textarea => {
|
||||
textarea.addEventListener('blur', () => {
|
||||
const error = validateRequired(textarea.value, 'Content');
|
||||
if (error) {
|
||||
showFieldError(textarea, error);
|
||||
} else {
|
||||
clearFieldError(textarea);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
function initLikeButtons() {
|
||||
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
|
||||
form.addEventListener('submit', () => {
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (button) {
|
||||
button.style.opacity = '0.5';
|
||||
button.textContent = button.textContent + ' ✓';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
function initKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && activeElement.tagName === 'TEXTAREA') {
|
||||
const form = activeElement.closest('form');
|
||||
if (form) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.notification').forEach(notif => {
|
||||
notif.classList.add('hiding');
|
||||
setTimeout(() => notif.remove(), 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
433
static/style.css
433
static/style.css
|
|
@ -6,6 +6,11 @@ body {
|
|||
color: #001858; /* blue */
|
||||
}
|
||||
|
||||
body.chat-page {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -13,6 +18,12 @@ main {
|
|||
padding: 25px;
|
||||
}
|
||||
|
||||
body.chat-page main {
|
||||
padding: 0;
|
||||
margin-top: 3em;
|
||||
height: calc(100vh - 3em);
|
||||
}
|
||||
|
||||
main > header {
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
|
|
@ -343,6 +354,344 @@ p.thread-info {
|
|||
background-color: #ffe0f0; /* Light pink background */
|
||||
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) */
|
||||
.chat-message.grouped {
|
||||
margin-top: -4px;
|
||||
|
|
@ -440,6 +789,90 @@ p.thread-info {
|
|||
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 */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
function validateRequired(value, fieldName) {
|
||||
if (!value || value.trim() === '') {
|
||||
return `${fieldName} is required`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateUsername(username) {
|
||||
if (!username || username.trim() === '') {
|
||||
return 'Username is required';
|
||||
}
|
||||
if (username.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
if (username.length > 50) {
|
||||
return 'Username must be less than 50 characters';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
return 'Username can only contain letters, numbers, and underscores';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePassword(password) {
|
||||
if (!password || password.trim() === '') {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function showFieldError(field, error) {
|
||||
field.classList.add('error');
|
||||
let errorDiv = field.nextElementSibling;
|
||||
if (!errorDiv || !errorDiv.classList.contains('field-error')) {
|
||||
errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
field.parentNode.insertBefore(errorDiv, field.nextSibling);
|
||||
}
|
||||
errorDiv.textContent = error;
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.classList.remove('error');
|
||||
const errorDiv = field.nextElementSibling;
|
||||
if (errorDiv && errorDiv.classList.contains('field-error')) {
|
||||
errorDiv.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,18 @@
|
|||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
<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}}/chat.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<body{{if .BodyClass}} class="{{.BodyClass}}"{{end}}>
|
||||
{{template "navbar" .}}
|
||||
<main>
|
||||
{{block "content" .}}{{end}} <!-- Define a block for content -->
|
||||
{{template .ContentTemplate .}}
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,422 +1,7 @@
|
|||
{{define "chat"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<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">
|
||||
{{define "chat"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "chat-content"}}
|
||||
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
|
||||
<div class="chat-breadcrumb">
|
||||
<a href="{{.BasePath}}/">Home</a>
|
||||
<span class="chat-breadcrumb-separator">›</span>
|
||||
|
|
@ -457,491 +42,19 @@
|
|||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
|
||||
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom" type="button">
|
||||
↓
|
||||
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
||||
</button>
|
||||
<div class="chat-input">
|
||||
<div id="reply-indicator" class="reply-indicator">
|
||||
<span id="reply-username">Replying to </span>
|
||||
<button onclick="cancelReply()">X</button>
|
||||
<button onclick="cancelReply()" type="button">X</button>
|
||||
</div>
|
||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||
<button onclick="sendMessage()">Send</button>
|
||||
<button onclick="sendMessage()" type="button">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||
</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>
|
||||
</div>
|
||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||
<script type="application/json" id="chat-usernames">{{.AllUsernames}}</script>
|
||||
{{end}}
|
||||
|
|
@ -1,23 +1,15 @@
|
|||
{{define "preferences"}}
|
||||
<!DOCTYPE html>
|
||||
<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>
|
||||
{{define "preferences"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "preferences-content"}}
|
||||
<header>
|
||||
<h2>Preferences</h2>
|
||||
</header>
|
||||
{{if .ShowSuccess}}
|
||||
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
|
||||
</header>
|
||||
{{if .ShowSuccess}}
|
||||
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
|
||||
Preferences saved successfully!
|
||||
</div>
|
||||
{{end}}
|
||||
<section>
|
||||
</div>
|
||||
{{end}}
|
||||
<section>
|
||||
<form method="post" action="{{.BasePath}}/preferences/">
|
||||
<h3>Draft Auto-Save</h3>
|
||||
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
|
||||
|
|
@ -30,9 +22,5 @@
|
|||
|
||||
<input type="submit" value="Save Preferences" style="margin-top: 2em;">
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{{template "cookie_banner" .}}
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
{{end}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue