diff --git a/assets/admin.css b/assets/admin.css new file mode 100644 index 0000000..95069f7 --- /dev/null +++ b/assets/admin.css @@ -0,0 +1,230 @@ +@font-face { + font-family: 'Seagram'; + src: url('/assets/fonts/seagram.ttf') format('truetype'); + font-display: swap; +} + +:root { + color: #222; + font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; + font-size: 16px; +} + +.seagram { + font-family: 'Seagram', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: inherit; + font-size: 1rem; +} + +body { + padding: 1rem; + background-image: url("/assets/images/backgrounds/snowstorm.jpg"); +} + +i { font-style: italic; } +b { font-weight: bold; } + +h1, h2, h3, h4, h5 { + font-family: 'Seagram', Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; + margin-bottom: 1rem; +} +h1 { font-size: 2rem; } +h2 { font-size: 1.7rem; } +h3 { font-size: 1.4rem; } +h4 { font-size: 1.1rem; } + +p { + margin-bottom: 1rem; + &:last-child { + margin-bottom: 0; + } +} + +div#container { + max-width: 1024px; + display: grid; + grid-template-columns: 180px 1fr; + margin: 0px auto; + gap: 1rem; + + & > footer { + grid-column: 1 / -1 + } +} + +nav { + border-right: 2px solid black; + padding-bottom: 1rem; + + & > section { + display: flex; + flex-direction: column; + padding-bottom: 1rem; + margin-bottom: 1rem; + border-bottom: 2px solid black; + } +} + +table { + border-collapse: collapse; + + th, td { + padding: 0.5rem; + text-align: left; + border: 1px solid rgba(0, 0, 0, 0.2); + } + + th { + background-color: rgba(0, 0, 0, 0.1); + } + + tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); + } +} + +a { + color: #663300; + text-decoration: none; + font-weight: bold; + cursor: pointer; + + &:hover { + color: #330000; + } +} + +.small { + font-size: 0.75rem; +} + +.highlight { + color: red; +} + +.light { + color: rgba(0, 0, 0, 0.35); +} + +button.btn { + font-family: inherit; + font-size: 1rem; + appearance: none; + outline: none; + background-color: #808080; + background-image: url("/assets/images/overlay.png"); + border: 1px solid #808080; + padding: 0.25rem 0.5rem; + cursor: pointer; + color: white; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.2); + text-shadow: 0px 1px 1px rgba(0, 0, 0, 0.1); + + &:hover { + background-color: #909090; + } + + &.btn-primary { + color: rgba(0, 0, 0, 0.75); + background-color: #F2994A; + background-image: url("/assets/images/overlay.png"), linear-gradient(to bottom, #F2C94C, #F2994A); + border-color: #F2994A; + + &:hover { + background-color: #ffb574; + background-image: url("/assets/images/overlay.png"), linear-gradient(to bottom, #ffd965, #ffb574); + } + } + + &.btn-blue { + background-color: #00c6ff; + background-image: url("/assets/images/overlay.png"), linear-gradient(to bottom, #00c6ff, #0072ff); + border-color: #0072ff; + + &:hover { + background-color: #49d6fd; + background-image: url("/assets/images/overlay.png"), linear-gradient(to bottom, #49d6fd, #2987fa); + } + } +} + +form.standard { + & > div.row { + display: flex; + flex-direction: column; + margin-bottom: 1.5rem; + + label { + font-weight: bold; + } + } + + span.help { + font-size: 0.8em; + color: #555; + } + + & > div.actions { + margin-top: 1rem; + } + + input.text { + appearance: none; + outline: none; + border: 1px solid transparent; + padding: 0.25rem 0.25rem; + background-color: rgba(0, 0, 0, 0.4); + color: white; + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + } + + &:focus { + background-color: rgba(0, 0, 0, 0.6); + border-color: black; + } + + &::placeholder { + color: rgba(255, 255, 255, 0.6); + } + } + + textarea.text { + appearance: none; + outline: none; + border: 1px solid transparent; + padding: 0.25rem 0.25rem; + background-color: rgba(0, 0, 0, 0.4); + color: white; + min-height: 100px; + resize: vertical; + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + } + + &:focus { + background-color: rgba(0, 0, 0, 0.6); + border-color: black; + } + + &::placeholder { + color: rgba(255, 255, 255, 0.6); + } + } +} + +.w-full { + width: 100%; +} + +.mb-1 { + margin-bottom: 1rem; +} diff --git a/assets/dk.css b/assets/dk.css index cbdb308..a649848 100644 --- a/assets/dk.css +++ b/assets/dk.css @@ -1,9 +1,3 @@ -/* - In general, we don't want to stray too far from the original appearance. - We'll optimize the layout for modern CSS and use better styles and fonts - for legibility and design, but keep the soul of the original project. -*/ - @font-face { font-family: 'Seagram'; src: url('/assets/fonts/seagram.ttf') format('truetype'); @@ -279,7 +273,7 @@ form.standard { } } - textarea.forum-text { + textarea.text { appearance: none; outline: none; border: 1px solid transparent; @@ -321,8 +315,8 @@ form.standard { } div.town { - & > section:not(:last-child) { - margin-bottom: 2rem; + hr { + margin: 2rem 0; } & > section.split { diff --git a/data/dk.db b/data/dk.db index c8e41cd..f795a41 100644 Binary files a/data/dk.db and b/data/dk.db differ diff --git a/internal/components/page.go b/internal/components/page.go index 0ce4d46..c52ac15 100644 --- a/internal/components/page.go +++ b/internal/components/page.go @@ -6,6 +6,7 @@ import ( "runtime" "strings" + "dk/internal/models/users" "dk/internal/template" sushi "git.sharkk.net/Sharkk/Sushi" @@ -44,7 +45,7 @@ func RenderPage(ctx sushi.Ctx, title, tmplPath string, additionalData map[string "_totaltime": totalTime, "_version": "1.0.0", "_build": "dev", - "user": ctx.GetCurrentUser(), + "user": ctx.GetCurrentUser().(*users.User), "_memalloc": m.Alloc / 1024 / 1024, "_errormsg": sess.GetFlashMessage("error"), "_successmsg": sess.GetFlashMessage("success"), @@ -71,3 +72,44 @@ func PageTitle(title string) string { return title + " - Dragon Knight" } + +// RenderAdminPage renders a page using the admin layout template with common data and additional custom data +func RenderAdminPage(ctx sushi.Ctx, title, tmplPath string, additionalData map[string]any) error { + if template.Cache == nil { + return fmt.Errorf("template.Cache not initialized") + } + + tmpl, err := template.Cache.Load(tmplPath) + if err != nil { + return fmt.Errorf("failed to load layout template: %w", err) + } + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + sess := ctx.GetCurrentSession() + + seconds := timing.GetRequestDuration(ctx).Seconds() + var totalTime string + if seconds < 0.001 { + totalTime = fmt.Sprintf("%.0f", seconds) + } else { + totalTime = fmt.Sprintf("%.3f", seconds) + } + + data := map[string]any{ + "_title": PageTitle(title), + "csrf": csrf.HiddenField(ctx), + "_totaltime": totalTime, + "_version": "1.0.0", + "_build": "dev", + "user": ctx.GetCurrentUser().(*users.User), + "_memalloc": m.Alloc / 1024 / 1024, + "_errormsg": sess.GetFlashMessage("error"), + "_successmsg": sess.GetFlashMessage("success"), + } + + maps.Copy(data, additionalData) + + return tmpl.WriteTo(ctx, data) +} diff --git a/internal/components/town.go b/internal/components/town.go index 5e023a5..444ce4b 100644 --- a/internal/components/town.go +++ b/internal/components/town.go @@ -9,12 +9,12 @@ import ( ) func GenerateTownNews() string { - title := `

Latest News

` + title := `

Latest News

` news, err := news.Recent(1) if err == nil && len(news) > 0 { item := news[0] - content := fmt.Sprintf(`%s`, item.ReadableTime()) + content := fmt.Sprintf(`%s
`, item.ReadableTime()) return title + content + fmt.Sprintf(`
%s
`, markdown.MarkdownToHTML(item.Content)) } diff --git a/internal/routes/admin.go b/internal/routes/admin.go new file mode 100644 index 0000000..6225a38 --- /dev/null +++ b/internal/routes/admin.go @@ -0,0 +1,83 @@ +package routes + +import ( + "dk/internal/components" + "dk/internal/models/news" + "dk/internal/models/users" + "runtime" + "strings" + "time" + + sushi "git.sharkk.net/Sharkk/Sushi" + "git.sharkk.net/Sharkk/Sushi/auth" +) + +func RegisterAdminRoutes(app *sushi.App) { + group := app.Group("/admin") + group.Use(auth.RequireAuth()) + group.Use(func(ctx sushi.Ctx, next func()) { + if ctx.GetCurrentUser().(*users.User).Auth < 4 { + ctx.Redirect("/") + return + } + next() + }) + + group.Get("/", adminIndex) + group.Get("/news", adminNewsForm) + group.Post("/news", adminNewsCreate) +} + +func adminIndex(ctx sushi.Ctx) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + components.RenderAdminPage(ctx, "", "admin/home.html", map[string]any{ + "alloc_mb": bToMb(m.Alloc), + "total_alloc_mb": bToMb(m.TotalAlloc), + "sys_mb": bToMb(m.Sys), + "heap_alloc_mb": bToMb(m.HeapAlloc), + "heap_sys_mb": bToMb(m.HeapSys), + "heap_released_mb": bToMb(m.HeapReleased), + "gc_cycles": m.NumGC, + "gc_pause_total": m.PauseTotalNs / 1000000, // ms + "goroutines": runtime.NumGoroutine(), + "cpu_cores": runtime.NumCPU(), + "go_version": runtime.Version(), + }) +} + +func adminNewsForm(ctx sushi.Ctx) { + components.RenderAdminPage(ctx, "", "admin/news.html", map[string]any{}) +} + +func adminNewsCreate(ctx sushi.Ctx) { + sess := ctx.GetCurrentSession() + content := strings.TrimSpace(ctx.Form("content").String()) + + if content == "" { + sess.SetFlash("error", "Content cannot be empty") + ctx.Redirect("/admin/news") + return + } + + user := ctx.GetCurrentUser().(*users.User) + newsPost := &news.News{ + Author: user.ID, + Content: content, + Posted: time.Now().Unix(), + } + + if err := newsPost.Insert(); err != nil { + sess.SetFlash("error", "Failed to create news post") + ctx.Redirect("/admin/news") + return + } + + sess.SetFlash("success", "News post created successfully") + ctx.Redirect("/admin") +} + +func bToMb(b uint64) uint64 { + return b / 1024 / 1024 +} diff --git a/internal/routes/forum.go b/internal/routes/forum.go index 946374a..65c3e13 100644 --- a/internal/routes/forum.go +++ b/internal/routes/forum.go @@ -60,9 +60,9 @@ func (r *replyData) PostedTime() time.Time { func RegisterForumRoutes(app *sushi.App) { authed := app.Group("/forum") authed.Use(auth.RequireAuth()) - authed.Get("/", index) + authed.Get("/", forumIndex) authed.Get("/new", showNew) - authed.Post("/new", new) + authed.Post("/new", newThread) authed.Get("/:id", showThread) authed.Post("/:id/reply", reply) } @@ -95,21 +95,21 @@ func getPagination(ctx sushi.Ctx, perPage int) helpers.Pagination { } } -func index(ctx sushi.Ctx) { +func forumIndex(ctx sushi.Ctx) { pagination := getPagination(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.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 + 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()) @@ -129,10 +129,10 @@ func index(ctx sushi.Ctx) { 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 + 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 { @@ -163,7 +163,7 @@ func showNew(ctx sushi.Ctx) { components.RenderPage(ctx, "New Forum Thread", "forum/new.html", map[string]any{}) } -func new(ctx sushi.Ctx) { +func newThread(ctx sushi.Ctx) { sess := ctx.GetCurrentSession() title := strings.TrimSpace(ctx.Form("title").String()) @@ -212,7 +212,7 @@ func showThread(ctx sushi.Ctx) { 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.username, '[Deleted]') as author_username, COALESCE(u.level, 1) as author_level, COALESCE(c.name, 'Unknown') as author_class FROM forum f @@ -232,14 +232,14 @@ func showThread(ctx sushi.Ctx) { 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.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 + WHERE f.parent = %d + ORDER BY f.posted ASC LIMIT %d OFFSET %d`, id, pagination.PerPage, pagination.Offset()) if err != nil { diff --git a/main.go b/main.go index f63ec8f..38bee02 100644 --- a/main.go +++ b/main.go @@ -188,6 +188,7 @@ func start(port string) error { routes.RegisterFightRoutes(app) routes.RegisterForumRoutes(app) routes.RegisterHelpRoutes(app) + routes.RegisterAdminRoutes(app) app.Get("/assets/*path", sushi.Static(cwd)) diff --git a/templates/admin/home.html b/templates/admin/home.html new file mode 100644 index 0000000..cc31210 --- /dev/null +++ b/templates/admin/home.html @@ -0,0 +1,24 @@ +{include "admin/layout.html"} + +{block "content"} +

Admininistration

+ +

+ Welcome to the admin panel! Utilize the tools to the left, or head back. Below + are the server stats as of loading this page. +

+ + + + + + + + + + + + + +
Memory Allocated {alloc_mb} MB
Total Allocated {total_alloc_mb} MB
System Memory {sys_mb} MB
Heap Allocated {heap_alloc_mb} MB
Heap System {heap_sys_mb} MB
Heap Released {heap_released_mb} MB
GC Cycles {gc_cycles}
GC Pause Total {gc_pause_total} ms
Goroutines {goroutines}
CPU Cores {cpu_cores}
Go Version {go_version}
+{/block} diff --git a/templates/admin/layout.html b/templates/admin/layout.html new file mode 100644 index 0000000..9306d1b --- /dev/null +++ b/templates/admin/layout.html @@ -0,0 +1,43 @@ + + + + + + {_title} + + + + + +
+ +
+ {include "flashes.html"} + {yield "content"} +
+
+ + diff --git a/templates/admin/news.html b/templates/admin/news.html new file mode 100644 index 0000000..9d8be52 --- /dev/null +++ b/templates/admin/news.html @@ -0,0 +1,20 @@ +{include "admin/layout.html"} + +{block "content"} +

Add News Post

+ +

+ Write a new news post that will be displayed in every town! You can use basic markdown to style the post. +

+ +
+ {csrf} +
+ +
+
+ + +
+
+{/block} diff --git a/templates/forum/new.html b/templates/forum/new.html index 10907d7..c308eac 100644 --- a/templates/forum/new.html +++ b/templates/forum/new.html @@ -8,7 +8,7 @@
- +
diff --git a/templates/forum/thread.html b/templates/forum/thread.html index db60a2b..6f1c64c 100644 --- a/templates/forum/thread.html +++ b/templates/forum/thread.html @@ -51,9 +51,9 @@ {if hasPrev} {/if} - + Page {currentPage} of {totalPages} - + {if hasNext} {/if} @@ -66,7 +66,7 @@ {csrf}
- +
@@ -74,4 +74,4 @@
-{/block} \ No newline at end of file +{/block} diff --git a/templates/leftside.html b/templates/leftside.html index de1871c..3d57aa1 100644 --- a/templates/leftside.html +++ b/templates/leftside.html @@ -50,6 +50,9 @@ Change Password Log Out Help + {if user.Auth >= 4} + Admin + {/if}