diff --git a/handlers/like.go b/handlers/like.go
index 303e70a..728fed8 100644
--- a/handlers/like.go
+++ b/handlers/like.go
@@ -1,78 +1,95 @@
package handlers
import (
- "log"
- "net/http"
- "strconv"
- "threadr/models"
- "github.com/gorilla/sessions"
+ "encoding/json"
+ "log"
+ "net/http"
+ "strconv"
+ "threadr/models"
+
+ "github.com/gorilla/sessions"
)
func LikeHandler(app *App) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
- session := r.Context().Value("session").(*sessions.Session)
- userID, ok := session.Values["user_id"].(int)
- if !ok {
- http.Error(w, "Unauthorized", http.StatusUnauthorized)
- return
- }
+ session := r.Context().Value("session").(*sessions.Session)
+ userID, ok := session.Values["user_id"].(int)
+ if !ok {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
- postIDStr := r.FormValue("post_id")
- postID, err := strconv.Atoi(postIDStr)
- if err != nil {
- http.Error(w, "Invalid post ID", http.StatusBadRequest)
- return
- }
+ postIDStr := r.FormValue("post_id")
+ postID, err := strconv.Atoi(postIDStr)
+ if err != nil {
+ http.Error(w, "Invalid post ID", http.StatusBadRequest)
+ return
+ }
- likeType := r.FormValue("type")
- if likeType != "like" && likeType != "dislike" {
- http.Error(w, "Invalid like type", http.StatusBadRequest)
- return
- }
+ likeType := r.FormValue("type")
+ if likeType != "like" && likeType != "dislike" {
+ http.Error(w, "Invalid like type", http.StatusBadRequest)
+ return
+ }
- existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID)
- if err != nil {
- log.Printf("Error checking existing like: %v", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
- return
- }
+ existingLike, err := models.GetLikeByPostAndUser(app.DB, postID, userID)
+ if err != nil {
+ log.Printf("Error checking existing like: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
- if existingLike != nil {
- if existingLike.Type == likeType {
- err = models.DeleteLike(app.DB, postID, userID)
- if err != nil {
- log.Printf("Error deleting like: %v", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
- return
- }
- } else {
- err = models.UpdateLikeType(app.DB, postID, userID, likeType)
- if err != nil {
- log.Printf("Error updating like: %v", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
- return
- }
- }
- } else {
- like := models.Like{
- PostID: postID,
- UserID: userID,
- Type: likeType,
- }
- err = models.CreateLike(app.DB, like)
- if err != nil {
- log.Printf("Error creating like: %v", err)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
- return
- }
- }
+ userAction := likeType // what the user's current vote will be after this
+ if existingLike != nil {
+ if existingLike.Type == likeType {
+ // Toggle off
+ err = models.DeleteLike(app.DB, postID, userID)
+ userAction = "" // no active vote
+ if err != nil {
+ log.Printf("Error deleting like: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ err = models.UpdateLikeType(app.DB, postID, userID, likeType)
+ if err != nil {
+ log.Printf("Error updating like: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ }
+ } else {
+ like := models.Like{
+ PostID: postID,
+ UserID: userID,
+ Type: likeType,
+ }
+ err = models.CreateLike(app.DB, like)
+ if err != nil {
+ log.Printf("Error creating like: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ }
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("OK"))
- }
-}
\ No newline at end of file
+ // Fetch updated counts
+ 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,
+ })
+ }
+}
diff --git a/handlers/thread.go b/handlers/thread.go
index a99e73d..34eb4f3 100644
--- a/handlers/thread.go
+++ b/handlers/thread.go
@@ -60,7 +60,10 @@ func ThreadHandler(app *App) http.HandlerFunc {
action := r.URL.Query().Get("action")
if action == "submit" {
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
if replyToStr != "" {
replyTo, err = strconv.Atoi(replyToStr)
@@ -109,11 +112,49 @@ func ThreadHandler(app *App) http.HandlerFunc {
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 {
PageData
- Thread models.Thread
- Board models.Board
- Posts []models.Post
+ Thread models.Thread
+ Board models.Board
+ Posts []models.Post
+ LikeCounts map[int]models.LikeCounts
+ UserLikes map[int]string
+ Usernames map[int]string
}{
PageData: PageData{
Title: "ThreadR - " + thread.Title,
@@ -124,9 +165,12 @@ func ThreadHandler(app *App) http.HandlerFunc {
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
},
- Thread: *thread,
- Board: *board,
- Posts: posts,
+ Thread: *thread,
+ Board: *board,
+ Posts: posts,
+ LikeCounts: likeCounts,
+ UserLikes: userLikes,
+ Usernames: usernames,
}
if err := app.Tmpl.ExecuteTemplate(w, "thread", data); err != nil {
log.Printf("Error executing template in ThreadHandler: %v", err)
diff --git a/models/like.go b/models/like.go
index cd866a2..b5ea2bc 100644
--- a/models/like.go
+++ b/models/like.go
@@ -3,60 +3,143 @@ package models
import "database/sql"
type Like struct {
- ID int
- PostID int
- UserID int
- Type string // "like" or "dislike"
+ ID int
+ PostID int
+ UserID int
+ Type string // "like" or "dislike"
}
func GetLikesByPostID(db *sql.DB, postID int) ([]Like, error) {
- query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?"
- rows, err := db.Query(query, postID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
+ query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ?"
+ rows, err := db.Query(query, postID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
- var likes []Like
- for rows.Next() {
- like := Like{}
- err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
- if err != nil {
- return nil, err
- }
- likes = append(likes, like)
- }
- return likes, nil
+ var likes []Like
+ for rows.Next() {
+ like := Like{}
+ err := rows.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
+ if err != nil {
+ return nil, err
+ }
+ likes = append(likes, like)
+ }
+ return likes, nil
}
func GetLikeByPostAndUser(db *sql.DB, postID, userID int) (*Like, error) {
- query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?"
- row := db.QueryRow(query, postID, userID)
- like := &Like{}
- err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
- if err == sql.ErrNoRows {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return like, nil
+ query := "SELECT id, post_id, user_id, type FROM likes WHERE post_id = ? AND user_id = ?"
+ row := db.QueryRow(query, postID, userID)
+ like := &Like{}
+ err := row.Scan(&like.ID, &like.PostID, &like.UserID, &like.Type)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return like, nil
}
func CreateLike(db *sql.DB, like Like) error {
- query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)"
- _, err := db.Exec(query, like.PostID, like.UserID, like.Type)
- return err
+ query := "INSERT INTO likes (post_id, user_id, type) VALUES (?, ?, ?)"
+ _, err := db.Exec(query, like.PostID, like.UserID, like.Type)
+ return err
}
func UpdateLikeType(db *sql.DB, postID, userID int, likeType string) error {
- query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?"
- _, err := db.Exec(query, likeType, postID, userID)
- return err
+ query := "UPDATE likes SET type = ? WHERE post_id = ? AND user_id = ?"
+ _, err := db.Exec(query, likeType, postID, userID)
+ return err
}
func DeleteLike(db *sql.DB, postID, userID int) error {
- query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?"
- _, err := db.Exec(query, postID, userID)
- return err
-}
\ No newline at end of file
+ query := "DELETE FROM likes WHERE post_id = ? AND user_id = ?"
+ _, err := db.Exec(query, postID, userID)
+ 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
+}
diff --git a/static/likes.js b/static/likes.js
index eff8075..9331c2c 100644
--- a/static/likes.js
+++ b/static/likes.js
@@ -1,11 +1,51 @@
function initLikeButtons() {
- document.querySelectorAll('form[action*="/like/"]').forEach(form => {
- form.addEventListener('submit', () => {
- const button = form.querySelector('button[type="submit"]');
- if (button) {
- button.style.opacity = '0.5';
- button.textContent = button.textContent + ' ✓';
- }
+ document.querySelectorAll('.like-btn, .dislike-btn').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var postId = btn.getAttribute('data-post-id');
+ var type = btn.getAttribute('data-type');
+ 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;
+ });
});
});
}
diff --git a/static/style.css b/static/style.css
index c3ed9e7..c9f8c17 100644
--- a/static/style.css
+++ b/static/style.css
@@ -333,21 +333,120 @@ p.thread-info {
display: flex;
gap: 0.5em;
align-items: center;
+ flex-wrap: wrap;
}
-.post-actions a {
- color: #001858;
- text-decoration: none;
- font-size: 1em;
+.post-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3em;
padding: 0.4em 0.8em;
border: 1px solid #001858;
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;
+ 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;
+ 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 */
@@ -781,14 +880,38 @@ p.thread-info {
.post-content {
color: #fef6e4;
}
- .post-actions a {
+ .post-action-btn {
+ background-color: #555;
color: #fef6e4;
border-color: #fef6e4;
}
- .post-actions a:hover {
+ .post-action-btn:hover {
background-color: #8bd3dd;
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 {
color: #fef6e4;
}
diff --git a/templates/pages/thread.html b/templates/pages/thread.html
index a4f3a48..99e5b3e 100644
--- a/templates/pages/thread.html
+++ b/templates/pages/thread.html
@@ -4,6 +4,7 @@
{{.Title}}
+
@@ -11,11 +12,11 @@
Back to {{.Board.Name}}
@@ -24,37 +25,44 @@
{{range .Posts}}
-
+
- {{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}
- Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}
+
+ {{.PostTime.Format "02/01/2006 - 15:04"}}
{{if gt .ReplyTo 0}}
- Reply to post {{.ReplyTo}}
+ Replying to #{{.ReplyTo}}
{{end}}
{{.Content}}
- {{if $.LoggedIn}}
-
-
-
Reply
+ {{if $.LoggedIn}}
+
+
+
+ {{else}}
+
{{(index $.LikeCounts .ID).Likes}} Likes
+
{{(index $.LikeCounts .ID).Dislikes}} Dislikes
+ {{end}}
- {{end}}
{{end}}
{{if .LoggedIn}}
-
{{template "cookie_banner" .}}
+