build up forum features, work in method/func calls in template engine

This commit is contained in:
Sky Johnson 2025-08-22 18:55:00 -05:00
parent 340d4cf6e8
commit b5e3413c63
6 changed files with 560 additions and 22 deletions

Binary file not shown.

View File

@ -3,6 +3,7 @@ package routes
import ( import (
"dk/internal/components" "dk/internal/components"
"dk/internal/database" "dk/internal/database"
"dk/internal/helpers/markdown"
"dk/internal/models/forum" "dk/internal/models/forum"
"dk/internal/models/users" "dk/internal/models/users"
"fmt" "fmt"
@ -12,6 +13,22 @@ import (
"git.sharkk.net/Sharkk/Sushi/auth" "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) { func RegisterForumRoutes(app *sushi.App) {
authed := app.Group("/forum") authed := app.Group("/forum")
authed.Use(auth.RequireAuth()) authed.Use(auth.RequireAuth())
@ -19,17 +36,66 @@ 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)
} }
func index(ctx sushi.Ctx) { 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 { if err != nil {
threads = make([]*forum.Forum, 0) 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{ 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) { func showThread(ctx sushi.Ctx) {
sess := ctx.GetCurrentSession() sess := ctx.GetCurrentSession()
id := ctx.Param("id").Int() id := ctx.Param("id").Int()
page := int(ctx.QueryArgs().GetUintOrZero("page"))
if page < 1 {
page = 1
}
thread, err := forum.Find(id) thread, err := forum.Find(id)
if err != nil { if err != nil {
@ -86,7 +156,126 @@ func showThread(ctx sushi.Ctx) {
return 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{ 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, "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))
}

View File

@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
sushi "git.sharkk.net/Sharkk/Sushi" sushi "git.sharkk.net/Sharkk/Sushi"
@ -19,6 +20,51 @@ type Template struct {
cache *TemplateCache 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 { func (t *Template) RenderPositional(args ...any) string {
result := t.content result := t.content
for i, arg := range args { 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 { func (t *Template) processVariables(content string, data map[string]any) string {
result := content result := content
// Process simple variables // Process function calls and complex expressions first
for key, value := range data {
result = strings.ReplaceAll(result, fmt.Sprintf("{%s}", key), fmt.Sprintf("%v", value))
}
// Process dot notation
start := 0 start := 0
for { for {
startIdx := strings.Index(result[start:], "{") 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] 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, ".") { if strings.Contains(placeholder, ".") {
value := t.getNestedValue(data, placeholder) value := t.getNestedValue(data, placeholder)
if value != nil { 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 start = endIdx + 1
} }
return result 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 { func (t *Template) findMatchingEnd(content, startTag, endTag string) int {
level := 1 level := 1
pos := 0 pos := 0
@ -456,6 +682,11 @@ func (t *Template) getNestedValue(data map[string]any, path string) any {
var current any = data var current any = data
for i, key := range keys { 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 { if i == len(keys)-1 {
switch v := current.(type) { switch v := current.(type) {
case map[string]any: 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] 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, ".") { if strings.Contains(expr, ".") {
return t.getNestedValue(data, expr) return t.getNestedValue(data, expr)
} }

View File

@ -3,25 +3,64 @@
{block "content"} {block "content"}
<div class="thread-title mb-05"> <div class="thread-title mb-05">
<h2>Forum</h2> <h2>Forum</h2>
<div> <div>
<a href="/forum/new"><button class="btn btn-primary">New Thread</button></a> <a href="/forum/new"><button class="btn btn-primary">New Thread</button></a>
</div> </div>
</div> </div>
{if #threadInfos > 0}
{if #threads > 0} <table style="width: 100%; border-collapse: collapse;">
<table> <thead>
<tr style="background-color: #f5f5f5;">
<th style="padding: 10px; text-align: left; border-bottom: 1px solid #ddd;">Thread</th>
<th style="padding: 10px; text-align: center; border-bottom: 1px solid #ddd; width: 100px;">Replies</th>
<th style="padding: 10px; text-align: left; border-bottom: 1px solid #ddd; width: 200px;">Last Reply</th>
</tr>
</thead>
<tbody> <tbody>
{for thread in threads} {for threadInfo in threadInfos}
<tr> <tr style="border-bottom: 1px solid #eee;">
<td><a href="/forum/{thread.ID}">{thread.Title}</a></td> <td style="padding: 10px;">
<td>{thread.Replies} Replies</td> <div style="font-weight: bold; margin-bottom: 5px;">
<a href="/forum/{threadInfo.Thread.ID}" style="text-decoration: none; color: #333;">{threadInfo.Thread.Title}</a>
</div>
<div style="font-size: 12px; color: #666;">
by {threadInfo.Author.Username} • {threadInfo.PostedTime}
</div>
</td>
<td style="padding: 10px; text-align: center;">
{threadInfo.Thread.Replies}
</td>
<td style="padding: 10px; font-size: 12px; color: #666;">
{if threadInfo.LastReplyBy}
by {threadInfo.LastReplyBy.Username}<br>
{threadInfo.LastPostTime}
{else}
No replies
{/if}
</td>
</tr> </tr>
{/for} {/for}
</tbody> </tbody>
</table> </table>
{if totalPages > 1}
<div style="margin-top: 20px; text-align: center;">
{if hasPrev}
<a href="/forum?page={currentPage - 1}"><button class="btn">Previous</button></a>
{/if}
<span style="margin: 0 10px;">Page {currentPage} of {totalPages}</span>
{if hasNext}
<a href="/forum?page={currentPage + 1}"><button class="btn">Next</button></a>
{/if}
</div>
{/if}
{else} {else}
No threads! <div style="text-align: center; padding: 40px; color: #666;">
No threads yet. <a href="/forum/new">Create the first one!</a>
</div>
{/if} {/if}
{/block} {/block}

View File

@ -0,0 +1,18 @@
{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}

View File

@ -3,15 +3,71 @@
{block "content"} {block "content"}
<div class="thread-title mb-05"> <div class="thread-title mb-05">
<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="/forum/{thread.ID}/reply"><button class="btn btn-primary">Reply</button></a>
</div> </div>
</div> </div>
<!-- Original Thread Post -->
<div style="display: flex; border: 1px solid #ddd; margin-bottom: 20px; background-color: #fafafa;">
<div style="width: 150px; padding: 15px; background-color: #f5f5f5; border-right: 1px solid #ddd;">
<div style="font-weight: bold; color: #333;">{threadAuthor.Username}</div>
<div style="font-size: 12px; color: #666; margin-top: 5px;">
Level {threadAuthor.Level}<br>
{threadAuthorClass}
</div>
</div>
<div style="flex: 1; padding: 15px;">
<div style="margin-bottom: 10px;">
{threadContent}
</div>
<div style="font-size: 11px; color: #888; border-top: 1px solid #eee; padding-top: 10px; margin-top: 10px;">
Posted: {threadPostedTime}
</div>
</div>
</div>
<p> <!-- Replies -->
{thread.Content} {if #replyInfos > 0}
</p> {for replyInfo in replyInfos}
<div style="display: flex; border: 1px solid #ddd; margin-bottom: 10px;">
<div style="width: 150px; padding: 15px; background-color: #f9f9f9; border-right: 1px solid #ddd;">
<div style="font-weight: bold; color: #333;">{replyInfo.Author.Username}</div>
<div style="font-size: 12px; color: #666; margin-top: 5px;">
Level {replyInfo.Author.Level}<br>
{replyInfo.AuthorClass}
</div>
</div>
<div style="flex: 1; padding: 15px;">
<div style="margin-bottom: 10px;">
{replyInfo.Content}
</div>
<div style="font-size: 11px; color: #888; border-top: 1px solid #eee; padding-top: 10px; margin-top: 10px;">
Posted: {replyInfo.PostedTime}
</div>
</div>
</div>
{/for}
{/if}
<!-- Pagination -->
{if totalPages > 1}
<div style="margin-top: 20px; text-align: center;">
{if hasPrev}
<a href="/forum/{thread.ID}?page={currentPage - 1}"><button class="btn">Previous</button></a>
{/if}
<span style="margin: 0 10px;">Page {currentPage} of {totalPages}</span>
{if hasNext}
<a href="/forum/{thread.ID}?page={currentPage + 1}"><button class="btn">Next</button></a>
{/if}
</div>
{/if}
<!-- Reply Button at Bottom -->
<div style="margin-top: 20px; text-align: center;">
<a href="/forum/{thread.ID}/reply"><button class="btn btn-primary">Reply to Thread</button></a>
</div>
{/block} {/block}