begin admin panel, create news

This commit is contained in:
Sky Johnson 2025-08-27 17:11:47 -05:00
parent af0ba28c58
commit c7c76b413c
15 changed files with 476 additions and 32 deletions

230
assets/admin.css Normal file
View File

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

View File

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

Binary file not shown.

View File

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

View File

@ -9,12 +9,12 @@ import (
)
func GenerateTownNews() string {
title := `<h4 class="mb-025">Latest News</h4>`
title := `<div class="mb-1"><h4 class="mb-025">Latest News</h4>`
news, err := news.Recent(1)
if err == nil && len(news) > 0 {
item := news[0]
content := fmt.Sprintf(`<span class="light">%s</span>`, item.ReadableTime())
content := fmt.Sprintf(`<span class="light">%s</span></div>`, item.ReadableTime())
return title + content + fmt.Sprintf(`<div>%s</div>`, markdown.MarkdownToHTML(item.Content))
}

83
internal/routes/admin.go Normal file
View File

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

View File

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

View File

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

24
templates/admin/home.html Normal file
View File

@ -0,0 +1,24 @@
{include "admin/layout.html"}
{block "content"}
<h1>Admininistration</h1>
<p>
Welcome to the admin panel! Utilize the tools to the left, or <a href="/">head back</a>. Below
are the server stats as of loading this page.
</p>
<table>
<tr><td>Memory Allocated</td> <td>{alloc_mb} MB</td></tr>
<tr><td>Total Allocated</td> <td>{total_alloc_mb} MB</td></tr>
<tr><td>System Memory</td> <td>{sys_mb} MB</td></tr>
<tr><td>Heap Allocated</td> <td>{heap_alloc_mb} MB</td></tr>
<tr><td>Heap System</td> <td>{heap_sys_mb} MB</td></tr>
<tr><td>Heap Released</td> <td>{heap_released_mb} MB</td></tr>
<tr><td>GC Cycles</td> <td>{gc_cycles}</td></tr>
<tr><td>GC Pause Total</td> <td>{gc_pause_total} ms</td></tr>
<tr><td>Goroutines</td> <td>{goroutines}</td></tr>
<tr><td>CPU Cores</td> <td>{cpu_cores}</td></tr>
<tr><td>Go Version</td> <td>{go_version}</td></tr>
</table>
{/block}

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_title}</title>
<link rel="stylesheet" href="/assets/reset.css">
<link rel="stylesheet" href="/assets/admin.css">
</head>
<body>
<div id="container">
<nav>
<section>
<a href="/admin">Admin Home</a>
<a href="/">Game Home</a>
</section>
<section>
<a href="/admin/control">Main Settings</a>
<a href="/admin/news">Add News Post</a>
<a href="/admin/users">Edit Users</a>
</section>
<section>
<a href="/admin/items">Edit Items</a>
<a href="/admin/towns">Edit Towns</a>
<a href="/admin/monsters">Edit Monsters</a>
<a href="/admin/spells">Edit Spells</a>
</section>
<footer>
<div>{_totaltime} Seconds, {_memalloc} MiB</div>
<div>Version {_version} {_build}</div>
</footer>
</nav>
<main>
{include "flashes.html"}
{yield "content"}
</main>
</div>
</body>
</html>

20
templates/admin/news.html Normal file
View File

@ -0,0 +1,20 @@
{include "admin/layout.html"}
{block "content"}
<h1>Add News Post</h1>
<p>
Write a new news post that will be displayed in every town! You can use basic markdown to style the post.
</p>
<form class="standard" method="post">
{csrf}
<div>
<textarea name="content" id="content" class="text w-full mb-1" required placholder="Write the news here!"></textarea>
</div>
<div>
<a href="/admin"><button type="button" class="btn">Cancel</button></a>
<button type="submit" class="btn btn-primary">Create Post</button>
</div>
</form>
{/block}

View File

@ -8,7 +8,7 @@
<div class="mb-1">
<div class="mb-025"><input type="text" class="text w-full" placeholder="Thread title" name="title"></div>
<textarea class="forum-text w-full" placeholder="Type your message here!" name="content"></textarea>
<textarea class="text w-full" placeholder="Type your message here!" name="content"></textarea>
</div>
<div>

View File

@ -51,9 +51,9 @@
{if hasPrev}
<a href="/forum/{thread.ID}?page={currentPage - 1}"><button class="btn">Previous</button></a>
{/if}
<span>Page {currentPage} of {totalPages}</span>
{if hasNext}
<a href="/forum/{thread.ID}?page={currentPage + 1}"><button class="btn">Next</button></a>
{/if}
@ -66,7 +66,7 @@
{csrf}
<div class="mb-1">
<textarea class="forum-text w-full" placeholder="Type your reply here!" name="content" rows="10"></textarea>
<textarea class="text w-full" placeholder="Type your reply here!" name="content"></textarea>
</div>
<div>
@ -74,4 +74,4 @@
</div>
</form>
</div>
{/block}
{/block}

View File

@ -50,6 +50,9 @@
<a href="/change-password">Change Password</a>
<a class="logout-link">Log Out</a>
<a href="/help">Help</a>
{if user.Auth >= 4}
<a href="/admin">Admin</a>
{/if}
</section>
<form id="logout-form" class="hidden" action="/logout" method="post">

View File

@ -11,10 +11,14 @@
</ul>
</section>
<hr>
<section class="news">
{newscontent}
</section>
<hr>
<section class="split">
<section class="whos-online">
{whosonline}