largely finish forum implementation

This commit is contained in:
Sky Johnson 2025-08-24 21:28:31 -05:00
parent ddc5bd5f6e
commit e3146068bc
8 changed files with 521 additions and 301 deletions

View File

@ -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);
@ -531,3 +532,48 @@ div.thread-title {
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;
}

Binary file not shown.

View 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
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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}

View File

@ -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}

View File

@ -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}