add news component, style fixes, markdown to html parser

This commit is contained in:
Sky Johnson 2025-08-11 20:32:29 -05:00
parent 7a9f5b732f
commit 85af81a818
10 changed files with 289 additions and 34 deletions

View File

@ -4,23 +4,33 @@
for legibility and design, but keep the soul of the original project. for legibility and design, but keep the soul of the original project.
*/ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root { :root {
color: #222; color: #222;
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
font-size: 16px; font-size: 16px;
} }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: inherit;
font-size: 1rem;
}
body { body {
padding: 1rem; padding: 1rem;
background-image: url("/assets/images/backgrounds/snowstorm.jpg"); background-image: url("/assets/images/backgrounds/snowstorm.jpg");
} }
i { font-style: italic; }
b { font-weight: bold; }
h1 { font-size: 2rem; font-weight: bold; }
h2 { font-size: 1.7rem; font-weight: bold; }
h3 { font-size: 1.4rem; font-weight: bold; }
h4 { font-size: 1.1rem; font-weight: bold; }
div#container { div#container {
width: 90vw; width: 90vw;
display: flex; display: flex;
@ -46,6 +56,10 @@ section#game {
margin: 1rem 0; margin: 1rem 0;
border-top: 2px solid #000; border-top: 2px solid #000;
& > aside {
padding: 0.5rem;
}
& > aside > section:not(:last-child) { & > aside > section:not(:last-child) {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -58,18 +72,16 @@ section#game {
& > aside#left { & > aside#left {
grid-column: 1; grid-column: 1;
border-right: 2px solid #000; border-right: 2px solid #000;
padding: 3px;
} }
& > main { & > main {
grid-column: 2; grid-column: 2;
padding: 3px; padding: 0.5rem;
} }
& > aside#right { & > aside#right {
grid-column: 3; grid-column: 3;
border-left: 2px solid #000; border-left: 2px solid #000;
padding: 3px;
} }
} }
@ -100,7 +112,6 @@ div.title {
background-color: #eeeeee; background-color: #eeeeee;
font-weight: bold; font-weight: bold;
padding: 5px; padding: 5px;
margin: 3px;
} }
footer { footer {
@ -202,6 +213,24 @@ form.standard {
& > div.actions { & > div.actions {
margin-top: 1rem; 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;
}
}
} }
.mb-1 { .mb-1 {
@ -210,4 +239,28 @@ form.standard {
.mb-05 { .mb-05 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
}
div.town {
& > div:not(:last-child) {
margin-bottom: 2rem;
}
& > div.split {
width: 100%;
display: flex;
gap: 1rem;
& > div {
width: 100%;
}
}
}
button.img-button {
appearance: none;
border: none;
outline: none;
background: none;
cursor: pointer;
} }

48
assets/reset.css Normal file
View File

@ -0,0 +1,48 @@
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View File

@ -0,0 +1,117 @@
package markdown
import (
"html"
"strings"
)
// MarkdownToHTML converts a basic subset of markdown to HTML, for
// use in the news posts, forums, or user blurbs.
func MarkdownToHTML(s string) string {
// Escape HTML entities first to sanitize user input
s = html.EscapeString(s)
// Handle headers first (line-based)
lines := strings.Split(s, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "### ") {
lines[i] = "<h3>" + line[4:] + "</h3>"
} else if strings.HasPrefix(line, "## ") {
lines[i] = "<h2>" + line[3:] + "</h2>"
} else if strings.HasPrefix(line, "# ") {
lines[i] = "<h1>" + line[2:] + "</h1>"
}
}
s = strings.Join(lines, "\n")
// Handle code spans before other formatting
s = replaceCodeSpans(s)
// Handle links [text](url)
s = replaceLinks(s)
// Handle bold and italic
s = replaceBoldItalic(s)
// Handle line breaks last
s = strings.ReplaceAll(s, "\n", "<br>")
return `<div class="md-lib">` + s + "</div>"
}
func replaceCodeSpans(s string) string {
for {
start := strings.Index(s, "`")
if start == -1 {
break
}
end := strings.Index(s[start+1:], "`")
if end == -1 {
break
}
end += start + 1
code := s[start+1 : end]
s = s[:start] + "<code>" + code + "</code>" + s[end+1:]
}
return s
}
func replaceLinks(s string) string {
for {
linkStart := strings.Index(s, "[")
if linkStart == -1 {
break
}
linkEnd := strings.Index(s[linkStart:], "](")
if linkEnd == -1 {
break
}
linkEnd += linkStart
urlStart := linkEnd + 2
urlEnd := strings.Index(s[urlStart:], ")")
if urlEnd == -1 {
break
}
urlEnd += urlStart
text := s[linkStart+1 : linkEnd]
url := s[urlStart:urlEnd]
s = s[:linkStart] + `<a href="` + url + `">` + text + "</a>" + s[urlEnd+1:]
}
return s
}
func replaceBoldItalic(s string) string {
// Handle bold first
for strings.Contains(s, "**") {
first := strings.Index(s, "**")
if first == -1 {
break
}
second := strings.Index(s[first+2:], "**")
if second == -1 {
break
}
second += first + 2
text := s[first+2 : second]
s = s[:first] + "<b>" + text + "</b>" + s[second+2:]
}
// Handle italic
for strings.Contains(s, "*") {
first := strings.Index(s, "*")
if first == -1 {
break
}
second := strings.Index(s[first+1:], "*")
if second == -1 {
break
}
second += first + 1
text := s[first+1 : second]
s = s[:first] + "<i>" + text + "</i>" + s[second+1:]
}
return s
}

View File

@ -21,9 +21,9 @@ type News struct {
// New creates a new News with sensible defaults // New creates a new News with sensible defaults
func New() *News { func New() *News {
return &News{ return &News{
Author: 0, // No author by default Author: 0, // No author by default
Posted: time.Now().Unix(), // Current time Posted: time.Now().Unix(), // Current time
Content: "", // Empty content Content: "", // Empty content
} }
} }
@ -229,6 +229,11 @@ func (n *News) Age() time.Duration {
return time.Since(n.PostedTime()) return time.Since(n.PostedTime())
} }
// ReadableTime converts a time.Time to a human-readable date string
func (n *News) ReadableTime() string {
return n.PostedTime().Format("Jan 2, 2006 3:04 PM")
}
// IsAuthor returns true if the given user ID is the author of this news post // IsAuthor returns true if the given user ID is the author of this news post
func (n *News) IsAuthor(userID int) bool { func (n *News) IsAuthor(userID int) bool {
return n.Author == userID return n.Author == userID
@ -317,13 +322,14 @@ func (n *News) ToMap() map[string]any {
"Author": n.Author, "Author": n.Author,
"Posted": n.Posted, "Posted": n.Posted,
"Content": n.Content, "Content": n.Content,
// Computed values // Computed values
"PostedTime": n.PostedTime(), "PostedTime": n.PostedTime(),
"IsRecent": n.IsRecent(), "IsRecent": n.IsRecent(),
"Age": n.Age(), "Age": n.Age(),
"WordCount": n.WordCount(), "ReadableTime": n.ReadableTime(),
"Length": n.Length(), "WordCount": n.WordCount(),
"IsEmpty": n.IsEmpty(), "Length": n.Length(),
"IsEmpty": n.IsEmpty(),
} }
} }

View File

@ -18,6 +18,8 @@ func RegisterTownRoutes(r *router.Router) {
func showTown(ctx router.Ctx, _ []string) { func showTown(ctx router.Ctx, _ []string) {
town := ctx.UserValue("town").(*towns.Town) town := ctx.UserValue("town").(*towns.Town)
components.RenderPageTemplate(ctx, town.Name, "town/town.html", map[string]any{ components.RenderPageTemplate(ctx, town.Name, "town/town.html", map[string]any{
"town": town, "town": town,
"newscontent": components.GenerateTownNews(),
"whosonline": components.GenerateTownWhosOnline(),
}) })
} }

View File

@ -0,0 +1,26 @@
package components
import (
"dk/internal/helpers/markdown"
"dk/internal/news"
"fmt"
)
func GenerateTownNews() string {
title := `<div class="title">Latest News</div>`
news, err := news.Recent(1)
if err == nil && len(news) > 0 {
item := news[0]
content := fmt.Sprintf(`<span class="light">%s</span>`, item.ReadableTime())
return title + content + fmt.Sprintf(`<div>%s</div>`, markdown.MarkdownToHTML(item.Content))
}
return title + "<div>No news!</div>"
}
func GenerateTownWhosOnline() string {
title := `<div class="title">Who's Online</div>`
return title + "<div>No one!</div>"
}

View File

@ -7,12 +7,12 @@
<div class="row"> <div class="row">
<label for="id">Email/Username</label> <label for="id">Email/Username</label>
<input id="id" type="text" name="id" value="{id}" required> <input class="text" id="id" type="text" name="id" value="{id}" required>
</div> </div>
<div class="row"> <div class="row">
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" type="password" name="password" required> <input class="text" id="password" type="password" name="password" required>
</div> </div>
<div class="actions"> <div class="actions">

View File

@ -10,7 +10,7 @@
<label for="username">Username</label> <label for="username">Username</label>
<span class="help">Must be 30 alphanumeric characters or less.</span> <span class="help">Must be 30 alphanumeric characters or less.</span>
</div> </div>
<input type="text" id="username" name="username" maxlength="30" value="{username}" required> <input class="text" type="text" id="username" name="username" maxlength="30" value="{username}" required>
</div> </div>
<div class="row"> <div class="row">
@ -18,14 +18,14 @@
<label for="password">Password</label> <label for="password">Password</label>
<span class="help">Passwords must be at least 6 characters.</span> <span class="help">Passwords must be at least 6 characters.</span>
</div> </div>
<input type="password" id="password" name="password" required class="mb-05"> <input class="text" type="password" id="password" name="password" required class="mb-05">
<label for="confirm_password">Verify Password</label> <label for="confirm_password">Verify Password</label>
<input type="password" id="confirm_password" name="confirm_password" required> <input class="text" type="password" id="confirm_password" name="confirm_password" required>
</div> </div>
<div class="row"> <div class="row">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email" maxlength="100" value="{email}" required> <input class="text" type="email" id="email" name="email" maxlength="100" value="{email}" required>
</div> </div>
<div class="row"> <div class="row">

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title> <title>{title}</title>
<link rel="stylesheet" href="/assets/reset.css">
<link rel="stylesheet" href="/assets/dk.css"> <link rel="stylesheet" href="/assets/dk.css">
<script> <script>

View File

@ -10,15 +10,17 @@
</div> </div>
<div class="news"> <div class="news">
{news} {newscontent}
</div> </div>
<div class="whos-online"> <div class="split">
{whosonline} <div class="whos-online">
</div> {whosonline}
</div>
<div class="babblebox"> <div class="babblebox">
<div class="title">Babblebox</div> <div class="title">Babblebox</div>
@TODO @TODO
</div>
</div> </div>
</div> </div>