311 lines
8.1 KiB
Go

package routes
import (
"dk/internal/components"
"dk/internal/database"
"dk/internal/helpers"
"dk/internal/helpers/markdown"
"dk/internal/models/forum"
"dk/internal/models/users"
"fmt"
"log"
"strings"
"time"
sushi "git.sharkk.net/Sharkk/Sushi"
"git.sharkk.net/Sharkk/Sushi/auth"
)
type threadData struct {
ID int
Posted int64
LastPost int64
Author int
Parent int
Replies int
Title string
Content string
AuthorUsername string
AuthorLevel int
AuthorClass string
LastReplyUsername string
LastReplyLevel int
LastReplyClassID int
HasReplies bool
}
type replyData struct {
ID int
Posted int64
Author int
Title string
Content string
AuthorUsername string
AuthorLevel int
AuthorClass string
}
func (t *threadData) PostedTime() time.Time {
return time.Unix(t.Posted, 0)
}
func (t *threadData) LastPostTime() time.Time {
return time.Unix(t.LastPost, 0)
}
func (r *replyData) PostedTime() time.Time {
return time.Unix(r.Posted, 0)
}
func RegisterForumRoutes(app *sushi.App) {
authed := app.Group("/forum")
authed.Use(auth.RequireAuth())
authed.Get("/", forumIndex)
authed.Get("/new", showNew)
authed.Post("/new", newThread)
authed.Get("/:id", showThread)
authed.Post("/:id/reply", reply)
}
// Helper function to validate thread exists and is actually a thread (not a reply)
func validateThread(ctx sushi.Ctx, id int) (*forum.Forum, bool) {
sess := ctx.GetCurrentSession()
thread, err := forum.Find(id)
if err != nil {
sess.SetFlash("error", fmt.Sprintf("Forum thread %d not found", id))
ctx.Redirect("/forum")
return nil, false
}
if thread.Parent != 0 {
sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id))
ctx.Redirect("/forum")
return nil, false
}
return thread, true
}
// Helper function to get pagination params from request
func getPagination(ctx sushi.Ctx, perPage int) helpers.Pagination {
page := max(int(ctx.QueryArgs().GetUintOrZero("page")), 1)
return helpers.Pagination{
Page: page,
PerPage: perPage,
}
}
func forumIndex(ctx sushi.Ctx) {
pagination := getPagination(ctx, 30)
// Get threads with user data
var threads []*threadData
query := `
SELECT f.id, f.posted, f.last_post, f.author, f.parent, f.replies, f.title, f.content,
COALESCE(u.username, '[Deleted]') as author_username,
COALESCE(u.level, 1) as author_level,
COALESCE(c.name, 'Unknown') as author_class
FROM forum f
LEFT JOIN users u ON f.author = u.id
LEFT JOIN classes c ON u.class_id = c.id
WHERE f.parent = 0
ORDER BY f.last_post DESC, f.id DESC
LIMIT %d OFFSET %d`
err := database.Select(&threads, query, pagination.PerPage, pagination.Offset())
if err != nil {
log.Printf("Error fetching forum threads: %v", err)
threads = make([]*threadData, 0)
}
// Get last reply info for each thread
for i := range threads {
type ReplyCount struct{ Count int }
var replyCount ReplyCount
database.Get(&replyCount, "SELECT COUNT(*) as count FROM forum WHERE parent = %d", threads[i].ID)
if replyCount.Count > 0 {
type LastReply struct{ Username string }
var lastReply LastReply
err := database.Get(&lastReply, `
SELECT COALESCE(u.username, '[Deleted]') as username
FROM forum f
LEFT JOIN users u ON f.author = u.id
WHERE f.parent = %d
ORDER BY f.posted DESC
LIMIT 1`, threads[i].ID)
if err == nil {
threads[i].LastReplyUsername = lastReply.Username
threads[i].HasReplies = true
} else {
log.Printf("Error getting last reply author for thread %d: %v", threads[i].ID, err)
}
}
}
// Get total count for pagination
type CountResult struct{ Count int }
var paginationResult CountResult
database.Get(&paginationResult, "SELECT COUNT(*) as count FROM forum WHERE parent = 0")
pagination.Total = paginationResult.Count
components.RenderPage(ctx, "Forum", "forum/index.html", map[string]any{
"threads": threads,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
})
}
func showNew(ctx sushi.Ctx) {
components.RenderPage(ctx, "New Forum Thread", "forum/new.html", map[string]any{})
}
func newThread(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
title := strings.TrimSpace(ctx.Form("title").String())
content := strings.TrimSpace(ctx.Form("content").String())
if title == "" {
sess.SetFlash("error", "Thread title cannot be empty")
ctx.Redirect("/forum/new")
return
}
if content == "" {
sess.SetFlash("error", "Thread content cannot be empty")
ctx.Redirect("/forum/new")
return
}
user := ctx.GetCurrentUser().(*users.User)
thread := forum.New()
thread.Author = user.ID
thread.Title = title
thread.Content = content
if err := thread.Insert(); err != nil {
log.Printf("Error creating thread: %v", err)
sess.SetFlash("error", "Failed to create thread")
ctx.Redirect("/forum/new")
return
}
ctx.Redirect(fmt.Sprintf("/forum/%d", thread.ID))
}
func showThread(ctx sushi.Ctx) {
id := ctx.Param("id").Int()
thread, ok := validateThread(ctx, id)
if !ok {
return
}
pagination := getPagination(ctx, 10)
pagination.Total = thread.Replies
// Get thread with author data
var threadData threadData
err := database.Get(&threadData, `
SELECT f.id, f.posted, f.last_post, f.author, f.parent, f.replies, f.title, f.content,
COALESCE(u.username, '[Deleted]') as author_username,
COALESCE(u.level, 1) as author_level,
COALESCE(c.name, 'Unknown') as author_class
FROM forum f
LEFT JOIN users u ON f.author = u.id
LEFT JOIN classes c ON u.class_id = c.id
WHERE f.id = %d`, id)
if err != nil {
log.Printf("Error loading thread %d: %v", id, err)
sess := ctx.GetCurrentSession()
sess.SetFlash("error", "Failed to load thread")
ctx.Redirect("/forum")
return
}
// Get replies with user data
var replies []*replyData
err = database.Select(&replies, `
SELECT f.id, f.posted, f.author, f.title, f.content,
COALESCE(u.username, '[Deleted]') as author_username,
COALESCE(u.level, 1) as author_level,
COALESCE(c.name, 'Unknown') as author_class
FROM forum f
LEFT JOIN users u ON f.author = u.id
LEFT JOIN classes c ON u.class_id = c.id
WHERE f.parent = %d
ORDER BY f.posted ASC
LIMIT %d OFFSET %d`, id, pagination.PerPage, pagination.Offset())
if err != nil {
log.Printf("Error loading replies for thread %d: %v", id, err)
replies = make([]*replyData, 0)
}
components.RenderPage(ctx, threadData.Title, "forum/thread.html", map[string]any{
"thread": &threadData,
"replies": replies,
"currentPage": pagination.Page,
"totalPages": pagination.TotalPages(),
"hasNext": pagination.HasNext(),
"hasPrev": pagination.HasPrev(),
"markdown": markdown.MarkdownToHTML,
})
}
func reply(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int()
thread, ok := validateThread(ctx, id)
if !ok {
return
}
content := strings.TrimSpace(ctx.Form("content").String())
if content == "" {
sess.SetFlash("error", "Reply content cannot be empty")
ctx.Redirect(fmt.Sprintf("/forum/%d#reply", id))
return
}
user := ctx.GetCurrentUser().(*users.User)
reply := forum.New()
reply.Author = user.ID
reply.Parent = thread.ID
reply.Title = "Re: " + thread.Title
reply.Content = content
err := database.Transaction(func() error {
if err := reply.Insert(); err != nil {
return err
}
// Update thread reply count and last post time
thread.IncrementReplies()
thread.UpdateLastPost()
return database.Exec("UPDATE forum SET replies = %d, last_post = %d WHERE id = %d",
thread.Replies, thread.LastPost, thread.ID)
})
if err != nil {
log.Printf("Error posting reply to thread %d: %v", id, err)
sess.SetFlash("error", "Failed to post reply")
ctx.Redirect(fmt.Sprintf("/forum/%d#reply", id))
return
}
// Calculate the last page where the new reply will appear
const repliesPerPage = 10
lastPage := max((thread.Replies+repliesPerPage-1)/repliesPerPage, 1)
ctx.Redirect(fmt.Sprintf("/forum/%d?page=%d", thread.ID, lastPage))
}