largely finish forum implementation
This commit is contained in:
parent
ddc5bd5f6e
commit
e3146068bc
@ -285,7 +285,8 @@ form.standard {
|
|||||||
padding: 0.25rem 0.25rem;
|
padding: 0.25rem 0.25rem;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
color: white;
|
color: white;
|
||||||
min-height: 200px;
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
@ -530,4 +531,49 @@ div.thread-title {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.forum-post {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr 1fr;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:not(:first-of-type) {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.original-post {
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div.left {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div.right {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.timestamp {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.user-class {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.bottom-reply {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 2px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
BIN
data/dk.db
BIN
data/dk.db
Binary file not shown.
27
internal/helpers/pagination.go
Normal file
27
internal/helpers/pagination.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
type Pagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Pagination) Offset() int {
|
||||||
|
return (p.Page - 1) * p.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Pagination) TotalPages() int {
|
||||||
|
pages := (p.Total + p.PerPage - 1) / p.PerPage
|
||||||
|
if pages < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Pagination) HasNext() bool {
|
||||||
|
return p.Page < p.TotalPages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Pagination) HasPrev() bool {
|
||||||
|
return p.Page > 1
|
||||||
|
}
|
@ -7,23 +7,84 @@ import (
|
|||||||
"dk/internal/models/forum"
|
"dk/internal/models/forum"
|
||||||
"dk/internal/models/users"
|
"dk/internal/models/users"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
sushi "git.sharkk.net/Sharkk/Sushi"
|
sushi "git.sharkk.net/Sharkk/Sushi"
|
||||||
"git.sharkk.net/Sharkk/Sushi/auth"
|
"git.sharkk.net/Sharkk/Sushi/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ThreadInfo combines forum thread with author and last reply info
|
// ThreadData flattened struct for template use
|
||||||
type ThreadInfo struct {
|
type ThreadData struct {
|
||||||
Thread *forum.Forum
|
ID int
|
||||||
Author *users.User
|
Posted int64
|
||||||
LastReplyBy *users.User
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostInfo combines a forum post/reply with its author
|
// ReplyData flattened struct for template use
|
||||||
type PostInfo struct {
|
type ReplyData struct {
|
||||||
Post *forum.Forum
|
ID int
|
||||||
Author *users.User
|
Posted int64
|
||||||
|
Author int
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
AuthorUsername string
|
||||||
|
AuthorLevel int
|
||||||
|
AuthorClass string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for ThreadData
|
||||||
|
func (t *ThreadData) PostedTime() time.Time {
|
||||||
|
return time.Unix(t.Posted, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ThreadData) LastPostTime() time.Time {
|
||||||
|
return time.Unix(t.LastPost, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for ReplyData
|
||||||
|
func (r *ReplyData) PostedTime() time.Time {
|
||||||
|
return time.Unix(r.Posted, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginationParams handles common pagination logic
|
||||||
|
type PaginationParams struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PaginationParams) Offset() int {
|
||||||
|
return (p.Page - 1) * p.PerPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PaginationParams) TotalPages() int {
|
||||||
|
pages := (p.Total + p.PerPage - 1) / p.PerPage
|
||||||
|
if pages < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PaginationParams) HasNext() bool {
|
||||||
|
return p.Page < p.TotalPages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PaginationParams) HasPrev() bool {
|
||||||
|
return p.Page > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterForumRoutes(app *sushi.App) {
|
func RegisterForumRoutes(app *sushi.App) {
|
||||||
@ -33,63 +94,98 @@ func RegisterForumRoutes(app *sushi.App) {
|
|||||||
authed.Get("/new", showNew)
|
authed.Get("/new", showNew)
|
||||||
authed.Post("/new", new)
|
authed.Post("/new", new)
|
||||||
authed.Get("/:id", showThread)
|
authed.Get("/:id", showThread)
|
||||||
authed.Get("/:id/reply", showReply)
|
|
||||||
authed.Post("/:id/reply", reply)
|
authed.Post("/:id/reply", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
func index(ctx sushi.Ctx) {
|
// Helper function to validate thread exists and is actually a thread (not a reply)
|
||||||
page := int(ctx.QueryArgs().GetUintOrZero("page"))
|
func validateThread(ctx sushi.Ctx, id int) (*forum.Forum, bool) {
|
||||||
if page < 1 {
|
sess := ctx.GetCurrentSession()
|
||||||
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 nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
perPage := 30
|
if thread.Parent != 0 {
|
||||||
offset := (page - 1) * perPage
|
sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id))
|
||||||
|
ctx.Redirect("/forum")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
// Get threads with pagination
|
return thread, true
|
||||||
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)
|
|
||||||
|
// Helper function to get pagination params from request
|
||||||
|
func getPaginationParams(ctx sushi.Ctx, perPage int) PaginationParams {
|
||||||
|
page := max(int(ctx.QueryArgs().GetUintOrZero("page")), 1)
|
||||||
|
return PaginationParams{
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func index(ctx sushi.Ctx) {
|
||||||
|
pagination := getPaginationParams(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 {
|
if err != nil {
|
||||||
threads = make([]*forum.Forum, 0)
|
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
|
// Get total count for pagination
|
||||||
var totalCount int
|
type CountResult struct{ Count int }
|
||||||
database.Get(&totalCount, "SELECT COUNT(*) FROM forum WHERE parent = 0")
|
var paginationResult CountResult
|
||||||
|
database.Get(&paginationResult, "SELECT COUNT(*) as count FROM forum WHERE parent = 0")
|
||||||
// Build thread info with authors and last reply info
|
pagination.Total = paginationResult.Count
|
||||||
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{
|
components.RenderPage(ctx, "Forum", "forum/index.html", map[string]any{
|
||||||
"threadInfos": threadInfos,
|
"threads": threads,
|
||||||
"currentPage": page,
|
"currentPage": pagination.Page,
|
||||||
"totalPages": totalPages,
|
"totalPages": pagination.TotalPages(),
|
||||||
"hasNext": page < totalPages,
|
"hasNext": pagination.HasNext(),
|
||||||
"hasPrev": page > 1,
|
"hasPrev": pagination.HasPrev(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,100 +218,73 @@ func new(ctx sushi.Ctx) {
|
|||||||
thread.Title = title
|
thread.Title = title
|
||||||
thread.Content = content
|
thread.Content = content
|
||||||
|
|
||||||
database.Transaction(func() error {
|
if err := thread.Insert(); err != nil {
|
||||||
return thread.Insert()
|
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))
|
ctx.Redirect(fmt.Sprintf("/forum/%d", thread.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func showThread(ctx sushi.Ctx) {
|
func showThread(ctx sushi.Ctx) {
|
||||||
sess := ctx.GetCurrentSession()
|
|
||||||
id := ctx.Param("id").Int()
|
id := ctx.Param("id").Int()
|
||||||
page := int(ctx.QueryArgs().GetUintOrZero("page"))
|
thread, ok := validateThread(ctx, id)
|
||||||
if page < 1 {
|
if !ok {
|
||||||
page = 1
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
thread, err := forum.Find(id)
|
pagination := getPaginationParams(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 {
|
if err != nil {
|
||||||
sess.SetFlash("error", fmt.Sprintf("Forum thread %d not found", id))
|
log.Printf("Error loading thread %d: %v", id, err)
|
||||||
|
sess := ctx.GetCurrentSession()
|
||||||
|
sess.SetFlash("error", "Failed to load thread")
|
||||||
ctx.Redirect("/forum")
|
ctx.Redirect("/forum")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if thread.Parent != 0 {
|
// Get replies with user data
|
||||||
sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id))
|
var replies []*ReplyData
|
||||||
ctx.Redirect("/forum")
|
err = database.Select(&replies, `
|
||||||
return
|
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())
|
||||||
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
replies = make([]*forum.Forum, 0)
|
log.Printf("Error loading replies for thread %d: %v", id, err)
|
||||||
|
replies = make([]*ReplyData, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build reply info with authors
|
components.RenderPage(ctx, threadData.Title, "forum/thread.html", map[string]any{
|
||||||
var replyInfos []PostInfo
|
"thread": threadData,
|
||||||
for _, reply := range replies {
|
"replies": replies,
|
||||||
author, _ := users.Find(reply.Author)
|
"currentPage": pagination.Page,
|
||||||
if author == nil {
|
"totalPages": pagination.TotalPages(),
|
||||||
author = &users.User{Username: "[Deleted]", Level: 1, ClassID: 1}
|
"hasNext": pagination.HasNext(),
|
||||||
}
|
"hasPrev": pagination.HasPrev(),
|
||||||
|
"markdown": markdown.MarkdownToHTML,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,23 +292,15 @@ func reply(ctx sushi.Ctx) {
|
|||||||
sess := ctx.GetCurrentSession()
|
sess := ctx.GetCurrentSession()
|
||||||
id := ctx.Param("id").Int()
|
id := ctx.Param("id").Int()
|
||||||
|
|
||||||
thread, err := forum.Find(id)
|
thread, ok := validateThread(ctx, id)
|
||||||
if err != nil {
|
if !ok {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content := strings.TrimSpace(ctx.Form("content").String())
|
content := strings.TrimSpace(ctx.Form("content").String())
|
||||||
if content == "" {
|
if content == "" {
|
||||||
sess.SetFlash("error", "Reply content cannot be empty")
|
sess.SetFlash("error", "Reply content cannot be empty")
|
||||||
ctx.Redirect(fmt.Sprintf("/forum/%d/reply", id))
|
ctx.Redirect(fmt.Sprintf("/forum/%d#reply", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,7 +312,7 @@ func reply(ctx sushi.Ctx) {
|
|||||||
reply.Title = "Re: " + thread.Title
|
reply.Title = "Re: " + thread.Title
|
||||||
reply.Content = content
|
reply.Content = content
|
||||||
|
|
||||||
database.Transaction(func() error {
|
err := database.Transaction(func() error {
|
||||||
if err := reply.Insert(); err != nil {
|
if err := reply.Insert(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -264,5 +325,16 @@ func reply(ctx sushi.Ctx) {
|
|||||||
thread.Replies, thread.LastPost, thread.ID)
|
thread.Replies, thread.LastPost, thread.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.Redirect(fmt.Sprintf("/forum/%d", 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))
|
||||||
}
|
}
|
||||||
|
@ -386,7 +386,6 @@ func (t *Template) processConditionals(content string, data map[string]any) stri
|
|||||||
func (t *Template) processVariables(content string, data map[string]any) string {
|
func (t *Template) processVariables(content string, data map[string]any) string {
|
||||||
result := content
|
result := content
|
||||||
|
|
||||||
// Process function calls and complex expressions first
|
|
||||||
start := 0
|
start := 0
|
||||||
for {
|
for {
|
||||||
startIdx := strings.Index(result[start:], "{")
|
startIdx := strings.Index(result[start:], "{")
|
||||||
@ -403,28 +402,9 @@ func (t *Template) processVariables(content string, data map[string]any) string
|
|||||||
|
|
||||||
placeholder := result[startIdx+1 : endIdx]
|
placeholder := result[startIdx+1 : endIdx]
|
||||||
|
|
||||||
// Check for function calls
|
// Try to evaluate as expression
|
||||||
if parenIdx := strings.Index(placeholder, "("); parenIdx != -1 && strings.HasSuffix(placeholder, ")") {
|
value := t.evaluateExpression(placeholder, data)
|
||||||
value := t.callFunction(placeholder, data)
|
if value != nil {
|
||||||
if value != nil {
|
|
||||||
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
|
||||||
start = startIdx + len(fmt.Sprintf("%v", value))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for method calls or dot notation
|
|
||||||
if strings.Contains(placeholder, ".") {
|
|
||||||
value := t.getNestedValue(data, placeholder)
|
|
||||||
if value != nil {
|
|
||||||
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
|
||||||
start = startIdx + len(fmt.Sprintf("%v", value))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple variable
|
|
||||||
if value, ok := data[placeholder]; ok {
|
|
||||||
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
||||||
start = startIdx + len(fmt.Sprintf("%v", value))
|
start = startIdx + len(fmt.Sprintf("%v", value))
|
||||||
continue
|
continue
|
||||||
@ -436,6 +416,188 @@ func (t *Template) processVariables(content string, data map[string]any) string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evaluateExpression handles expressions - tries arithmetic first, falls back to original logic
|
||||||
|
func (t *Template) evaluateExpression(expr string, data map[string]any) any {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
|
||||||
|
// Special handling for length operator
|
||||||
|
if strings.HasPrefix(expr, "#") {
|
||||||
|
return t.getLength(t.getNestedValue(data, expr[1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it contains arithmetic operators (but not inside method calls)
|
||||||
|
var foundOp string
|
||||||
|
var opPos int = -1
|
||||||
|
parenLevel := 0
|
||||||
|
inQuotes := false
|
||||||
|
|
||||||
|
// Scan for operators not inside parentheses or quotes
|
||||||
|
for i, r := range expr {
|
||||||
|
switch r {
|
||||||
|
case '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
case '(':
|
||||||
|
if !inQuotes {
|
||||||
|
parenLevel++
|
||||||
|
}
|
||||||
|
case ')':
|
||||||
|
if !inQuotes {
|
||||||
|
parenLevel--
|
||||||
|
}
|
||||||
|
case '+', '-':
|
||||||
|
if !inQuotes && parenLevel == 0 && i > 0 {
|
||||||
|
foundOp = string(r)
|
||||||
|
opPos = i
|
||||||
|
}
|
||||||
|
case '*', '/', '%':
|
||||||
|
if !inQuotes && parenLevel == 0 && i > 0 && (opPos == -1 || (foundOp != "+" && foundOp != "-")) {
|
||||||
|
foundOp = string(r)
|
||||||
|
opPos = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found an arithmetic operator, evaluate it
|
||||||
|
if opPos > 0 {
|
||||||
|
left := strings.TrimSpace(expr[:opPos])
|
||||||
|
right := strings.TrimSpace(expr[opPos+1:])
|
||||||
|
|
||||||
|
leftVal := t.evaluateExpression(left, data)
|
||||||
|
rightVal := t.evaluateExpression(right, data)
|
||||||
|
|
||||||
|
return t.performArithmetic(leftVal, rightVal, foundOp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle parentheses (but only if they wrap the entire expression)
|
||||||
|
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") && t.matchingParen(expr) == len(expr)-1 {
|
||||||
|
return t.evaluateExpression(expr[1:len(expr)-1], data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use original parsing logic for everything else
|
||||||
|
return t.parseValue(expr, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingParen finds the matching closing paren for the opening paren at position 0
|
||||||
|
func (t *Template) matchingParen(expr string) int {
|
||||||
|
level := 0
|
||||||
|
inQuotes := false
|
||||||
|
for i, r := range expr {
|
||||||
|
switch r {
|
||||||
|
case '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
case '(':
|
||||||
|
if !inQuotes {
|
||||||
|
level++
|
||||||
|
}
|
||||||
|
case ')':
|
||||||
|
if !inQuotes {
|
||||||
|
level--
|
||||||
|
if level == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue uses the original parsing logic
|
||||||
|
func (t *Template) parseValue(expr string, data map[string]any) any {
|
||||||
|
// String literal
|
||||||
|
if strings.HasPrefix(expr, "\"") && strings.HasSuffix(expr, "\"") {
|
||||||
|
return expr[1 : len(expr)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number
|
||||||
|
if num, err := strconv.ParseFloat(expr, 64); err == nil {
|
||||||
|
if strings.Contains(expr, ".") {
|
||||||
|
return num
|
||||||
|
}
|
||||||
|
return int(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if expr == "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if expr == "false" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function call (simple function name only, no dots)
|
||||||
|
if parenIdx := strings.Index(expr, "("); parenIdx != -1 && strings.HasSuffix(expr, ")") {
|
||||||
|
funcName := expr[:parenIdx]
|
||||||
|
if !strings.Contains(funcName, ".") {
|
||||||
|
return t.callFunction(expr, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method calls or dot notation
|
||||||
|
if strings.Contains(expr, ".") {
|
||||||
|
return t.getNestedValue(data, expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple variable
|
||||||
|
if value, ok := data[expr]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return expr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Template) performArithmetic(left, right any, op string) any {
|
||||||
|
leftNum, leftOk := t.toFloat(left)
|
||||||
|
rightNum, rightOk := t.toFloat(right)
|
||||||
|
|
||||||
|
if !leftOk || !rightOk {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "+":
|
||||||
|
result := leftNum + rightNum
|
||||||
|
if t.isInteger(left) && t.isInteger(right) {
|
||||||
|
return int(result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case "-":
|
||||||
|
result := leftNum - rightNum
|
||||||
|
if t.isInteger(left) && t.isInteger(right) {
|
||||||
|
return int(result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case "*":
|
||||||
|
result := leftNum * rightNum
|
||||||
|
if t.isInteger(left) && t.isInteger(right) {
|
||||||
|
return int(result)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case "/":
|
||||||
|
if rightNum == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return leftNum / rightNum
|
||||||
|
case "%":
|
||||||
|
if rightNum == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t.isInteger(left) && t.isInteger(right) {
|
||||||
|
return int(leftNum) % int(rightNum)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Template) isInteger(value any) bool {
|
||||||
|
switch value.(type) {
|
||||||
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Template) callFunction(expr string, data map[string]any) any {
|
func (t *Template) callFunction(expr string, data map[string]any) any {
|
||||||
parenIdx := strings.Index(expr, "(")
|
parenIdx := strings.Index(expr, "(")
|
||||||
funcName := strings.TrimSpace(expr[:parenIdx])
|
funcName := strings.TrimSpace(expr[:parenIdx])
|
||||||
@ -520,7 +682,7 @@ func (t *Template) parseArgs(argsStr string, data map[string]any) []any {
|
|||||||
current += string(r)
|
current += string(r)
|
||||||
case ',':
|
case ',':
|
||||||
if !inQuotes && parenLevel == 0 {
|
if !inQuotes && parenLevel == 0 {
|
||||||
args = append(args, t.parseArgValue(strings.TrimSpace(current), data))
|
args = append(args, t.evaluateExpression(strings.TrimSpace(current), data))
|
||||||
current = ""
|
current = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -531,54 +693,12 @@ func (t *Template) parseArgs(argsStr string, data map[string]any) []any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if current != "" {
|
if current != "" {
|
||||||
args = append(args, t.parseArgValue(strings.TrimSpace(current), data))
|
args = append(args, t.evaluateExpression(strings.TrimSpace(current), data))
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) parseArgValue(arg string, data map[string]any) any {
|
|
||||||
arg = strings.TrimSpace(arg)
|
|
||||||
|
|
||||||
// String literal
|
|
||||||
if strings.HasPrefix(arg, "\"") && strings.HasSuffix(arg, "\"") {
|
|
||||||
return arg[1 : len(arg)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number
|
|
||||||
if num, err := strconv.ParseFloat(arg, 64); err == nil {
|
|
||||||
if strings.Contains(arg, ".") {
|
|
||||||
return num
|
|
||||||
}
|
|
||||||
return int(num)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boolean
|
|
||||||
if arg == "true" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if arg == "false" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function call
|
|
||||||
if parenIdx := strings.Index(arg, "("); parenIdx != -1 && strings.HasSuffix(arg, ")") {
|
|
||||||
return t.callFunction(arg, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variable or dot notation
|
|
||||||
if strings.Contains(arg, ".") {
|
|
||||||
return t.getNestedValue(data, arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple variable
|
|
||||||
if value, ok := data[arg]; ok {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return arg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Template) convertArg(arg any, targetType reflect.Type) reflect.Value {
|
func (t *Template) convertArg(arg any, targetType reflect.Type) reflect.Value {
|
||||||
argValue := reflect.ValueOf(arg)
|
argValue := reflect.ValueOf(arg)
|
||||||
|
|
||||||
@ -785,43 +905,12 @@ func (t *Template) evaluateCondition(condition string, data map[string]any) bool
|
|||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
left := strings.TrimSpace(parts[0])
|
left := strings.TrimSpace(parts[0])
|
||||||
right := strings.TrimSpace(parts[1])
|
right := strings.TrimSpace(parts[1])
|
||||||
return t.compareValues(t.getConditionValue(left, data), t.getConditionValue(right, data), op)
|
return t.compareValues(t.evaluateExpression(left, data), t.evaluateExpression(right, data), op)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.isTruthy(t.getConditionValue(condition, data))
|
return t.isTruthy(t.evaluateExpression(condition, data))
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Template) getConditionValue(expr string, data map[string]any) any {
|
|
||||||
expr = strings.TrimSpace(expr)
|
|
||||||
|
|
||||||
if strings.HasPrefix(expr, "#") {
|
|
||||||
return t.getLength(t.getNestedValue(data, expr[1:]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if num, err := strconv.ParseFloat(expr, 64); err == nil {
|
|
||||||
return num
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(expr, "\"") && strings.HasSuffix(expr, "\"") {
|
|
||||||
return expr[1 : len(expr)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function call
|
|
||||||
if parenIdx := strings.Index(expr, "("); parenIdx != -1 && strings.HasSuffix(expr, ")") {
|
|
||||||
return t.callFunction(expr, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(expr, ".") {
|
|
||||||
return t.getNestedValue(data, expr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if value, ok := data[expr]; ok {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return expr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Template) compareValues(left, right any, op string) bool {
|
func (t *Template) compareValues(left, right any, op string) bool {
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{if #threadInfos > 0}
|
{if #threads > 0}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -18,23 +18,23 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{for threadInfo in threadInfos}
|
{for thread in threads}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div>
|
<div>
|
||||||
<a href="/forum/{threadInfo.Thread.ID}">{threadInfo.Thread.Title}</a>
|
<a href="/forum/{thread.ID}">{thread.Title}</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
by {threadInfo.Author.Username} • {threadInfo.Thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
by {thread.AuthorUsername} • {thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{threadInfo.Thread.Replies}
|
{thread.Replies}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{if threadInfo.LastReplyBy}
|
{if thread.HasReplies}
|
||||||
by {threadInfo.LastReplyBy.Username}<br>
|
by {thread.LastReplyUsername}<br>
|
||||||
{threadInfo.Thread.LastPostTime().Format("Jan 2, 2006 3:04 PM")}
|
{thread.LastPostTime().Format("Jan 2, 2006 3:04 PM")}
|
||||||
{else}
|
{else}
|
||||||
No replies
|
No replies
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
{include "layout.html"}
|
|
||||||
|
|
||||||
{block "content"}
|
|
||||||
<h2 class="mb-1">Reply to: {thread.Title}</h2>
|
|
||||||
|
|
||||||
<form action="/forum/{thread.ID}/reply" method="post" class="standard">
|
|
||||||
{csrf}
|
|
||||||
|
|
||||||
<div class="mb-1">
|
|
||||||
<textarea class="forum-text w-full" placeholder="Type your reply here!" name="content" rows="10"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="submit" class="btn btn-primary">Reply</button>
|
|
||||||
<a href="/forum/{thread.ID}"><button class="btn">Back</button></a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/block}
|
|
@ -5,47 +5,41 @@
|
|||||||
<h2>{thread.Title}</h2>
|
<h2>{thread.Title}</h2>
|
||||||
<div>
|
<div>
|
||||||
<a href="/forum"><button class="btn">Back</button></a>
|
<a href="/forum"><button class="btn">Back</button></a>
|
||||||
<a href="/forum/{thread.ID}/reply"><button class="btn btn-primary">Reply</button></a>
|
<a href="#reply"><button class="btn btn-primary">Reply</button></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Original Thread Post -->
|
<!-- Original Thread Post -->
|
||||||
<div>
|
<div class="forum-post original-post">
|
||||||
<div>
|
<div class="left">
|
||||||
<div>{threadAuthor.Username}</div>
|
<div class="timestamp">
|
||||||
<div>
|
<b>OP</b> - {thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||||
Level {threadAuthor.Level}<br>
|
|
||||||
{threadAuthor.Class().Name}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div>
|
<div>
|
||||||
{md(thread.Content)}
|
{md(thread.Content)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
Posted: {thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
<div class="right">
|
||||||
</div>
|
<div>{thread.AuthorUsername}</div>
|
||||||
|
<div class="user-class">Level {thread.AuthorLevel} {thread.AuthorClass}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
{if #replyInfos > 0}
|
{if #replies > 0}
|
||||||
{for replyInfo in replyInfos}
|
{for reply in replies}
|
||||||
<div>
|
<div class="forum-post">
|
||||||
<div>
|
<div class="left">
|
||||||
<div>{replyInfo.Author.Username}</div>
|
<div class="timestamp">
|
||||||
|
{reply.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Level {replyInfo.Author.Level}<br>
|
{md(reply.Content)}
|
||||||
{replyInfo.Author.Class().Name}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="right">
|
||||||
<div>
|
<div>{reply.AuthorUsername}</div>
|
||||||
{markdown(replyInfo.Post.Content)}
|
<div class="user-class">Level {reply.AuthorLevel} {reply.AuthorClass}</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Posted: {replyInfo.Post.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/for}
|
{/for}
|
||||||
@ -53,7 +47,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{if totalPages > 1}
|
{if totalPages > 1}
|
||||||
<div>
|
<div class="mt-1">
|
||||||
{if hasPrev}
|
{if hasPrev}
|
||||||
<a href="/forum/{thread.ID}?page={currentPage - 1}"><button class="btn">Previous</button></a>
|
<a href="/forum/{thread.ID}?page={currentPage - 1}"><button class="btn">Previous</button></a>
|
||||||
{/if}
|
{/if}
|
||||||
@ -66,8 +60,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Reply Button at Bottom -->
|
<!-- Reply Form at Bottom -->
|
||||||
<div>
|
<div id="reply" class="bottom-reply">
|
||||||
<a href="/forum/{thread.ID}/reply"><button class="btn btn-primary">Reply to Thread</button></a>
|
<form action="/forum/{thread.ID}/reply" method="post" class="standard">
|
||||||
|
{csrf}
|
||||||
|
|
||||||
|
<div class="mb-1">
|
||||||
|
<textarea class="forum-text w-full" placeholder="Type your reply here!" name="content" rows="10"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary">Reply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/block}
|
{/block}
|
Loading…
x
Reference in New Issue
Block a user