largely finish forum implementation
This commit is contained in:
parent
ddc5bd5f6e
commit
e3146068bc
@ -285,7 +285,8 @@ form.standard {
|
||||
padding: 0.25rem 0.25rem;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
min-height: 200px;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
@ -530,4 +531,49 @@ div.thread-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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/users"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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
|
||||
// ThreadData flattened struct for template use
|
||||
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
|
||||
}
|
||||
|
||||
// PostInfo combines a forum post/reply with its author
|
||||
type PostInfo struct {
|
||||
Post *forum.Forum
|
||||
Author *users.User
|
||||
// ReplyData flattened struct for template use
|
||||
type ReplyData struct {
|
||||
ID int
|
||||
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) {
|
||||
@ -33,63 +94,98 @@ func RegisterForumRoutes(app *sushi.App) {
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
perPage := 30
|
||||
offset := (page - 1) * perPage
|
||||
if thread.Parent != 0 {
|
||||
sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id))
|
||||
ctx.Redirect("/forum")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 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)
|
||||
return thread, true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
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
|
||||
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{
|
||||
"threadInfos": threadInfos,
|
||||
"currentPage": page,
|
||||
"totalPages": totalPages,
|
||||
"hasNext": page < totalPages,
|
||||
"hasPrev": page > 1,
|
||||
"threads": threads,
|
||||
"currentPage": pagination.Page,
|
||||
"totalPages": pagination.TotalPages(),
|
||||
"hasNext": pagination.HasNext(),
|
||||
"hasPrev": pagination.HasPrev(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -122,100 +218,73 @@ func new(ctx sushi.Ctx) {
|
||||
thread.Title = title
|
||||
thread.Content = content
|
||||
|
||||
database.Transaction(func() error {
|
||||
return thread.Insert()
|
||||
})
|
||||
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) {
|
||||
sess := ctx.GetCurrentSession()
|
||||
id := ctx.Param("id").Int()
|
||||
page := int(ctx.QueryArgs().GetUintOrZero("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
thread, ok := validateThread(ctx, id)
|
||||
if !ok {
|
||||
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 {
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
if thread.Parent != 0 {
|
||||
sess.SetFlash("error", fmt.Sprintf("Forum post %d is not a thread", id))
|
||||
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())
|
||||
|
||||
// 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)
|
||||
log.Printf("Error loading replies for thread %d: %v", id, err)
|
||||
replies = make([]*ReplyData, 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,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -223,23 +292,15 @@ 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")
|
||||
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))
|
||||
ctx.Redirect(fmt.Sprintf("/forum/%d#reply", id))
|
||||
return
|
||||
}
|
||||
|
||||
@ -251,7 +312,7 @@ func reply(ctx sushi.Ctx) {
|
||||
reply.Title = "Re: " + thread.Title
|
||||
reply.Content = content
|
||||
|
||||
database.Transaction(func() error {
|
||||
err := database.Transaction(func() error {
|
||||
if err := reply.Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -264,5 +325,16 @@ func reply(ctx sushi.Ctx) {
|
||||
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 {
|
||||
result := content
|
||||
|
||||
// Process function calls and complex expressions first
|
||||
start := 0
|
||||
for {
|
||||
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]
|
||||
|
||||
// Check for function calls
|
||||
if parenIdx := strings.Index(placeholder, "("); parenIdx != -1 && strings.HasSuffix(placeholder, ")") {
|
||||
value := t.callFunction(placeholder, data)
|
||||
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 {
|
||||
// Try to evaluate as expression
|
||||
value := t.evaluateExpression(placeholder, data)
|
||||
if value != nil {
|
||||
result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:]
|
||||
start = startIdx + len(fmt.Sprintf("%v", value))
|
||||
continue
|
||||
@ -436,6 +416,188 @@ func (t *Template) processVariables(content string, data map[string]any) string
|
||||
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 {
|
||||
parenIdx := strings.Index(expr, "(")
|
||||
funcName := strings.TrimSpace(expr[:parenIdx])
|
||||
@ -520,7 +682,7 @@ func (t *Template) parseArgs(argsStr string, data map[string]any) []any {
|
||||
current += string(r)
|
||||
case ',':
|
||||
if !inQuotes && parenLevel == 0 {
|
||||
args = append(args, t.parseArgValue(strings.TrimSpace(current), data))
|
||||
args = append(args, t.evaluateExpression(strings.TrimSpace(current), data))
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
@ -531,54 +693,12 @@ func (t *Template) parseArgs(argsStr string, data map[string]any) []any {
|
||||
}
|
||||
|
||||
if current != "" {
|
||||
args = append(args, t.parseArgValue(strings.TrimSpace(current), data))
|
||||
args = append(args, t.evaluateExpression(strings.TrimSpace(current), data))
|
||||
}
|
||||
|
||||
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 {
|
||||
argValue := reflect.ValueOf(arg)
|
||||
|
||||
@ -785,43 +905,12 @@ func (t *Template) evaluateCondition(condition string, data map[string]any) bool
|
||||
if len(parts) == 2 {
|
||||
left := strings.TrimSpace(parts[0])
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
return t.isTruthy(t.evaluateExpression(condition, data))
|
||||
}
|
||||
|
||||
func (t *Template) compareValues(left, right any, op string) bool {
|
||||
|
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if #threadInfos > 0}
|
||||
{if #threads > 0}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -18,23 +18,23 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for threadInfo in threadInfos}
|
||||
{for thread in threads}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<a href="/forum/{threadInfo.Thread.ID}">{threadInfo.Thread.Title}</a>
|
||||
<a href="/forum/{thread.ID}">{thread.Title}</a>
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
{threadInfo.Thread.Replies}
|
||||
{thread.Replies}
|
||||
</td>
|
||||
<td>
|
||||
{if threadInfo.LastReplyBy}
|
||||
by {threadInfo.LastReplyBy.Username}<br>
|
||||
{threadInfo.Thread.LastPostTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
{if thread.HasReplies}
|
||||
by {thread.LastReplyUsername}<br>
|
||||
{thread.LastPostTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
{else}
|
||||
No replies
|
||||
{/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>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Original Thread Post -->
|
||||
<div>
|
||||
<div>
|
||||
<div>{threadAuthor.Username}</div>
|
||||
<div>
|
||||
Level {threadAuthor.Level}<br>
|
||||
{threadAuthor.Class().Name}
|
||||
<div class="forum-post original-post">
|
||||
<div class="left">
|
||||
<div class="timestamp">
|
||||
<b>OP</b> - {thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{md(thread.Content)}
|
||||
</div>
|
||||
<div>
|
||||
Posted: {thread.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div>{thread.AuthorUsername}</div>
|
||||
<div class="user-class">Level {thread.AuthorLevel} {thread.AuthorClass}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{if #replyInfos > 0}
|
||||
{for replyInfo in replyInfos}
|
||||
<div>
|
||||
<div>
|
||||
<div>{replyInfo.Author.Username}</div>
|
||||
{if #replies > 0}
|
||||
{for reply in replies}
|
||||
<div class="forum-post">
|
||||
<div class="left">
|
||||
<div class="timestamp">
|
||||
{reply.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
</div>
|
||||
<div>
|
||||
Level {replyInfo.Author.Level}<br>
|
||||
{replyInfo.Author.Class().Name}
|
||||
{md(reply.Content)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{markdown(replyInfo.Post.Content)}
|
||||
</div>
|
||||
<div>
|
||||
Posted: {replyInfo.Post.PostedTime().Format("Jan 2, 2006 3:04 PM")}
|
||||
</div>
|
||||
<div class="right">
|
||||
<div>{reply.AuthorUsername}</div>
|
||||
<div class="user-class">Level {reply.AuthorLevel} {reply.AuthorClass}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/for}
|
||||
@ -53,7 +47,7 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{if totalPages > 1}
|
||||
<div>
|
||||
<div class="mt-1">
|
||||
{if hasPrev}
|
||||
<a href="/forum/{thread.ID}?page={currentPage - 1}"><button class="btn">Previous</button></a>
|
||||
{/if}
|
||||
@ -66,8 +60,18 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply Button at Bottom -->
|
||||
<div>
|
||||
<a href="/forum/{thread.ID}/reply"><button class="btn btn-primary">Reply to Thread</button></a>
|
||||
<!-- Reply Form at Bottom -->
|
||||
<div id="reply" class="bottom-reply">
|
||||
<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>
|
||||
{/block}
|
Loading…
x
Reference in New Issue
Block a user