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

-
- Details -
-
-
-{{ 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" }} - -
- -
-
-
-
-
{{ .ArticlePageVM.Title }}
-
- -
-

Datum{{ .ArticlePageVM.PublishDate }}

-

Quelle{{ .ArticlePageVM.ShortSource }}

-

TLDR{{ .ArticlePageVM.Summary }}

-

Inhalt{{ .ArticlePageVM.Content }}

- -
-
-
- -
-{{ end }} diff --git a/src/assets/templates/components/pagination.html b/src/assets/templates/components/pagination.html deleted file mode 100644 index 54dea5a..0000000 --- a/src/assets/templates/components/pagination.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "pagination" }} - - - -{{ end }} \ No newline at end of file diff --git a/src/assets/templates/layout.html b/src/assets/templates/layout.html deleted file mode 100644 index d048bb4..0000000 --- a/src/assets/templates/layout.html +++ /dev/null @@ -1,69 +0,0 @@ -{{ define "base" }} - - - - - - {{/* Unpoly */}} - - - - {{/* DasyUi */}} - - - - - - -
- {{ 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)