build up forum features, work in method/func calls in template engine
This commit is contained in:
parent
340d4cf6e8
commit
b5e3413c63
BIN
data/dk.db
BIN
data/dk.db
Binary file not shown.
@ -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))
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -3,25 +3,64 @@
|
||||
{block "content"}
|
||||
<div class="thread-title mb-05">
|
||||
<h2>Forum</h2>
|
||||
|
||||
<div>
|
||||
<a href="/forum/new"><button class="btn btn-primary">New Thread</button></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{if #threads > 0}
|
||||
<table>
|
||||
{if #threadInfos > 0}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<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>
|
||||
{for thread in threads}
|
||||
<tr>
|
||||
<td><a href="/forum/{thread.ID}">{thread.Title}</a></td>
|
||||
<td>{thread.Replies} Replies</td>
|
||||
{for threadInfo in threadInfos}
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px;">
|
||||
<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>
|
||||
{/for}
|
||||
</tbody>
|
||||
</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}
|
||||
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}
|
||||
{/block}
|
||||
|
18
templates/forum/reply.html
Normal file
18
templates/forum/reply.html
Normal 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}
|
@ -3,15 +3,71 @@
|
||||
{block "content"}
|
||||
<div class="thread-title mb-05">
|
||||
<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>
|
||||
</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>
|
||||
{thread.Content}
|
||||
</p>
|
||||
<!-- Replies -->
|
||||
{if #replyInfos > 0}
|
||||
{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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user