modify the classic board page heavily
parent
95c2fc7c0d
commit
91c7591c19
|
|
@ -1,10 +1,12 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"threadr/models"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
|
|
@ -42,9 +44,12 @@ func LikeHandler(app *App) http.HandlerFunc {
|
|||
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)
|
||||
|
|
@ -72,7 +77,19 @@ func LikeHandler(app *App) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
LikeCounts map[int]models.LikeCounts
|
||||
UserLikes map[int]string
|
||||
Usernames map[int]string
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - " + thread.Title,
|
||||
|
|
@ -127,6 +168,9 @@ func ThreadHandler(app *App) http.HandlerFunc {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -60,3 +60,86 @@ func DeleteLike(db *sql.DB, postID, userID int) error {
|
|||
_, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
139
static/style.css
139
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
{{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>
|
||||
</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}}
|
||||
Loading…
Reference in New Issue