UI: Add navigation improvements with breadcrumbs, back buttons, scroll-to-top, and keyboard shortcuts

- 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
jocadbz
Joca 2026-01-15 22:49:54 -03:00
parent 00185e6774
commit f7b8055062
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
5 changed files with 341 additions and 241 deletions

View File

@ -1,11 +1,11 @@
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 {
@ -112,6 +112,7 @@ func ThreadHandler(app *App) http.HandlerFunc {
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{
@ -124,6 +125,7 @@ func ThreadHandler(app *App) http.HandlerFunc {
CurrentURL: r.URL.Path, CurrentURL: r.URL.Path,
}, },
Thread: *thread, Thread: *thread,
Board: *board,
Posts: posts, Posts: posts,
} }
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {

View File

@ -47,123 +47,19 @@ function enableEnterToSubmit(input, form) {
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');
}
} }
textarea.addEventListener('input', updateCounter); // Optimistic UI for like/dislike buttons
updateCounter(); document.querySelectorAll('form[action*="/like/"]').forEach(form => {
} form.addEventListener('submit', (e) => {
const button = form.querySelector('button[type="submit"]');
// Client-side validation helpers if (button) {
function validateUsername(username) { button.style.opacity = '0.5';
if (username.length < 3) { button.textContent = button.textContent + ' ✓';
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
@ -173,8 +69,70 @@ 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) => {

View File

@ -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 */ /* Loading spinner */
.spinner { .spinner {
display: inline-block; display: inline-block;

View File

@ -9,6 +9,14 @@
<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>

View File

@ -9,6 +9,16 @@
<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>