move to gomponents for rendering html

This commit is contained in:
2025-03-26 15:12:07 +01:00
parent b7b4e74eff
commit f312489aa6
17 changed files with 309 additions and 174 deletions

View File

@@ -1,24 +0,0 @@
{{ define "content" }}
<div class="content max-w-screen-lg flex flex-col mx-auto">
{{ range .ArticleVMs }}
<div tabindex="0" class="collapse bg-base-200 shadow mb-4">
<div class="collapse-title font-medium">{{ .Title }}</div>
<div class="collapse-content">
<p class="pb-2">
<span class="badge badge-outline">{{ .ShortSource }}</span>
<span class="badge badge-outline">{{ .PublishDate }}</span>
</p>
<p class="card-text">{{ .Summary }}</p>
<div class="flex flex-row-reverse">
<a href="/article/{{ .Id }}" class="btn btn-active btn-sm btn-primary">Details</a>
</div>
</div>
</div>
{{ end }}
{{ template "pagination" .Paginations }}
</div>
{{ end }}

View File

@@ -1,27 +0,0 @@
{{ define "content" }}
<div class="content max-w-screen-lg flex flex-col mx-auto">
<div tabindex="0" class="card bg-base-200 shadow mb-4">
<div class="card-body">
<div class="flex flex-row pb-4">
<div class="divider divider-horizontal divider-primary"></div>
<div class="card-title font-medium">{{ .ArticlePageVM.Title }}</div>
</div>
<div class="px-5 pb-4 grid gap-y-4 grid-cols-1">
<p><span class="badge badge-neutral me-4 w-20">Datum</span>{{ .ArticlePageVM.PublishDate }}</p>
<p><span class="badge badge-neutral me-4 w-20">Quelle</span>{{ .ArticlePageVM.ShortSource }}</p>
<p><span class="badge badge-neutral me-4 w-20">TLDR</span>{{ .ArticlePageVM.Summary }}</p>
<p><span class="badge badge-neutral me-4 w-20">Inhalt</span>{{ .ArticlePageVM.Content }}</p>
<div class="card-actions justify-end">
<a href="{{ .ArticlePageVM.SourceUrl }}">
<button class="btn btn-primary btn-sm">Seite besuchen</button>
</a>
</div>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -1,10 +0,0 @@
{{ define "pagination" }}
<div class="join pagination p-5 mx-auto">
{{ range . }}
<a class="join-item btn btn-sm {{ if .Active }}btn-active{{ end }} {{ if .Disabled }}btn-disabled{{end}}" up-follow up-target=".content"
{{ if .Disabled }}{{else}}href="/page/{{ .Content }}" tabindex="0"{{end}}>{{ .Content }}</a>
{{ end }}
</div>
{{ end }}

View File

@@ -1,69 +0,0 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
{{/* Unpoly */}}
<link rel="stylesheet" type="text/css" href="/static/unpoly.min.css" />
<script src="/static/unpoly.min.js"></script>
{{/* DasyUi */}}
<link rel="stylesheet" type="text/css" href="/static/daisyui.min.css" />
<script src="/static/tailwindcss.min.js"></script>
</head>
<body>
<nav class="fixed top-0 z-50 w-full p-4">
<div class="navbar bg-base-300 rounded-box drop-shadow-md">
{{/* Logo with navigation */}}
<div class="flex-1">
<a href="/" tabindex="0" class="btn btn-ghost text-xl" up-follow up-target=".body">crowsnest</a>
<ul class="menu menu-horizontal hidden sm:flex">
<li><a href="/" tabindex="0" {{ if .SelectedNavItemArticle }} class="active" {{ end }}>Artikel</a></li>
<li><a tabindex="0">Themen</a></li>
</ul>
</div>
{{/* Search field for normal sized screen */}}
<div class="hidden sm:flex flex-none pe-4">
<form role="search" method="post" action="/up/search" up-submit up-autosubmit up-target=".content">
<label class="input input-bordered input-sm flex items-center gap-2">
<input name="search" type="search" class="grow" placeholder="Suche" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"
class="h-4 w-4 opacity-70">
<path fill-rule="evenodd" d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z" clip-rule="evenodd" />
</svg>
</label>
</form>
</div>
{{/* Dropdown for small screens */}}
<div class="dropdown dropdown-end sm:hidden">
<div tabindex="0" role="button" class="btn btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
class="h-6 w-6 opacity-70">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M12 17.25h8.25" />
</svg>
</div>
<ul class="menu dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow">
<li><a href="/" tabindex="0" class="active">Artikel</a></li>
<li><a tabindex="0">Themen</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mx-auto px-4 mt-28">
{{ template "content" . }}
</div>
<script src="https://cdn.tailwindcss.com"></script>
<script>
up.link.config.followSelectors.push('a[href]')
up.link.config.instantSelectors.push('a[href]')
</script>
</body>
</html>
{{ end }}

View File

@@ -79,4 +79,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorgonia.org/vecf32 v0.9.0 // indirect gorgonia.org/vecf32 v0.9.0 // indirect
gorgonia.org/vecf64 v0.9.0 // indirect gorgonia.org/vecf64 v0.9.0 // indirect
maragu.dev/gomponents v1.1.0 // indirect
maragu.dev/gomponents-heroicons/v3 v3.0.0 // indirect
) )

View File

@@ -447,5 +447,9 @@ gorgonia.org/vecf64 v0.9.0 h1:bgZDP5x0OzBF64PjMGC3EvTdOoMEcmfAh1VCUnZFm1A=
gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
maragu.dev/gomponents-heroicons/v3 v3.0.0 h1:QBw4CSST12mrdcYzl1XrEnbMxfhvQgnVunhFgQ4RPyI=
maragu.dev/gomponents-heroicons/v3 v3.0.0/go.mod h1:Rqc5BhSQUHBnGuWEPihg+IsQnOkiBY+Ibu1DDGEarsY=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -1,7 +1,7 @@
package app package app
import ( import (
"html/template" "crowsnest/internal/html"
"net/http" "net/http"
"strconv" "strconv"
) )
@@ -24,17 +24,8 @@ func (app *App) Article(w http.ResponseWriter, req *http.Request) {
return return
} }
// render template // render page
t := template.Must(template.ParseFiles( err = html.ArticleLayout(articlePageVM).Render(w)
"assets/templates/articlePage.html",
"assets/templates/layout.html",
))
data := map[string]interface{}{
"SelectedNavItemArticle": false,
"ArticlePageVM": articlePageVM,
}
err = t.ExecuteTemplate(w, "base", data)
if err != nil { if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError) http.Error(w, "Failed to render template", http.StatusInternalServerError)
return return

View File

@@ -1,8 +1,7 @@
package app package app
import ( import (
"crowsnest/internal/model" "crowsnest/internal/html"
"html/template"
"net/http" "net/http"
"strconv" "strconv"
) )
@@ -10,7 +9,7 @@ import (
// List the latest articles using the base template. // List the latest articles using the base template.
func (app *App) Index(w http.ResponseWriter, req *http.Request) { func (app *App) Index(w http.ResponseWriter, req *http.Request) {
const pageSize = 15 const pageSize = 15
var limit, offset, pageId uint64 = pageSize, 0, 0 var offset, pageId uint64 = 0, 0
var err error var err error
// get page number // get page number
@@ -20,7 +19,7 @@ func (app *App) Index(w http.ResponseWriter, req *http.Request) {
} }
// get articles // get articles
articleVMs, err := app.articleVMs.All(int(limit), int(offset)) articleVMs, err := app.articleVMs.All(pageSize, int(offset))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -34,18 +33,8 @@ func (app *App) Index(w http.ResponseWriter, req *http.Request) {
} }
totalCount /= pageSize totalCount /= pageSize
// render template // render page
t := template.Must(template.ParseFiles( err = html.IndexLayout(articleVMs, uint(pageId+1), totalCount+1).Render(w)
"assets/templates/article.html",
"assets/templates/layout.html",
"assets/templates/components/pagination.html"))
data := map[string]interface{}{
"SelectedNavItemArticle": true,
"ArticleVMs": &articleVMs,
"Paginations": model.NewPaginationViewModel(uint(pageId+1), totalCount+1),
}
err = t.ExecuteTemplate(w, "base", data)
if err != nil { if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError) http.Error(w, "Failed to render template", http.StatusInternalServerError)
return return

View File

@@ -1,7 +1,7 @@
package app package app
import ( import (
"html/template" "crowsnest/internal/html"
"net/http" "net/http"
) )
@@ -23,18 +23,8 @@ func (app *App) UpSearch(w http.ResponseWriter, req *http.Request) {
return return
} }
// render template // render page
t := template.Must(template.ParseFiles( err = html.IndexLayout(articleVMs, 0, 0).Render(w)
"assets/templates/article.html",
"assets/templates/layout.html",
"assets/templates/components/pagination.html"))
data := map[string]interface{}{
"SelectedNavItemArticle": true,
"ArticleVMs": &articleVMs,
"Paginations": nil,
}
err = t.ExecuteTemplate(w, "base", data)
if err != nil { if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError) http.Error(w, "Failed to render template", http.StatusInternalServerError)
return return

View File

@@ -0,0 +1,39 @@
package html
import (
"crowsnest/internal/model"
"strconv"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func Articles(articles []*model.ArticleViewModel) g.Node {
return h.Div(h.Class("content max-w-screen-lg flex flex-col mx-auto"), g.Map(articles, Article))
}
func Article(article *model.ArticleViewModel) g.Node {
return h.Div(
h.Class("collapse bg-base-200 shadow mb-4"),
h.TabIndex("0"),
h.Div(h.Class("collapse-title font-medium"), g.Text(article.Title)),
h.Div(
h.Class("collapse-content"),
h.P(
h.Class("pb-2"),
h.Span(h.Class("badge badge-outline"), g.Text(article.ShortSource)),
h.Span(h.Class("badge badge-outline"), g.Text(article.PublishDate)),
),
h.P(h.Class("card-text"), g.Text(article.Summary)),
h.Div(
h.Class("flex flex-row-reverse"),
h.A(
h.Href("/article/"+strconv.Itoa(article.Id)),
h.Class("btn btn-active btn-sm btn-primary"),
g.Attr("up-follow"),
g.Text("Details"),
),
),
),
)
}

View File

@@ -0,0 +1,14 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
)
func ArticleLayout(articlePageVMs *model.ArticlePageViewModel) g.Node {
return Layout(
"Crowsnest - "+articlePageVMs.Title,
ArticlePage(articlePageVMs),
)
}

View File

@@ -0,0 +1,55 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func ArticlePageColumn(key string, value string) g.Node {
return h.P(
h.Span(
h.Class("badge badge-neutral me-4 w-20"),
g.Text(key),
),
g.Text(value),
)
}
func ArticlePage(articlePageVM *model.ArticlePageViewModel) g.Node {
return h.Div(
h.TabIndex("0"),
h.Class("card bg-base-200 shadow mb-4"),
h.Div(
h.Class("card-body"),
h.Div(
h.Class("flex flex-row pb-4"),
h.Div(
h.Class("divider divider-horizontal divider-primary"),
),
h.Div(
h.Class("card-title font-medium"),
g.Text(articlePageVM.Title),
),
),
h.Div(
h.Class("px-5 pb-4 grid gap-y-4 grid-cols-1"),
ArticlePageColumn("Datum", articlePageVM.PublishDate),
ArticlePageColumn("Quelle", articlePageVM.ShortSource),
ArticlePageColumn("TLDR", articlePageVM.Summary),
ArticlePageColumn("Inhalt", articlePageVM.Content),
h.Div(
h.Class("card-actions justify-end"),
h.A(
h.Href(articlePageVM.SourceUrl),
h.Button(
h.Class("btn btn-primary btn-sm"),
g.Text("Seite besuchen"),
),
),
),
),
),
)
}

View File

@@ -0,0 +1,17 @@
package html
import (
"crowsnest/internal/model"
g "maragu.dev/gomponents"
)
func IndexLayout(articleVMs []*model.ArticleViewModel, paginationCurrent uint, paginationTotal uint) g.Node {
return Layout(
"Crowsnest - Artikel",
g.Group{
Articles(articleVMs),
Pagination(paginationCurrent, paginationTotal),
},
)
}

View File

@@ -0,0 +1,43 @@
package html
import (
g "maragu.dev/gomponents"
c "maragu.dev/gomponents/components"
h "maragu.dev/gomponents/html"
)
func Layout(title string, body g.Node) g.Node {
completeBody := []g.Node{
Navbar(),
h.Div(
h.Class("content container flex flex-col mx-auto px-4 mt-28"),
body,
),
h.Script(h.Src("https://cdn.tailwindcss.com")),
h.Script(
g.Text(
"up.link.config.followSelectors.push('a[href]')\n" +
"up.link.config.instantSelectors.push('a[href]')",
),
),
}
return c.HTML5(c.HTML5Props{
Title: title,
Language: "de",
Head: layoutHead(),
Body: completeBody,
HTMLAttrs: []g.Node{g.Attr("data-theme", "dark")},
})
}
func layoutHead() []g.Node {
return []g.Node{
h.Meta(h.Name("viewport"), h.Content("width=device-width, initial-scale=1")),
h.Link(h.Rel("stylesheet"), h.Type("text/css"), h.Href("/static/unpoly.min.css")),
h.Script(h.Src("/static/unpoly.min.js")),
h.Link(h.Rel("stylesheet"), h.Type("text/css"), h.Href("/static/daisyui.min.css")),
h.Script(h.Src("/static/tailwindcss.min.js")),
}
}

View File

@@ -0,0 +1,76 @@
package html
import (
g "maragu.dev/gomponents"
"maragu.dev/gomponents-heroicons/v3/outline"
h "maragu.dev/gomponents/html"
)
func NavbarLi(href string, text string, active bool) g.Node {
return h.Li(h.A(h.Href(href), h.TabIndex("0"), g.If(active, h.Class("active")), g.Text(text)))
}
func SearchForm() g.Node {
return h.Form(
h.Role("search"),
h.Method("post"),
h.Action("/up/search"),
g.Attr("up-submit"),
g.Attr("up-autosubmit"),
g.Attr("up-target", ".content"),
h.Label(
h.Class("input input-bordered input-sm flex items-center gap-2"),
h.Input(
h.Name("search"),
h.Type("search"),
h.Class("grow"),
h.Placeholder("Suche"),
),
outline.MagnifyingGlass(
h.Class("h-4 w-4 opacity-70"),
g.Attr("stroke", "currentColor"),
),
),
)
}
func Navbar() g.Node {
return h.Nav(
h.Class("fixed top-0 z-50 w-full p-4"),
h.Div(
h.Class("navbar bg-base-300 rounded-box drop-shadow-md"),
// links for large screens
h.Div(
h.Class("flex-1"),
h.A(h.Href("/"), h.TabIndex("0"), h.Class("btn btn-ghost text-xl"), g.Text("crowsnest")),
h.Ul(
h.Class("menu menu-horizontal hidden sm:flex"),
NavbarLi("/", "Artikel", true),
NavbarLi("/", "Themen", false),
),
),
// search
h.Div(h.Class("hidden sm:flex flex-none pe-4"), SearchForm()),
// small navbar dropdown
h.Div(
h.Class("dropdown dropdown-end sm:hidden"),
// dropdown button
h.Div(
h.TabIndex("0"),
h.Role("button"),
h.Class("btn btn-ghost"),
outline.Bars3BottomRight(
h.Class("h-6 w-6 opacity-70"),
g.Attr("stroke", "currentColor"),
),
),
// dropdown content
h.Ul(
h.Class("menu dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"),
NavbarLi("/", "Artikel", true),
NavbarLi("/", "Themen", false),
),
),
),
)
}

View File

@@ -0,0 +1,45 @@
package html
import (
"strconv"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
)
func PaginationButton(content string, active bool, disabled bool) g.Node {
classStr := "join-item btn btn-sm"
if disabled { classStr += " btn-disabled" }
if active { classStr += " btn-active" }
return h.A(
h.Class(classStr),
g.If(!disabled, h.Href("/page/"+content)),
g.If(!disabled, h.TabIndex("0")),
g.Attr("up-follow"),
g.Attr("up-target", ".content"),
g.Text(content),
)
}
func Pagination(currentPage uint, totalPages uint) g.Node {
buttons := make([]g.Node, 0)
if totalPages > 1 {
buttons = append(buttons, PaginationButton("1", currentPage == 1, false))
}
if currentPage > 3 {
buttons = append(buttons, PaginationButton("...", false, true))
}
for i := max(2, currentPage-1); i <= min(totalPages-1, currentPage+1); i++ {
buttons = append(buttons, PaginationButton(strconv.Itoa(int(i)), i == currentPage, false))
}
if currentPage < totalPages-2 {
buttons = append(buttons, PaginationButton("...", false, true))
}
if totalPages > 1 {
buttons = append(buttons, PaginationButton(strconv.Itoa(int(totalPages)), totalPages == currentPage, false))
}
return h.Div(h.Class("join pagination p-5 mx-auto"), h.Span(buttons...))
}

View File

@@ -45,7 +45,7 @@ func (m *ArticleRepository) All(limit int, offset int) ([]*model.Article, error)
// Counts all articles in the database. This may throw an error if the // Counts all articles in the database. This may throw an error if the
// connection to the database fails. // connection to the database fails.
func (m *ArticleRepository) CountAll() (uint, error) { func (m *ArticleRepository) CountAll() (uint, error) {
stmt := `SELECT count(id) FROM articles ` stmt := `SELECT count(id) FROM articles`
rows := m.DB.QueryRow(stmt) rows := m.DB.QueryRow(stmt)