From f7b8055062ff57ee2f41d4f9910cfe1a70de54c7 Mon Sep 17 00:00:00 2001 From: Jocadbz Date: Thu, 15 Jan 2026 22:49:54 -0300 Subject: [PATCH] UI: Add navigation improvements with breadcrumbs, back buttons, scroll-to-top, and keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Breadcrumb navigation on board and thread pages (Home › Boards › Board › Thread) - Back buttons to return to parent page (with arrow icon and hover effects) - Scroll-to-top button appears after scrolling 300px (smooth animation) - Keyboard shortcuts: Ctrl+Enter submits forms from textarea, Esc clears focus and closes notifications - Optimistic UI for like/dislike buttons (immediate visual feedback with checkmark) - Updated thread handler to pass board data for breadcrumb context --- handlers/thread.go | 248 ++++++++++++++++++------------------ static/app.js | 194 +++++++++++----------------- static/style.css | 122 ++++++++++++++++++ templates/pages/board.html | 8 ++ templates/pages/thread.html | 10 ++ 5 files changed, 341 insertions(+), 241 deletions(-) diff --git a/handlers/thread.go b/handlers/thread.go index df5c12c..312a8e8 100644 --- a/handlers/thread.go +++ b/handlers/thread.go @@ -1,135 +1,137 @@ package handlers import ( - "log" - "net/http" - "strconv" - "threadr/models" - "github.com/gorilla/sessions" + "github.com/gorilla/sessions" + "log" + "net/http" + "strconv" + "threadr/models" ) func ThreadHandler(app *App) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - session := r.Context().Value("session").(*sessions.Session) - loggedIn := session.Values["user_id"] != nil - userID, _ := session.Values["user_id"].(int) - cookie, _ := r.Cookie("threadr_cookie_banner") + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*sessions.Session) + loggedIn := session.Values["user_id"] != nil + userID, _ := session.Values["user_id"].(int) + cookie, _ := r.Cookie("threadr_cookie_banner") - threadIDStr := r.URL.Query().Get("id") - threadID, err := strconv.Atoi(threadIDStr) - if err != nil { - http.Error(w, "Invalid thread ID", http.StatusBadRequest) - return - } + threadIDStr := r.URL.Query().Get("id") + threadID, err := strconv.Atoi(threadIDStr) + if err != nil { + http.Error(w, "Invalid thread ID", http.StatusBadRequest) + return + } - thread, err := models.GetThreadByID(app.DB, threadID) - if err != nil { - log.Printf("Error fetching thread: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if thread == nil { - http.Error(w, "Thread not found", http.StatusNotFound) - return - } + thread, err := models.GetThreadByID(app.DB, threadID) + if err != nil { + log.Printf("Error fetching thread: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if thread == nil { + http.Error(w, "Thread not found", http.StatusNotFound) + return + } - board, err := models.GetBoardByID(app.DB, thread.BoardID) - if err != nil { - log.Printf("Error fetching board: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if board.Private { - if !loggedIn { - http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) - return - } - hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) - if err != nil { - log.Printf("Error checking permission: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if !hasPerm { - http.Error(w, "You do not have permission to view this board", http.StatusForbidden) - return - } - } + board, err := models.GetBoardByID(app.DB, thread.BoardID) + if err != nil { + log.Printf("Error fetching board: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if board.Private { + if !loggedIn { + http.Redirect(w, r, app.Config.ThreadrDir+"/login/", http.StatusFound) + return + } + hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermViewBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to view this board", http.StatusForbidden) + return + } + } - if r.Method == http.MethodPost && loggedIn { - action := r.URL.Query().Get("action") - if action == "submit" { - content := r.FormValue("content") - replyToStr := r.URL.Query().Get("to") - replyTo := -1 - if replyToStr != "" { - replyTo, err = strconv.Atoi(replyToStr) - if err != nil { - http.Error(w, "Invalid reply_to ID", http.StatusBadRequest) - return - } - } - if content == "" { - http.Error(w, "Content cannot be empty", http.StatusBadRequest) - return - } - if board.Private { - hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard) - if err != nil { - log.Printf("Error checking permission: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if !hasPerm { - http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) - return - } - } - post := models.Post{ - ThreadID: threadID, - UserID: userID, - Content: content, - ReplyTo: replyTo, - } - err = models.CreatePost(app.DB, post) - if err != nil { - log.Printf("Error creating post: %v", err) - http.Error(w, "Failed to create post", http.StatusInternalServerError) - return - } - http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound) - return - } - } + if r.Method == http.MethodPost && loggedIn { + action := r.URL.Query().Get("action") + if action == "submit" { + content := r.FormValue("content") + replyToStr := r.URL.Query().Get("to") + replyTo := -1 + if replyToStr != "" { + replyTo, err = strconv.Atoi(replyToStr) + if err != nil { + http.Error(w, "Invalid reply_to ID", http.StatusBadRequest) + return + } + } + if content == "" { + http.Error(w, "Content cannot be empty", http.StatusBadRequest) + return + } + if board.Private { + hasPerm, err := models.HasBoardPermission(app.DB, userID, board.ID, models.PermPostInBoard) + if err != nil { + log.Printf("Error checking permission: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, "You do not have permission to post in this board", http.StatusForbidden) + return + } + } + post := models.Post{ + ThreadID: threadID, + UserID: userID, + Content: content, + ReplyTo: replyTo, + } + err = models.CreatePost(app.DB, post) + if err != nil { + log.Printf("Error creating post: %v", err) + http.Error(w, "Failed to create post", http.StatusInternalServerError) + return + } + http.Redirect(w, r, app.Config.ThreadrDir+"/thread/?id="+threadIDStr, http.StatusFound) + return + } + } - posts, err := models.GetPostsByThreadID(app.DB, threadID) - if err != nil { - log.Printf("Error fetching posts: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } + posts, err := models.GetPostsByThreadID(app.DB, threadID) + if err != nil { + log.Printf("Error fetching posts: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } - data := struct { - PageData - Thread models.Thread - Posts []models.Post - }{ - PageData: PageData{ - Title: "ThreadR - " + thread.Title, - Navbar: "boards", - LoggedIn: loggedIn, - ShowCookieBanner: cookie == nil || cookie.Value != "accepted", - BasePath: app.Config.ThreadrDir, - StaticPath: app.Config.ThreadrDir + "/static", - CurrentURL: r.URL.Path, - }, - Thread: *thread, - Posts: posts, - } - if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { - log.Printf("Error executing template in ThreadHandler: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - } -} \ No newline at end of file + data := struct { + PageData + Thread models.Thread + Board models.Board + Posts []models.Post + }{ + PageData: PageData{ + Title: "ThreadR - " + thread.Title, + Navbar: "boards", + LoggedIn: loggedIn, + ShowCookieBanner: cookie == nil || cookie.Value != "accepted", + BasePath: app.Config.ThreadrDir, + StaticPath: app.Config.ThreadrDir + "/static", + CurrentURL: r.URL.Path, + }, + Thread: *thread, + Board: *board, + Posts: posts, + } + if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { + log.Printf("Error executing template in ThreadHandler: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} diff --git a/static/app.js b/static/app.js index 82d3605..b398720 100644 --- a/static/app.js +++ b/static/app.js @@ -45,125 +45,21 @@ function enableEnterToSubmit(input, form) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); 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'); - } - } - - textarea.addEventListener('input', updateCounter); - updateCounter(); -} - -// Client-side validation helpers -function validateUsername(username) { - 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'; + } + }); + } + + // Optimistic UI for like/dislike buttons + document.querySelectorAll('form[action*="/like/"]').forEach(form => { + form.addEventListener('submit', (e) => { + const button = form.querySelector('button[type="submit"]'); + if (button) { + button.style.opacity = '0.5'; + button.textContent = button.textContent + ' ✓'; + } + }); }); +}); } // Initialize on DOM ready @@ -173,8 +69,70 @@ if (document.readyState === 'loading') { 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 document.addEventListener('DOMContentLoaded', () => { + // Initialize scroll to top and keyboard shortcuts + initScrollToTop(); + initKeyboardShortcuts(); + // Handle all form submissions document.querySelectorAll('form').forEach(form => { form.addEventListener('submit', (e) => { diff --git a/static/style.css b/static/style.css index d440157..51dfd29 100644 --- a/static/style.css +++ b/static/style.css @@ -435,6 +435,128 @@ 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 */ .spinner { display: inline-block; diff --git a/templates/pages/board.html b/templates/pages/board.html index c09a695..3b99f93 100644 --- a/templates/pages/board.html +++ b/templates/pages/board.html @@ -9,6 +9,14 @@ {{template "navbar" .}}
+ + Back to Boards

{{.Board.Name}}

{{.Board.Description}}

diff --git a/templates/pages/thread.html b/templates/pages/thread.html index 3f86121..a4f3a48 100644 --- a/templates/pages/thread.html +++ b/templates/pages/thread.html @@ -9,6 +9,16 @@ {{template "navbar" .}}
+ + Back to {{.Board.Name}}

{{.Thread.Title}}