move inline css and js into proper files
parent
78a2875958
commit
5553a8af01
|
|
@ -18,6 +18,8 @@ type PageData struct {
|
||||||
BasePath string
|
BasePath string
|
||||||
StaticPath string
|
StaticPath string
|
||||||
CurrentURL string
|
CurrentURL string
|
||||||
|
ContentTemplate string
|
||||||
|
BodyClass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,8 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
435
static/style.css
435
static/style.css
|
|
@ -6,6 +6,11 @@ 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;
|
||||||
|
|
@ -13,6 +18,12 @@ 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;
|
||||||
|
|
@ -343,6 +354,344 @@ 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;
|
||||||
|
|
@ -440,6 +789,90 @@ 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 */
|
||||||
|
|
@ -809,4 +1242,4 @@ input.error, textarea.error, select.error {
|
||||||
left: 10px;
|
left: 10px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
|
<script src="{{.StaticPath}}/chat.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body{{if .BodyClass}} class="{{.BodyClass}}"{{end}}>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
{{block "content" .}}{{end}} <!-- Define a block for content -->
|
{{template .ContentTemplate .}}
|
||||||
</main>
|
</main>
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue