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,135 +1,137 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"github.com/gorilla/sessions"
|
||||||
"net/http"
|
"log"
|
||||||
"strconv"
|
"net/http"
|
||||||
"threadr/models"
|
"strconv"
|
||||||
"github.com/gorilla/sessions"
|
"threadr/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
Posts []models.Post
|
Board models.Board
|
||||||
}{
|
Posts []models.Post
|
||||||
PageData: PageData{
|
}{
|
||||||
Title: "ThreadR - " + thread.Title,
|
PageData: PageData{
|
||||||
Navbar: "boards",
|
Title: "ThreadR - " + thread.Title,
|
||||||
LoggedIn: loggedIn,
|
Navbar: "boards",
|
||||||
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
LoggedIn: loggedIn,
|
||||||
BasePath: app.Config.ThreadrDir,
|
ShowCookieBanner: cookie == nil || cookie.Value != "accepted",
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
BasePath: app.Config.ThreadrDir,
|
||||||
CurrentURL: r.URL.Path,
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
},
|
CurrentURL: r.URL.Path,
|
||||||
Thread: *thread,
|
},
|
||||||
Posts: posts,
|
Thread: *thread,
|
||||||
}
|
Board: *board,
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
Posts: posts,
|
||||||
log.Printf("Error executing template in ThreadHandler: %v", err)
|
}
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
|
||||||
return
|
log.Printf("Error executing template in ThreadHandler: %v", err)
|
||||||
}
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
194
static/app.js
194
static/app.js
|
|
@ -45,125 +45,21 @@ 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
|
// Optimistic UI for like/dislike buttons
|
||||||
function autoResizeTextarea(textarea) {
|
document.querySelectorAll('form[action*="/like/"]').forEach(form => {
|
||||||
textarea.style.height = 'auto';
|
form.addEventListener('submit', (e) => {
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
const button = form.querySelector('button[type="submit"]');
|
||||||
}
|
if (button) {
|
||||||
|
button.style.opacity = '0.5';
|
||||||
// Add character counter to textarea
|
button.textContent = button.textContent + ' ✓';
|
||||||
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';
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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