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 contextjocadbz
parent
00185e6774
commit
f7b8055062
|
|
@ -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 {
|
||||||
|
|
|
||||||
184
static/app.js
184
static/app.js
|
|
@ -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) => {
|
||||||
|
|
|
||||||
122
static/style.css
122
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 */
|
/* Loading spinner */
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue