package routes import ( "dk/internal/components" "dk/internal/database" "dk/internal/helpers/markdown" "dk/internal/models/forum" "dk/internal/models/users" "fmt" "strings" sushi "git.sharkk.net/Sharkk/Sushi" "git.sharkk.net/Sharkk/Sushi/auth" ) // ThreadInfo combines forum thread with author and last reply info type ThreadInfo struct { Thread *forum.Forum Author *users.User LastReplyBy *users.User } // PostInfo combines a forum post/reply with its author type PostInfo struct { Post *forum.Forum Author *users.User } 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.Get("/:id/reply", showReply) authed.Post("/:id/reply", reply) } func index(ctx sushi.Ctx) { page := int(ctx.QueryArgs().GetUintOrZero("page")) if page < 1 { page = 1 } perPage := 30 offset := (page - 1) * perPage // Get threads with pagination var threads []*forum.Forum err := database.Select(&threads, "SELECT * FROM forum WHERE parent = 0 ORDER BY last_post DESC, id DESC LIMIT %d OFFSET %d", perPage, offset) if err != nil { threads = make([]*forum.Forum, 0) } // Get total count for pagination var totalCount int database.Get(&totalCount, "SELECT COUNT(*) FROM forum WHERE parent = 0") // Build thread info with authors and last reply info var threadInfos []ThreadInfo for _, thread := range threads { author, _ := users.Find(thread.Author) if author == nil { author = &users.User{Username: "[Deleted]", ClassID: 1} } // Get last reply author if there are replies var lastReplyBy *users.User if thread.Replies > 0 { var lastReply forum.Forum err := database.Get(&lastReply, "SELECT * FROM forum WHERE parent = %d ORDER BY posted DESC LIMIT 1", thread.ID) if err == nil { lastReplyBy, _ = users.Find(lastReply.Author) } } threadInfos = append(threadInfos, ThreadInfo{ Thread: thread, Author: author, LastReplyBy: lastReplyBy, }) } totalPages := (totalCount + perPage - 1) / perPage components.RenderPage(ctx, "Forum", "forum/index.html", map[string]any{ "threadInfos": threadInfos, "currentPage": page, "totalPages": totalPages, "hasNext": page < totalPages, "hasPrev": page > 1, }) } 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 database.Transaction(func() error { return thread.Insert() }) ctx.Redirect(fmt.Sprintf("/forum/%d", thread.ID)) } func showThread(ctx sushi.Ctx) { sess := ctx.GetCurrentSession() id := ctx.Param("id").Int() page := int(ctx.QueryArgs().GetUintOrZero("page")) if page < 1 { page = 1 } thread, err := forum.Find(id) if err != nil { sess.SetFlash("error", fmt.Sprintf("Forum thread %d not found", id)) ctx.Redirect("/forum") return } if thread.Parent != 0 { sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id)) ctx.Redirect("/forum") return } // Get thread author threadAuthor, _ := users.Find(thread.Author) if threadAuthor == nil { threadAuthor = &users.User{Username: "[Deleted]", Level: 1, ClassID: 1} } // Get replies with pagination perPage := 30 offset := (page - 1) * perPage var replies []*forum.Forum err = database.Select(&replies, "SELECT * FROM forum WHERE parent = %d ORDER BY posted ASC LIMIT %d OFFSET %d", id, perPage, offset) if err != nil { replies = make([]*forum.Forum, 0) } // Build reply info with authors var replyInfos []PostInfo for _, reply := range replies { author, _ := users.Find(reply.Author) if author == nil { author = &users.User{Username: "[Deleted]", Level: 1, ClassID: 1} } replyInfos = append(replyInfos, PostInfo{ Post: reply, Author: author, }) } totalPages := (thread.Replies + perPage - 1) / perPage if totalPages < 1 { totalPages = 1 } components.RenderPage(ctx, thread.Title, "forum/thread.html", map[string]any{ "thread": thread, "threadAuthor": threadAuthor, "replyInfos": replyInfos, "currentPage": page, "totalPages": totalPages, "hasNext": page < totalPages, "hasPrev": page > 1, "markdown": markdown.MarkdownToHTML, }) } func showReply(ctx sushi.Ctx) { sess := ctx.GetCurrentSession() id := ctx.Param("id").Int() thread, err := forum.Find(id) if err != nil { sess.SetFlash("error", fmt.Sprintf("Forum thread %d not found", id)) ctx.Redirect("/forum") return } if thread.Parent != 0 { sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id)) ctx.Redirect("/forum") return } components.RenderPage(ctx, "Reply to "+thread.Title, "forum/reply.html", map[string]any{ "thread": thread, }) } func reply(ctx sushi.Ctx) { sess := ctx.GetCurrentSession() id := ctx.Param("id").Int() thread, err := forum.Find(id) if err != nil { sess.SetFlash("error", fmt.Sprintf("Forum thread %d not found", id)) ctx.Redirect("/forum") return } if thread.Parent != 0 { sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id)) ctx.Redirect("/forum") 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 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) }) ctx.Redirect(fmt.Sprintf("/forum/%d", thread.ID)) }