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,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"))
}
// 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,
})
}
}

View File

@ -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)

View File

@ -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
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
}

View File

@ -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;
});
});
});
}

View File

@ -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;
}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
@ -11,11 +12,11 @@
<main>
<div class="breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="breadcrumb-separator"></span>
<span class="breadcrumb-separator">></span>
<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>
<span class="breadcrumb-separator"></span>
<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>
@ -24,37 +25,44 @@
</header>
<div class="thread-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>
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3>
<p data-timestamp="{{.PostTime.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
<div class="post-header-top">
<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}}
<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}}
</header>
<div class="post-content">{{.Content}}</div>
{{if $.LoggedIn}}
<div class="post-actions">
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;">
<input type="hidden" name="post_id" value="{{.ID}}">
<input type="hidden" name="type" value="like">
<button type="submit">Like</button>
</form>
<form method="post" action="{{$.BasePath}}/like/" style="display:inline;">
<input type="hidden" name="post_id" value="{{.ID}}">
<input type="hidden" name="type" value="dislike">
<button type="submit">Dislike</button>
</form>
<a href="{{$.BasePath}}/thread/?id={{$.Thread.ID}}&action=submit&to={{.ID}}">Reply</a>
{{if $.LoggedIn}}
<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}}">
<span class="like-count">{{(index $.LikeCounts .ID).Likes}}</span> Like
</button>
<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}}">
<span class="dislike-count">{{(index $.LikeCounts .ID).Dislikes}}</span> Dislike
</button>
<button type="button" class="post-action-btn reply-btn" onclick="setReplyTo({{.ID}}, '{{index $.Usernames .UserID}}')">Reply</button>
{{else}}
<span class="like-count-display">{{(index $.LikeCounts .ID).Likes}} Likes</span>
<span class="dislike-count-display">{{(index $.LikeCounts .ID).Dislikes}} Dislikes</span>
{{end}}
</div>
{{end}}
</article>
{{end}}
</div>
{{if .LoggedIn}}
<section>
<h3>Post a Message</h3>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit">
<section id="reply-section">
<h3 id="reply-heading">Post a Reply</h3>
<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>
<textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post">
@ -63,6 +71,37 @@
{{end}}
</main>
{{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>
</html>
{{end}}