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