Compare commits
No commits in common. "83113a563acf1ec954795a63910c2c2f88d32259" and "00185e67744671910d3978f20aee23d656335ee3" have entirely different histories.
83113a563a
...
00185e6774
|
|
@ -83,10 +83,9 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
type IncomingChatMessage struct {
|
type IncomingChatMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyTo int `json:"replyTo"`
|
ReplyTo int `json:"replyTo"`
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ChatHandler(app *App) http.HandlerFunc {
|
func ChatHandler(app *App) http.HandlerFunc {
|
||||||
|
|
@ -171,19 +170,7 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if chatMsg.Type == "typing" {
|
if chatMsg.Type == "message" {
|
||||||
// Broadcast typing indicator to other clients
|
|
||||||
typingMsg := map[string]interface{}{
|
|
||||||
"type": "typing",
|
|
||||||
"username": chatMsg.Username,
|
|
||||||
}
|
|
||||||
typingJSON, _ := json.Marshal(typingMsg)
|
|
||||||
for c := range hub.clients {
|
|
||||||
if c.boardID == boardID && c.userID != userID {
|
|
||||||
c.conn.WriteMessage(websocket.TextMessage, typingJSON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if chatMsg.Type == "message" {
|
|
||||||
if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
|
if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
|
||||||
log.Printf("Error saving chat message: %v", err)
|
log.Printf("Error saving chat message: %v", err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,137 +1,135 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gorilla/sessions"
|
"log"
|
||||||
"log"
|
"net/http"
|
||||||
"net/http"
|
"strconv"
|
||||||
"strconv"
|
"threadr/models"
|
||||||
"threadr/models"
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ThreadHandler(app *App) http.HandlerFunc {
|
func ThreadHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
loggedIn := session.Values["user_id"] != nil
|
loggedIn := session.Values["user_id"] != nil
|
||||||
userID, _ := session.Values["user_id"].(int)
|
userID, _ := session.Values["user_id"].(int)
|
||||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||||
|
|
||||||
threadIDStr := r.URL.Query().Get("id")
|
threadIDStr := r.URL.Query().Get("id")
|
||||||
threadID, err := strconv.Atoi(threadIDStr)
|
threadID, err := strconv.Atoi(threadIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
|
http.Error(w, "Invalid thread ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
thread, err := models.GetThreadByID(app.DB, threadID)
|
thread, err := models.GetThreadByID(app.DB, threadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching thread: %v", err)
|
log.Printf("Error fetching thread: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if thread == nil {
|
if thread == nil {
|
||||||
http.Error(w, "Thread not found", http.StatusNotFound)
|
http.Error(w, "Thread not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
board, err := models.GetBoardByID(app.DB, thread.BoardID)
|
board, err := models.GetBoardByID(app.DB, thread.BoardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching board: %v", err)
|
log.Printf("Error fetching board: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if board.Private {
|
if board.Private {
|
||||||
if !loggedIn {
|
if !loggedIn {
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking permission: %v", err)
|
log.Printf("Error checking permission: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasPerm {
|
if !hasPerm {
|
||||||
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
http.Error(w, "You do not have permission to view this board", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost && loggedIn {
|
if r.Method == http.MethodPost && loggedIn {
|
||||||
action := r.URL.Query().Get("action")
|
action := r.URL.Query().Get("action")
|
||||||
if action == "submit" {
|
if action == "submit" {
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
replyToStr := r.URL.Query().Get("to")
|
replyToStr := r.URL.Query().Get("to")
|
||||||
replyTo := -1
|
replyTo := -1
|
||||||
if replyToStr != "" {
|
if replyToStr != "" {
|
||||||
replyTo, err = strconv.Atoi(replyToStr)
|
replyTo, err = strconv.Atoi(replyToStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
|
http.Error(w, "Invalid reply_to ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if content == "" {
|
if content == "" {
|
||||||
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if board.Private {
|
if board.Private {
|
||||||
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
|
hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error checking permission: %v", err)
|
log.Printf("Error checking permission: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !hasPerm {
|
if !hasPerm {
|
||||||
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
http.Error(w, "You do not have permission to post in this board", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post := models.Post{
|
post := models.Post{
|
||||||
ThreadID: threadID,
|
ThreadID: threadID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Content: content,
|
Content: content,
|
||||||
ReplyTo: replyTo,
|
ReplyTo: replyTo,
|
||||||
}
|
}
|
||||||
err = models.CreatePost(app.DB, post)
|
err = models.CreatePost(app.DB, post)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating post: %v", err)
|
log.Printf("Error creating post: %v", err)
|
||||||
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
|
http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posts, err := models.GetPostsByThreadID(app.DB, threadID)
|
posts, err := models.GetPostsByThreadID(app.DB, threadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching posts: %v", err)
|
log.Printf("Error fetching posts: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Thread models.Thread
|
Thread models.Thread
|
||||||
Board models.Board
|
Posts []models.Post
|
||||||
Posts []models.Post
|
}{
|
||||||
}{
|
PageData: PageData{
|
||||||
PageData: PageData{
|
Title: "ThreadR - " + thread.Title,
|
||||||
Title: "ThreadR - " + thread.Title,
|
Navbar: "boards",
|
||||||
Navbar: "boards",
|
LoggedIn: loggedIn,
|
||||||
LoggedIn: loggedIn,
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
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,
|
},
|
||||||
},
|
Thread: *thread,
|
||||||
Thread: *thread,
|
Posts: posts,
|
||||||
Board: *board,
|
}
|
||||||
Posts: posts,
|
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
||||||
}
|
log.Printf("Error executing template in ThreadHandler: %v", err)
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
log.Printf("Error executing template in ThreadHandler: %v", err)
|
return
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
}
|
||||||
return
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
190
static/app.js
190
static/app.js
|
|
@ -45,21 +45,125 @@ function enableEnterToSubmit(input, form) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize textarea as user types
|
||||||
|
function autoResizeTextarea(textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add character counter to textarea
|
||||||
|
function addCharacterCounter(textarea, maxLength) {
|
||||||
|
const counter = document.createElement('div');
|
||||||
|
counter.className = 'char-counter';
|
||||||
|
textarea.parentNode.insertBefore(counter, textarea.nextSibling);
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const length = textarea.value.length;
|
||||||
|
counter.textContent = `${length}${maxLength ? '/' + maxLength : ''} characters`;
|
||||||
|
|
||||||
|
if (maxLength && length > maxLength * 0.9) {
|
||||||
|
counter.classList.add('warning');
|
||||||
|
} else {
|
||||||
|
counter.classList.remove('warning');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic UI for like/dislike buttons
|
textarea.addEventListener('input', updateCounter);
|
||||||
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
|
updateCounter();
|
||||||
form.addEventListener('submit', (e) => {
|
}
|
||||||
const button = form.querySelector('button[type="submit"]');
|
|
||||||
if (button) {
|
// Client-side validation helpers
|
||||||
button.style.opacity = '0.5';
|
function validateUsername(username) {
|
||||||
button.textContent = button.textContent + ' ✓';
|
if (username.length < 3) {
|
||||||
}
|
return 'Username must be at least 3 characters';
|
||||||
});
|
}
|
||||||
|
if (username.length > 30) {
|
||||||
|
return 'Username must be less than 30 characters';
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
|
return 'Username can only contain letters, numbers, underscores, and hyphens';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassword(password) {
|
||||||
|
if (password.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
if (password.length > 128) {
|
||||||
|
return 'Password is too long';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequired(value, fieldName) {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return `${fieldName} is required`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show field error
|
||||||
|
function showFieldError(input, message) {
|
||||||
|
input.classList.add('error');
|
||||||
|
|
||||||
|
// Remove existing error message
|
||||||
|
const existingError = input.parentNode.querySelector('.field-error');
|
||||||
|
if (existingError) {
|
||||||
|
existingError.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'field-error';
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
input.parentNode.insertBefore(errorDiv, input.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear field error
|
||||||
|
function clearFieldError(input) {
|
||||||
|
input.classList.remove('error');
|
||||||
|
const errorDiv = input.parentNode.querySelector('.field-error');
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative time formatting
|
||||||
|
function formatRelativeTime(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||||
|
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||||
|
if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||||
|
if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`;
|
||||||
|
return `${years} year${years !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert timestamps to relative time
|
||||||
|
function initRelativeTimestamps() {
|
||||||
|
document.querySelectorAll('[data-timestamp]').forEach(element => {
|
||||||
|
const timestamp = element.getAttribute('data-timestamp');
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const originalText = element.textContent;
|
||||||
|
|
||||||
|
element.textContent = formatRelativeTime(date);
|
||||||
|
element.title = originalText;
|
||||||
|
element.style.cursor = 'help';
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on DOM ready
|
// Initialize on DOM ready
|
||||||
|
|
@ -69,70 +173,8 @@ if (document.readyState === 'loading') {
|
||||||
initRelativeTimestamps();
|
initRelativeTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to top button functionality
|
|
||||||
function initScrollToTop() {
|
|
||||||
const scrollButton = document.createElement('button');
|
|
||||||
scrollButton.className = 'scroll-to-top';
|
|
||||||
scrollButton.innerHTML = '↑';
|
|
||||||
scrollButton.setAttribute('aria-label', 'Scroll to top');
|
|
||||||
scrollButton.title = 'Scroll to top';
|
|
||||||
document.body.appendChild(scrollButton);
|
|
||||||
|
|
||||||
// Show/hide button based on scroll position
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
if (window.pageYOffset > 300) {
|
|
||||||
scrollButton.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
scrollButton.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to top when clicked
|
|
||||||
scrollButton.addEventListener('click', () => {
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
function initKeyboardShortcuts() {
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
// Ctrl+Enter or Cmd+Enter to submit forms
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
if (activeElement && activeElement.tagName === 'TEXTAREA') {
|
|
||||||
const form = activeElement.closest('form');
|
|
||||||
if (form) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape to cancel/close things
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
// Clear any active focus from textareas
|
|
||||||
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide notifications
|
|
||||||
document.querySelectorAll('.notification').forEach(notif => {
|
|
||||||
notif.classList.add('hiding');
|
|
||||||
setTimeout(() => notif.remove(), 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add form submission handlers
|
// Add form submission handlers
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Initialize scroll to top and keyboard shortcuts
|
|
||||||
initScrollToTop();
|
|
||||||
initKeyboardShortcuts();
|
|
||||||
|
|
||||||
// Handle all form submissions
|
// Handle all form submissions
|
||||||
document.querySelectorAll('form').forEach(form => {
|
document.querySelectorAll('form').forEach(form => {
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
|
|
|
||||||
122
static/style.css
122
static/style.css
|
|
@ -435,128 +435,6 @@ p.thread-info {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breadcrumb navigation */
|
|
||||||
.breadcrumb {
|
|
||||||
padding: 8px 0;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb a {
|
|
||||||
color: #001858;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb a:hover {
|
|
||||||
color: #f582ae;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
margin: 0 8px;
|
|
||||||
color: #001858;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-current {
|
|
||||||
color: #001858;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back button */
|
|
||||||
.back-button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px 16px;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
background-color: #f3d2c1;
|
|
||||||
color: #001858;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1px solid #001858;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0px 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button::before {
|
|
||||||
content: "← ";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll to top button */
|
|
||||||
.scroll-to-top {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 30px;
|
|
||||||
right: 30px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
background-color: #001858;
|
|
||||||
color: #fef6e4;
|
|
||||||
border: 2px solid #001858;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-to-top.visible {
|
|
||||||
opacity: 0.8;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-to-top:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-3px);
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.breadcrumb a {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-separator {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-current {
|
|
||||||
color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button {
|
|
||||||
background-color: #555;
|
|
||||||
color: #fef6e4;
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
color: #001858;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-to-top {
|
|
||||||
background-color: #fef6e4;
|
|
||||||
color: #001858;
|
|
||||||
border-color: #fef6e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-to-top:hover {
|
|
||||||
background-color: #8bd3dd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner */
|
/* Loading spinner */
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,6 @@
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
<div class="breadcrumb">
|
|
||||||
<a href="{{.BasePath}}/">Home</a>
|
|
||||||
<span class="breadcrumb-separator">›</span>
|
|
||||||
<a href="{{.BasePath}}/boards/">Boards</a>
|
|
||||||
<span class="breadcrumb-separator">›</span>
|
|
||||||
<span class="breadcrumb-current">{{.Board.Name}}</span>
|
|
||||||
</div>
|
|
||||||
<a href="{{.BasePath}}/boards/" class="back-button">Back to Boards</a>
|
|
||||||
<header>
|
<header>
|
||||||
<h2>{{.Board.Name}}</h2>
|
<h2>{{.Board.Name}}</h2>
|
||||||
<p>{{.Board.Description}}</p>
|
<p>{{.Board.Description}}</p>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
<head>
|
<head>
|
||||||
<title>{{.Title}}</title>
|
<title>{{.Title}}</title>
|
||||||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -34,145 +33,6 @@
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid #001858;
|
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 {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -335,24 +195,6 @@
|
||||||
.chat-container {
|
.chat-container {
|
||||||
background-color: #444;
|
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 {
|
.chat-header {
|
||||||
border-color: #fef6e4;
|
border-color: #fef6e4;
|
||||||
}
|
}
|
||||||
|
|
@ -417,25 +259,11 @@
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="chat-breadcrumb">
|
|
||||||
<a href="{{.BasePath}}/">Home</a>
|
|
||||||
<span class="chat-breadcrumb-separator">›</span>
|
|
||||||
<a href="{{.BasePath}}/boards/">Boards</a>
|
|
||||||
<span class="chat-breadcrumb-separator">›</span>
|
|
||||||
<span>{{.Board.Name}}</span>
|
|
||||||
</div>
|
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div class="connection-status">
|
|
||||||
<div class="connection-dot connecting" id="connection-dot"></div>
|
|
||||||
<span id="connection-text">Connecting...</span>
|
|
||||||
</div>
|
|
||||||
<h2>{{.Board.Name}}</h2>
|
<h2>{{.Board.Name}}</h2>
|
||||||
<p>{{.Board.Description}}</p>
|
<p>{{.Board.Description}}</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="chat-messages" id="chat-messages">
|
<div class="chat-messages" id="chat-messages">
|
||||||
<div class="typing-indicator" id="typing-indicator">
|
|
||||||
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
|
|
||||||
</div>
|
|
||||||
{{range .Messages}}
|
{{range .Messages}}
|
||||||
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
|
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
|
||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
|
|
@ -457,10 +285,6 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
|
|
||||||
↓
|
|
||||||
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
|
||||||
</button>
|
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
<div id="reply-indicator" class="reply-indicator">
|
<div id="reply-indicator" class="reply-indicator">
|
||||||
<span id="reply-username">Replying to </span>
|
<span id="reply-username">Replying to </span>
|
||||||
|
|
@ -477,149 +301,45 @@
|
||||||
let ws;
|
let ws;
|
||||||
let autocompleteActive = false;
|
let autocompleteActive = false;
|
||||||
let replyToId = -1;
|
let replyToId = -1;
|
||||||
let reconnectAttempts = 0;
|
|
||||||
let reconnectTimeout;
|
|
||||||
let typingTimeout;
|
|
||||||
let isUserAtBottom = true;
|
|
||||||
let unreadCount = 0;
|
|
||||||
const allUsernames = {{.AllUsernames}};
|
const allUsernames = {{.AllUsernames}};
|
||||||
const currentUsername = "{{.CurrentUsername}}";
|
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() {
|
function connectWebSocket() {
|
||||||
const boardID = {{.Board.ID}};
|
const boardID = {{.Board.ID}};
|
||||||
updateConnectionStatus('connecting');
|
|
||||||
|
|
||||||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
|
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
|
||||||
|
|
||||||
ws.onopen = function() {
|
|
||||||
updateConnectionStatus('connected');
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
ws.onmessage = function(event) {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
// Handle typing indicator
|
|
||||||
if (msg.type === 'typing') {
|
|
||||||
showTypingIndicator(msg.username);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle regular message
|
|
||||||
appendMessage(msg);
|
appendMessage(msg);
|
||||||
};
|
};
|
||||||
ws.onclose = function() {
|
ws.onclose = function() {
|
||||||
updateConnectionStatus('disconnected');
|
|
||||||
console.log("WebSocket closed, reconnecting...");
|
console.log("WebSocket closed, reconnecting...");
|
||||||
scheduleReconnect();
|
setTimeout(connectWebSocket, 5000);
|
||||||
};
|
};
|
||||||
ws.onerror = function(error) {
|
ws.onerror = function(error) {
|
||||||
console.error("WebSocket error:", 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() {
|
function sendMessage() {
|
||||||
const input = document.getElementById('chat-input-text');
|
const input = document.getElementById('chat-input-text');
|
||||||
const content = input.value.trim();
|
const content = input.value.trim();
|
||||||
if (content === '') return;
|
if (content === '') return;
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
content: content,
|
content: content,
|
||||||
replyTo: replyToId
|
replyTo: replyToId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(msg));
|
ws.send(JSON.stringify(msg));
|
||||||
input.value = '';
|
input.value = '';
|
||||||
cancelReply();
|
cancelReply();
|
||||||
} else {
|
} else {
|
||||||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
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) {
|
function appendMessage(msg) {
|
||||||
const messages = document.getElementById('chat-messages');
|
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');
|
const msgDiv = document.createElement('div');
|
||||||
let highlightClass = '';
|
let highlightClass = '';
|
||||||
if (msg.mentions && msg.mentions.includes(currentUsername)) {
|
if (msg.mentions && msg.mentions.includes(currentUsername)) {
|
||||||
|
|
@ -646,60 +366,9 @@
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
messages.appendChild(msgDiv);
|
messages.appendChild(msgDiv);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
// 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) {
|
function replyToMessage(id, username) {
|
||||||
replyToId = id;
|
replyToId = id;
|
||||||
const replyIndicator = document.getElementById('reply-indicator');
|
const replyIndicator = document.getElementById('reply-indicator');
|
||||||
|
|
@ -831,28 +500,7 @@
|
||||||
|
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
const messagesContainer = document.getElementById('chat-messages');
|
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial check for scroll position
|
|
||||||
checkScrollPosition();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,6 @@
|
||||||
<body>
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
<div class="breadcrumb">
|
|
||||||
<a href="{{.BasePath}}/">Home</a>
|
|
||||||
<span class="breadcrumb-separator">›</span>
|
|
||||||
<a href="{{.BasePath}}/boards/">Boards</a>
|
|
||||||
<span class="breadcrumb-separator">›</span>
|
|
||||||
<a href="{{.BasePath}}/board/?id={{.Board.ID}}">{{.Board.Name}}</a>
|
|
||||||
<span class="breadcrumb-separator">›</span>
|
|
||||||
<span class="breadcrumb-current">{{.Thread.Title}}</span>
|
|
||||||
</div>
|
|
||||||
<a href="{{.BasePath}}/board/?id={{.Board.ID}}" class="back-button">Back to {{.Board.Name}}</a>
|
|
||||||
<header>
|
<header>
|
||||||
<h2>{{.Thread.Title}}</h2>
|
<h2>{{.Thread.Title}}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue