Compare commits

..

No commits in common. "2c7634da43cb43c44ca3912991a2358fd4083cbe" and "78a287595809d4dd2a11c85f6cf35daaa8d105e2" have entirely different histories.

14 changed files with 1406 additions and 1488 deletions

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -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();
}

View File

@ -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();
}
})();

View File

@ -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;
}

View File

@ -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);
}
});
});
}

View File

@ -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 + ' ✓';
}
});
});
}

View File

@ -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);
});
}
});
}

View File

@ -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 */

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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}}

View File

@ -1,15 +1,23 @@
{{define "preferences"}}{{template "base" .}}{{end}} {{define "preferences"}}
<!DOCTYPE html>
{{define "preferences-content"}} <html>
<header> <head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
<main>
<header>
<h2>Preferences</h2> <h2>Preferences</h2>
</header> </header>
{{if .ShowSuccess}} {{if .ShowSuccess}}
<div class="notification success" style="position: static; margin-bottom: 1em; animation: none;"> <div class="notification success" style="position: static; margin-bottom: 1em; animation: none;">
Preferences saved successfully! Preferences saved successfully!
</div> </div>
{{end}} {{end}}
<section> <section>
<form method="post" action="{{.BasePath}}/preferences/"> <form method="post" action="{{.BasePath}}/preferences/">
<h3>Draft Auto-Save</h3> <h3>Draft Auto-Save</h3>
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;"> <label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
@ -22,5 +30,9 @@
<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}}