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.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
color: #222;
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
font-size: 16px;
}
* {
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 { 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 {
width: 90vw;
display: flex;
@ -46,6 +56,10 @@ section#game {
margin: 1rem 0;
border-top: 2px solid #000;
& > aside {
padding: 0.5rem;
}
& > aside > section:not(:last-child) {
margin-bottom: 1rem;
}
@ -58,18 +72,16 @@ section#game {
& > aside#left {
grid-column: 1;
border-right: 2px solid #000;
padding: 3px;
}
& > main {
grid-column: 2;
padding: 3px;
padding: 0.5rem;
}
& > aside#right {
grid-column: 3;
border-left: 2px solid #000;
padding: 3px;
}
}
@ -100,7 +112,6 @@ div.title {
background-color: #eeeeee;
font-weight: bold;
padding: 5px;
margin: 3px;
}
footer {
@ -202,6 +213,24 @@ form.standard {
& > 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;
}
}
}
.mb-1 {
@ -210,4 +239,28 @@ form.standard {
.mb-05 {
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
func New() *News {
return &News{
Author: 0, // No author by default
Posted: time.Now().Unix(), // Current time
Content: "", // Empty content
Author: 0, // No author by default
Posted: time.Now().Unix(), // Current time
Content: "", // Empty content
}
}
@ -229,6 +229,11 @@ func (n *News) Age() time.Duration {
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
func (n *News) IsAuthor(userID int) bool {
return n.Author == userID
@ -317,13 +322,14 @@ func (n *News) ToMap() map[string]any {
"Author": n.Author,
"Posted": n.Posted,
"Content": n.Content,
// Computed values
"PostedTime": n.PostedTime(),
"IsRecent": n.IsRecent(),
"Age": n.Age(),
"WordCount": n.WordCount(),
"Length": n.Length(),
"IsEmpty": n.IsEmpty(),
"PostedTime": n.PostedTime(),
"IsRecent": n.IsRecent(),
"Age": n.Age(),
"ReadableTime": n.ReadableTime(),
"WordCount": n.WordCount(),
"Length": n.Length(),
"IsEmpty": n.IsEmpty(),
}
}

View File

@ -18,6 +18,8 @@ func RegisterTownRoutes(r *router.Router) {
func showTown(ctx router.Ctx, _ []string) {
town := ctx.UserValue("town").(*towns.Town)
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">
<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 class="row">
<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 class="actions">

View File

@ -10,7 +10,7 @@
<label for="username">Username</label>
<span class="help">Must be 30 alphanumeric characters or less.</span>
</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 class="row">
@ -18,14 +18,14 @@
<label for="password">Password</label>
<span class="help">Passwords must be at least 6 characters.</span>
</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>
<input type="password" id="confirm_password" name="confirm_password" required>
<input class="text" type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="row">
<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 class="row">

View File

@ -5,6 +5,7 @@
<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/dk.css">
<script>

View File

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