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("/", index) authed.Get("/new", showNew) authed.Post("/new", new) 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 index(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 new(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)) }