modify the classic board page heavily

jocadbz
Joca 2026-02-26 21:33:34 -03:00
parent 95c2fc7c0d
commit 91c7591c19
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
6 changed files with 499 additions and 153 deletions

View File

@ -1,10 +1,12 @@
package handlers package handlers
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"threadr/models" "threadr/models"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
@ -42,9 +44,12 @@ func LikeHandler(app *App) http.HandlerFunc {
return return
} }
userAction := likeType // what the user's current vote will be after this
if existingLike != nil { if existingLike != nil {
if existingLike.Type == likeType { if existingLike.Type == likeType {
// Toggle off
err = models.DeleteLike(app.DB, postID, userID) err = models.DeleteLike(app.DB, postID, userID)
userAction = "" // no active vote
if err != nil { if err != nil {
log.Printf("Error deleting like: %v", err) log.Printf("Error deleting like: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -72,7 +77,19 @@ func LikeHandler(app *App) http.HandlerFunc {
} }
} }
w.WriteHeader(http.StatusOK) // Fetch updated counts
w.Write([]byte("OK")) counts, err := models.GetLikeCountsByPostID(app.DB, postID)
if err != nil {
log.Printf("Error fetching like counts: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"likes": counts.Likes,
"dislikes": counts.Dislikes,
"userAction": userAction,
})
} }
} }

View File

@ -60,7 +60,10 @@ func ThreadHandler(app *App) http.HandlerFunc {
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.FormValue("reply_to")
if replyToStr == "" {
replyToStr = r.URL.Query().Get("to")
}
replyTo := -1 replyTo := -1
if replyToStr != "" { if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr) replyTo, err = strconv.Atoi(replyToStr)
@ -109,11 +112,49 @@ func ThreadHandler(app *App) http.HandlerFunc {
return return
} }
// Collect post IDs for bulk queries
postIDs := make([]int, len(posts))
userIDs := make(map[int]bool)
for i, p := range posts {
postIDs[i] = p.ID
userIDs[p.UserID] = true
}
// Fetch like counts for all posts
likeCounts, err := models.GetLikeCountsForPosts(app.DB, postIDs)
if err != nil {
log.Printf("Error fetching like counts: %v", err)
likeCounts = make(map[int]models.LikeCounts)
}
// Fetch current user's likes
userLikes, err := models.GetUserLikesForPosts(app.DB, userID, postIDs)
if err != nil {
log.Printf("Error fetching user likes: %v", err)
userLikes = make(map[int]string)
}
// Fetch usernames for post authors
usernames := make(map[int]string)
for uid := range userIDs {
user, err := models.GetUserByID(app.DB, uid)
if err != nil || user == nil {
usernames[uid] = "Unknown"
} else if user.DisplayName != "" {
usernames[uid] = user.DisplayName
} else {
usernames[uid] = user.Username
}
}
data := struct { data := struct {
PageData PageData
Thread models.Thread Thread models.Thread
Board models.Board Board models.Board
Posts []models.Post Posts []models.Post
LikeCounts map[int]models.LikeCounts
UserLikes map[int]string
Usernames map[int]string
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - " + thread.Title, Title: "ThreadR - " + thread.Title,
@ -127,6 +168,9 @@ func ThreadHandler(app *App) http.HandlerFunc {
Thread: *thread, Thread: *thread,
Board: *board, Board: *board,
Posts: posts, Posts: posts,
LikeCounts: likeCounts,
UserLikes: userLikes,
Usernames: usernames,
} }
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil { if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err) log.Printf("Error executing template in ThreadHandler: %v", err)

View File

@ -60,3 +60,86 @@ func DeleteLike(db *sql.DB, postID, userID int) error {
_, err := db.Exec(query, postID, userID) _, err := db.Exec(query, postID, userID)
return err return err
} }
type LikeCounts struct {
Likes int
Dislikes int
}
func GetLikeCountsByPostID(db *sql.DB, postID int) (LikeCounts, error) {
counts := LikeCounts{}
query := "SELECT COALESCE(SUM(CASE WHEN type='like' THEN 1 ELSE 0 END),0), COALESCE(SUM(CASE WHEN type='dislike' THEN 1 ELSE 0 END),0) FROM likes WHERE post_id = ?"
err := db.QueryRow(query, postID).Scan(&counts.Likes, &counts.Dislikes)
return counts, err
}
func GetLikeCountsForPosts(db *sql.DB, postIDs []int) (map[int]LikeCounts, error) {
result := make(map[int]LikeCounts)
if len(postIDs) == 0 {
return result, nil
}
// Build placeholders
placeholders := ""
args := make([]interface{}, len(postIDs))
for i, id := range postIDs {
if i > 0 {
placeholders += ","
}
placeholders += "?"
args[i] = id
}
query := "SELECT post_id, type, COUNT(*) FROM likes WHERE post_id IN (" + placeholders + ") GROUP BY post_id, type"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var postID int
var likeType string
var count int
if err := rows.Scan(&postID, &likeType, &count); err != nil {
return nil, err
}
c := result[postID]
if likeType == "like" {
c.Likes = count
} else {
c.Dislikes = count
}
result[postID] = c
}
return result, nil
}
func GetUserLikesForPosts(db *sql.DB, userID int, postIDs []int) (map[int]string, error) {
result := make(map[int]string)
if len(postIDs) == 0 || userID == 0 {
return result, nil
}
placeholders := ""
args := make([]interface{}, 0, len(postIDs)+1)
args = append(args, userID)
for i, id := range postIDs {
if i > 0 {
placeholders += ","
}
placeholders += "?"
args = append(args, id)
}
query := "SELECT post_id, type FROM likes WHERE user_id = ? AND post_id IN (" + placeholders + ")"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var postID int
var likeType string
if err := rows.Scan(&postID, &likeType); err != nil {
return nil, err
}
result[postID] = likeType
}
return result, nil
}

View File

@ -1,11 +1,51 @@
function initLikeButtons() { function initLikeButtons() {
document.querySelectorAll('form[action*="/like/"]').forEach(form => { document.querySelectorAll('.like-btn, .dislike-btn').forEach(function(btn) {
form.addEventListener('submit', () => { btn.addEventListener('click', function(e) {
const button = form.querySelector('button[type="submit"]'); e.preventDefault();
if (button) { var postId = btn.getAttribute('data-post-id');
button.style.opacity = '0.5'; var type = btn.getAttribute('data-type');
button.textContent = button.textContent + ' ✓'; var basePath = btn.getAttribute('data-base-path');
btn.disabled = true;
var body = new URLSearchParams();
body.append('post_id', postId);
body.append('type', type);
fetch(basePath + '/like/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function(res) { return res.json(); })
.then(function(data) {
// Find sibling buttons for this post
var article = btn.closest('.post-actions');
var likeBtn = article.querySelector('.like-btn');
var dislikeBtn = article.querySelector('.dislike-btn');
// Update counts
if (likeBtn) {
likeBtn.querySelector('.like-count').textContent = data.likes;
} }
if (dislikeBtn) {
dislikeBtn.querySelector('.dislike-count').textContent = data.dislikes;
}
// Update active states
if (likeBtn) {
likeBtn.classList.toggle('active', data.userAction === 'like');
}
if (dislikeBtn) {
dislikeBtn.classList.toggle('active', data.userAction === 'dislike');
}
})
.catch(function(err) {
console.error('Like error:', err);
})
.finally(function() {
btn.disabled = false;
});
}); });
}); });
} }

View File

@ -333,21 +333,120 @@ p.thread-info {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.post-actions a { .post-action-btn {
color: #001858; display: inline-flex;
text-decoration: none; align-items: center;
font-size: 1em; gap: 0.3em;
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
border: 1px solid #001858; border: 1px solid #001858;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s ease; background-color: #fef6e4;
color: #001858;
cursor: pointer;
font-family: monospace;
font-size: 0.9em;
margin: 0;
width: auto;
transition: background-color 0.2s ease, transform 0.1s ease;
} }
.post-actions a:hover { .post-action-btn:hover {
background-color: #8bd3dd; background-color: #8bd3dd;
transform: translateY(-1px);
}
.post-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.post-action-btn.like-btn.active {
background-color: #8bd3dd;
border-color: #001858;
font-weight: bold;
}
.post-action-btn.dislike-btn.active {
background-color: #f582ae;
border-color: #001858;
color: #fef6e4; color: #fef6e4;
font-weight: bold;
}
.like-count-display, .dislike-count-display {
font-size: 0.9em;
color: #001858;
opacity: 0.8;
}
.post-header-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.post-id {
opacity: 0.6;
font-size: 0.85em;
}
.post-reply-link a {
color: #8bd3dd;
text-decoration: underline;
}
.post-reply-link a:hover {
color: #f582ae;
}
.post-reply {
margin-left: 20px;
border-left: 3px solid #8bd3dd;
}
.post-highlighted {
animation: post-highlight-fade 2s ease-out;
}
@keyframes post-highlight-fade {
0% { box-shadow: 0 0 0 3px #f582ae; }
100% { box-shadow: none; }
}
#reply-section .reply-indicator {
background-color: #001858;
color: #fef6e4;
padding: 8px 12px;
border-radius: 5px;
margin-bottom: 10px;
display: none;
align-items: center;
justify-content: space-between;
}
#reply-section .reply-indicator span {
font-size: 0.9em;
color: #fef6e4;
}
#reply-section .reply-indicator button {
background: none;
border: none;
color: #fef6e4;
cursor: pointer;
font-size: 1em;
padding: 0 6px;
margin: 0;
width: auto;
}
#reply-section .reply-indicator button:hover {
background: none;
color: #f582ae;
} }
/* New style for highlighted chat messages */ /* New style for highlighted chat messages */
@ -781,14 +880,38 @@ p.thread-info {
.post-content { .post-content {
color: #fef6e4; color: #fef6e4;
} }
.post-actions a { .post-action-btn {
background-color: #555;
color: #fef6e4; color: #fef6e4;
border-color: #fef6e4; border-color: #fef6e4;
} }
.post-actions a:hover { .post-action-btn:hover {
background-color: #8bd3dd; background-color: #8bd3dd;
color: #001858; color: #001858;
} }
.post-action-btn.like-btn.active {
background-color: #8bd3dd;
color: #001858;
}
.post-action-btn.dislike-btn.active {
background-color: #f582ae;
color: #001858;
}
.like-count-display, .dislike-count-display {
color: #fef6e4;
}
.post-reply {
border-left-color: #8bd3dd;
}
.post-reply-link a {
color: #8bd3dd;
}
.post-id {
color: #fef6e4;
}
#reply-section .reply-indicator {
background-color: #222;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #fef6e4; color: #fef6e4;
} }

View File

@ -4,6 +4,7 @@
<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}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script> <script src="{{.StaticPath}}/app.js" defer></script>
</head> </head>
<body> <body>
@ -11,11 +12,11 @@
<main> <main>
<div class="breadcrumb"> <div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a> <a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span> <span class="breadcrumb-separator">></span>
<a href="{{.BasePath}}/boards/">Boards</a> <a href="{{.BasePath}}/boards/">Boards</a>
<span class="breadcrumb-separator"></span> <span class="breadcrumb-separator">></span>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}">{{.Board.Name}}</a> <a href="{{.BasePath}}/board/?id={{.Board.ID}}">{{.Board.Name}}</a>
<span class="breadcrumb-separator"></span> <span class="breadcrumb-separator">></span>
<span class="breadcrumb-current">{{.Thread.Title}}</span> <span class="breadcrumb-current">{{.Thread.Title}}</span>
</div> </div>
<a href="{{.BasePath}}/board/?id={{.Board.ID}}" class="back-button">Back to {{.Board.Name}}</a> <a href="{{.BasePath}}/board/?id={{.Board.ID}}" class="back-button">Back to {{.Board.Name}}</a>
@ -24,37 +25,44 @@
</header> </header>
<div class="thread-posts"> <div class="thread-posts">
{{range .Posts}} {{range .Posts}}
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};"> <article id="post-{{.ID}}" class="post-item{{if gt .ReplyTo 0}} post-reply{{end}}">
<header> <header>
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3> <div class="post-header-top">
<p data-timestamp="{{.PostTime.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p> <h3>{{if .Title}}{{.Title}}{{else}}{{index $.Usernames .UserID}}{{end}}</h3>
<span class="post-id">#{{.ID}}</span>
</div>
<p data-timestamp="{{.PostTime.Format "2006-01-02T15:04:05Z07:00"}}">{{.PostTime.Format "02/01/2006 - 15:04"}}</p>
{{if gt .ReplyTo 0}} {{if gt .ReplyTo 0}}
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p> <p class="post-reply-link">Replying to <a href="#post-{{.ReplyTo}}">#{{.ReplyTo}}</a></p>
{{end}} {{end}}
</header> </header>
<div class="post-content">{{.Content}}</div> <div class="post-content">{{.Content}}</div>
{{if $.LoggedIn}}
<div class="post-actions"> <div class="post-actions">
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;"> {{if $.LoggedIn}}
<input type="hidden" name="post_id" value="{{.ID}}"> <button type="button" class="post-action-btn like-btn{{if eq (index $.UserLikes .ID) "like"}} active{{end}}" data-post-id="{{.ID}}" data-type="like" data-base-path="{{$.BasePath}}">
<input type="hidden" name="type" value="like"> <span class="like-count">{{(index $.LikeCounts .ID).Likes}}</span> Like
<button type="submit">Like</button> </button>
</form> <button type="button" class="post-action-btn dislike-btn{{if eq (index $.UserLikes .ID) "dislike"}} active{{end}}" data-post-id="{{.ID}}" data-type="dislike" data-base-path="{{$.BasePath}}">
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;"> <span class="dislike-count">{{(index $.LikeCounts .ID).Dislikes}}</span> Dislike
<input type="hidden" name="post_id" value="{{.ID}}"> </button>
<input type="hidden" name="type" value="dislike"> <button type="button" class="post-action-btn reply-btn" onclick="setReplyTo({{.ID}}, '{{index $.Usernames .UserID}}')">Reply</button>
<button type="submit">Dislike</button> {{else}}
</form> <span class="like-count-display">{{(index $.LikeCounts .ID).Likes}} Likes</span>
<a href="{{$.BasePath}}/thread/?id={{$.Thread.ID}}&action=submit&to={{.ID}}">Reply</a> <span class="dislike-count-display">{{(index $.LikeCounts .ID).Dislikes}} Dislikes</span>
</div>
{{end}} {{end}}
</div>
</article> </article>
{{end}} {{end}}
</div> </div>
{{if .LoggedIn}} {{if .LoggedIn}}
<section> <section id="reply-section">
<h3>Post a Message</h3> <h3 id="reply-heading">Post a Reply</h3>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit"> <div id="reply-indicator" class="reply-indicator" style="display:none;">
<span id="reply-indicator-text">Replying to...</span>
<button type="button" onclick="clearReply()">x</button>
</div>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit" id="reply-form">
<input type="hidden" id="reply-to-input" name="reply_to" value="">
<label for="content">Content:</label> <label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br> <textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post"> <input type="submit" value="Post">
@ -63,6 +71,37 @@
{{end}} {{end}}
</main> </main>
{{template "cookie_banner" .}} {{template "cookie_banner" .}}
<script>
function setReplyTo(postId, username) {
document.getElementById('reply-to-input').value = postId;
var indicator = document.getElementById('reply-indicator');
indicator.style.display = 'flex';
document.getElementById('reply-indicator-text').textContent = 'Replying to ' + username + ' (post #' + postId + ')';
document.getElementById('reply-heading').textContent = 'Reply to ' + username;
var section = document.getElementById('reply-section');
section.scrollIntoView({ behavior: 'smooth', block: 'center' });
document.getElementById('content').focus();
}
function clearReply() {
document.getElementById('reply-to-input').value = '';
document.getElementById('reply-indicator').style.display = 'none';
document.getElementById('reply-heading').textContent = 'Post a Reply';
}
// Smooth scroll to replied post when clicking reply links
document.querySelectorAll('.post-reply-link a').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('post-highlighted');
setTimeout(function() { target.classList.remove('post-highlighted'); }, 2000);
}
});
});
</script>
</body> </body>
</html> </html>
{{end}} {{end}}