diff --git a/data/dk.db b/data/dk.db index 598bc56..3c31fb3 100644 Binary files a/data/dk.db and b/data/dk.db differ diff --git a/internal/routes/forum.go b/internal/routes/forum.go index 337d0a3..f744d48 100644 --- a/internal/routes/forum.go +++ b/internal/routes/forum.go @@ -3,6 +3,7 @@ package routes import ( "dk/internal/components" "dk/internal/database" + "dk/internal/helpers/markdown" "dk/internal/models/forum" "dk/internal/models/users" "fmt" @@ -12,6 +13,22 @@ import ( "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 + AuthorClass string + LastReplyBy *users.User +} + +// PostInfo combines a forum post/reply with its author +type PostInfo struct { + Post *forum.Forum + Author *users.User + AuthorClass string + Content string // Pre-processed markdown content +} + func RegisterForumRoutes(app *sushi.App) { authed := app.Group("/forum") authed.Use(auth.RequireAuth()) @@ -19,17 +36,66 @@ 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) { - threads, err := forum.Threads() + 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) } - fmt.Printf("\nFound %d threads\n", len(threads)) + + // 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} + } + + authorClass := author.Class().Name + + // 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, + AuthorClass: authorClass, + LastReplyBy: lastReplyBy, + }) + } + + totalPages := (totalCount + perPage - 1) / perPage components.RenderPage(ctx, "Forum", "forum/index.html", map[string]any{ - "threads": threads, + "threadInfos": threadInfos, + "currentPage": page, + "totalPages": totalPages, + "hasNext": page < totalPages, + "hasPrev": page > 1, }) } @@ -72,6 +138,10 @@ func new(ctx sushi.Ctx) { 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 { @@ -86,7 +156,126 @@ func showThread(ctx sushi.Ctx) { return } + // Get thread author + threadAuthor, _ := users.Find(thread.Author) + if threadAuthor == nil { + threadAuthor = &users.User{Username: "[Deleted]", Level: 1, ClassID: 1} + } + threadAuthorClass := threadAuthor.Class().Name + threadContent := markdown.MarkdownToHTML(thread.Content) + + // 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 and processed content + var replyInfos []PostInfo + for _, reply := range replies { + author, _ := users.Find(reply.Author) + if author == nil { + author = &users.User{Username: "[Deleted]", Level: 1, ClassID: 1} + } + authorClass := author.Class().Name + content := markdown.MarkdownToHTML(reply.Content) + + replyInfos = append(replyInfos, PostInfo{ + Post: reply, + Author: author, + AuthorClass: authorClass, + Content: content, + }) + } + + totalPages := (thread.Replies + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + components.RenderPage(ctx, thread.Title, "forum/thread.html", map[string]any{ + "thread": thread, + "threadContent": threadContent, + "threadAuthor": threadAuthor, + "threadAuthorClass": threadAuthorClass, + "replyInfos": replyInfos, + "currentPage": page, + "totalPages": totalPages, + "hasNext": page < totalPages, + "hasPrev": page > 1, + }) +} + +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)) +} diff --git a/internal/template/template.go b/internal/template/template.go index 384f386..c5b5f10 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -6,6 +6,7 @@ import ( "reflect" "strconv" "strings" + "sync" "time" sushi "git.sharkk.net/Sharkk/Sushi" @@ -19,6 +20,51 @@ type Template struct { cache *TemplateCache } +type TemplateFunc func(args ...any) any + +var ( + funcRegistry = make(map[string]TemplateFunc) + funcMutex sync.RWMutex + methodCache = make(map[string]reflect.Method) + cacheMutex sync.RWMutex +) + +func init() { + // Built-in functions + RegisterFunc("upper", func(args ...any) any { + if len(args) == 0 { + return "" + } + return strings.ToUpper(fmt.Sprintf("%v", args[0])) + }) + + RegisterFunc("lower", func(args ...any) any { + if len(args) == 0 { + return "" + } + return strings.ToLower(fmt.Sprintf("%v", args[0])) + }) + + RegisterFunc("len", func(args ...any) any { + if len(args) == 0 { + return 0 + } + rv := reflect.ValueOf(args[0]) + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.String: + return rv.Len() + default: + return 0 + } + }) +} + +func RegisterFunc(name string, fn TemplateFunc) { + funcMutex.Lock() + defer funcMutex.Unlock() + funcRegistry[name] = fn +} + func (t *Template) RenderPositional(args ...any) string { result := t.content for i, arg := range args { @@ -342,12 +388,7 @@ 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 simple variables - for key, value := range data { - result = strings.ReplaceAll(result, fmt.Sprintf("{%s}", key), fmt.Sprintf("%v", value)) - } - - // Process dot notation + // Process function calls and complex expressions first start := 0 for { startIdx := strings.Index(result[start:], "{") @@ -364,6 +405,17 @@ 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 { @@ -373,12 +425,186 @@ func (t *Template) processVariables(content string, data map[string]any) string } } + // Simple variable + if value, ok := data[placeholder]; ok { + result = result[:startIdx] + fmt.Sprintf("%v", value) + result[endIdx+1:] + start = startIdx + len(fmt.Sprintf("%v", value)) + continue + } + start = endIdx + 1 } return result } +func (t *Template) callFunction(expr string, data map[string]any) any { + parenIdx := strings.Index(expr, "(") + funcName := strings.TrimSpace(expr[:parenIdx]) + argsStr := strings.TrimSpace(expr[parenIdx+1 : len(expr)-1]) + + funcMutex.RLock() + fn, exists := funcRegistry[funcName] + funcMutex.RUnlock() + + if !exists { + return nil + } + + args := t.parseArgs(argsStr, data) + return fn(args...) +} + +func (t *Template) callMethod(obj any, methodCall string, data map[string]any) any { + if obj == nil { + return nil + } + + parenIdx := strings.Index(methodCall, "(") + methodName := methodCall[:parenIdx] + argsStr := methodCall[parenIdx+1 : len(methodCall)-1] + + rv := reflect.ValueOf(obj) + objType := rv.Type() + + // Check method cache + cacheKey := fmt.Sprintf("%s.%s", objType.String(), methodName) + cacheMutex.RLock() + method, cached := methodCache[cacheKey] + cacheMutex.RUnlock() + + if !cached { + method = rv.MethodByName(methodName) + if !method.IsValid() { + return nil + } + + cacheMutex.Lock() + methodCache[cacheKey] = method + cacheMutex.Unlock() + } + + args := t.parseArgs(argsStr, data) + methodType := method.Type() + + // Convert arguments to match method signature + reflectArgs := make([]reflect.Value, len(args)) + for i, arg := range args { + if i < methodType.NumIn() { + reflectArgs[i] = t.convertArg(arg, methodType.In(i)) + } else { + reflectArgs[i] = reflect.ValueOf(arg) + } + } + + // Handle variadic methods + if methodType.IsVariadic() && len(reflectArgs) >= methodType.NumIn()-1 { + result := method.CallSlice(reflectArgs) + if len(result) > 0 { + return result[0].Interface() + } + } else if len(reflectArgs) == methodType.NumIn() { + result := method.Call(reflectArgs) + if len(result) > 0 { + return result[0].Interface() + } + } + + return nil +} + +func (t *Template) parseArgs(argsStr string, data map[string]any) []any { + if argsStr == "" { + return nil + } + + args := []any{} + current := "" + inQuotes := false + parenLevel := 0 + + for i, r := range argsStr { + switch r { + case '"': + inQuotes = !inQuotes + current += string(r) + case '(': + parenLevel++ + current += string(r) + case ')': + parenLevel-- + current += string(r) + case ',': + if !inQuotes && parenLevel == 0 { + args = append(args, t.parseArgValue(strings.TrimSpace(current), data)) + current = "" + continue + } + current += string(r) + default: + current += string(r) + } + } + + if current != "" { + args = append(args, t.parseArgValue(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) + + if argValue.Type().ConvertibleTo(targetType) { + return argValue.Convert(targetType) + } + + return argValue +} + func (t *Template) findMatchingEnd(content, startTag, endTag string) int { level := 1 pos := 0 @@ -456,6 +682,11 @@ func (t *Template) getNestedValue(data map[string]any, path string) any { var current any = data for i, key := range keys { + // Check for method call + if strings.Contains(key, "(") && strings.HasSuffix(key, ")") { + return t.callMethod(current, key, data) + } + if i == len(keys)-1 { switch v := current.(type) { case map[string]any: @@ -584,6 +815,11 @@ func (t *Template) getConditionValue(expr string, data map[string]any) any { 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) } diff --git a/templates/forum/index.html b/templates/forum/index.html index 578052e..0c46194 100644 --- a/templates/forum/index.html +++ b/templates/forum/index.html @@ -3,25 +3,64 @@ {block "content"}

Forum

-
- -{if #threads > 0} - +{if #threadInfos > 0} +
+ + + + + + + - {for thread in threads} - - - + {for threadInfo in threadInfos} + + + + {/for}
ThreadRepliesLast Reply
{thread.Title}{thread.Replies} Replies
+
+ {threadInfo.Thread.Title} +
+
+ by {threadInfo.Author.Username} • {threadInfo.PostedTime} +
+
+ {threadInfo.Thread.Replies} + + {if threadInfo.LastReplyBy} + by {threadInfo.LastReplyBy.Username}
+ {threadInfo.LastPostTime} + {else} + No replies + {/if} +
+ +{if totalPages > 1} +
+ {if hasPrev} + + {/if} + + Page {currentPage} of {totalPages} + + {if hasNext} + + {/if} +
+{/if} + {else} -No threads! +
+ No threads yet. Create the first one! +
{/if} {/block} diff --git a/templates/forum/reply.html b/templates/forum/reply.html new file mode 100644 index 0000000..da170c3 --- /dev/null +++ b/templates/forum/reply.html @@ -0,0 +1,18 @@ +{include "layout.html"} + +{block "content"} +

Reply to: {thread.Title}

+ +
+ {csrf} + +
+ +
+ +
+ + +
+
+{/block} diff --git a/templates/forum/thread.html b/templates/forum/thread.html index 342745c..63b48df 100644 --- a/templates/forum/thread.html +++ b/templates/forum/thread.html @@ -3,15 +3,71 @@ {block "content"}

{thread.Title}

-
+ +
+
+
{threadAuthor.Username}
+
+ Level {threadAuthor.Level}
+ {threadAuthorClass} +
+
+
+
+ {threadContent} +
+
+ Posted: {threadPostedTime} +
+
+
-

- {thread.Content} -

+ +{if #replyInfos > 0} +{for replyInfo in replyInfos} +
+
+
{replyInfo.Author.Username}
+
+ Level {replyInfo.Author.Level}
+ {replyInfo.AuthorClass} +
+
+
+
+ {replyInfo.Content} +
+
+ Posted: {replyInfo.PostedTime} +
+
+
+{/for} +{/if} + + +{if totalPages > 1} +
+ {if hasPrev} + + {/if} + + Page {currentPage} of {totalPages} + + {if hasNext} + + {/if} +
+{/if} + + +
+ +
{/block}