diff --git a/src/assets/templates/article.html b/src/assets/templates/article.html
deleted file mode 100644
index 8f8d282..0000000
--- a/src/assets/templates/article.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{{ define "content" }}
-
-
-
-{{ range .ArticleVMs }}
-
-
{{ .Title }}
-
-
- {{ .ShortSource }}
- {{ .PublishDate }}
-
-
{{ .Summary }}
-
-
-
-{{ end }}
-
-{{ template "pagination" .Paginations }}
-
-
-{{ end }}
diff --git a/src/assets/templates/articlePage.html b/src/assets/templates/articlePage.html
deleted file mode 100644
index 00e567e..0000000
--- a/src/assets/templates/articlePage.html
+++ /dev/null
@@ -1,27 +0,0 @@
-{{ define "content" }}
-
-
- {{ template "content" . }}
-
-
-
-
-
-
-{{ end }}
diff --git a/src/go.mod b/src/go.mod
index d7e914f..5dab848 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -79,4 +79,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gorgonia.org/vecf32 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
)
diff --git a/src/go.sum b/src/go.sum
index 7e6f824..79d22ad 100644
--- a/src/go.sum
+++ b/src/go.sum
@@ -447,5 +447,9 @@ gorgonia.org/vecf64 v0.9.0 h1:bgZDP5x0OzBF64PjMGC3EvTdOoMEcmfAh1VCUnZFm1A=
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-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=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/src/internal/app/article.go b/src/internal/app/article.go
index 1776a48..ed51c27 100644
--- a/src/internal/app/article.go
+++ b/src/internal/app/article.go
@@ -1,7 +1,7 @@
package app
import (
- "html/template"
+ "crowsnest/internal/html"
"net/http"
"strconv"
)
@@ -24,17 +24,8 @@ func (app *App) Article(w http.ResponseWriter, req *http.Request) {
return
}
- // render template
- t := template.Must(template.ParseFiles(
- "assets/templates/articlePage.html",
- "assets/templates/layout.html",
- ))
-
- data := map[string]interface{}{
- "SelectedNavItemArticle": false,
- "ArticlePageVM": articlePageVM,
- }
- err = t.ExecuteTemplate(w, "base", data)
+ // render page
+ err = html.ArticleLayout(articlePageVM).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
return
diff --git a/src/internal/app/index.go b/src/internal/app/index.go
index f22d302..6b47357 100644
--- a/src/internal/app/index.go
+++ b/src/internal/app/index.go
@@ -1,8 +1,7 @@
package app
import (
- "crowsnest/internal/model"
- "html/template"
+ "crowsnest/internal/html"
"net/http"
"strconv"
)
@@ -10,7 +9,7 @@ import (
// List the latest articles using the base template.
func (app *App) Index(w http.ResponseWriter, req *http.Request) {
const pageSize = 15
- var limit, offset, pageId uint64 = pageSize, 0, 0
+ var offset, pageId uint64 = 0, 0
var err error
// get page number
@@ -20,7 +19,7 @@ func (app *App) Index(w http.ResponseWriter, req *http.Request) {
}
// get articles
- articleVMs, err := app.articleVMs.All(int(limit), int(offset))
+ articleVMs, err := app.articleVMs.All(pageSize, int(offset))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -34,20 +33,10 @@ func (app *App) Index(w http.ResponseWriter, req *http.Request) {
}
totalCount /= pageSize
- // render template
- t := template.Must(template.ParseFiles(
- "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)
+ // render page
+ err = html.IndexLayout(articleVMs, uint(pageId+1), totalCount+1).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
- return
+ return
}
}
diff --git a/src/internal/app/upsearch.go b/src/internal/app/upsearch.go
index 603c92b..7d46359 100644
--- a/src/internal/app/upsearch.go
+++ b/src/internal/app/upsearch.go
@@ -1,7 +1,7 @@
package app
import (
- "html/template"
+ "crowsnest/internal/html"
"net/http"
)
@@ -23,20 +23,10 @@ func (app *App) UpSearch(w http.ResponseWriter, req *http.Request) {
return
}
- // render template
- t := template.Must(template.ParseFiles(
- "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)
+ // render page
+ err = html.IndexLayout(articleVMs, 0, 0).Render(w)
if err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
- return
+ return
}
}
diff --git a/src/internal/html/article.go b/src/internal/html/article.go
new file mode 100644
index 0000000..212203d
--- /dev/null
+++ b/src/internal/html/article.go
@@ -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"),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/src/internal/html/articleLayout.go b/src/internal/html/articleLayout.go
new file mode 100644
index 0000000..72081c7
--- /dev/null
+++ b/src/internal/html/articleLayout.go
@@ -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),
+ )
+}
diff --git a/src/internal/html/articlePage.go b/src/internal/html/articlePage.go
new file mode 100644
index 0000000..a0aac5b
--- /dev/null
+++ b/src/internal/html/articlePage.go
@@ -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"),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/src/internal/html/indexLayout.go b/src/internal/html/indexLayout.go
new file mode 100644
index 0000000..3040120
--- /dev/null
+++ b/src/internal/html/indexLayout.go
@@ -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),
+ },
+ )
+}
diff --git a/src/internal/html/layout.go b/src/internal/html/layout.go
new file mode 100644
index 0000000..ae9955c
--- /dev/null
+++ b/src/internal/html/layout.go
@@ -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")),
+ }
+}
+
diff --git a/src/internal/html/navbar.go b/src/internal/html/navbar.go
new file mode 100644
index 0000000..fda9e00
--- /dev/null
+++ b/src/internal/html/navbar.go
@@ -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),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/src/internal/html/pagination.go b/src/internal/html/pagination.go
new file mode 100644
index 0000000..7fe730e
--- /dev/null
+++ b/src/internal/html/pagination.go
@@ -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...))
+}
diff --git a/src/internal/model/database/articlerepository.go b/src/internal/model/database/articlerepository.go
index 8c6c0a2..777bb36 100644
--- a/src/internal/model/database/articlerepository.go
+++ b/src/internal/model/database/articlerepository.go
@@ -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
// connection to the database fails.
func (m *ArticleRepository) CountAll() (uint, error) {
- stmt := `SELECT count(id) FROM articles `
+ stmt := `SELECT count(id) FROM articles`
rows := m.DB.QueryRow(stmt)