From 7819fea28eef423e069e3af0be5c12362b44fbfd Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sat, 22 Feb 2025 09:52:29 -0500 Subject: [PATCH 01/30] Initial POC of gomponents. --- go.mod | 1 + go.sum | 8 +- pkg/handlers/auth.go | 11 +- pkg/handlers/contact.go | 33 ++---- pkg/handlers/pages.go | 29 ++++-- pkg/ui/cache.go | 1 + pkg/ui/components.go | 226 ++++++++++++++++++++++++++++++++++++++++ pkg/ui/form.go | 158 ++++++++++++++++++++++++++++ pkg/ui/forms.go | 56 ++++++++++ pkg/ui/layouts.go | 35 +++++++ pkg/ui/models.go | 15 +++ pkg/ui/pages.go | 57 ++++++++++ pkg/ui/request.go | 85 +++++++++++++++ pkg/ui/utils.go | 17 +++ 14 files changed, 684 insertions(+), 48 deletions(-) create mode 100644 pkg/ui/cache.go create mode 100644 pkg/ui/components.go create mode 100644 pkg/ui/form.go create mode 100644 pkg/ui/forms.go create mode 100644 pkg/ui/layouts.go create mode 100644 pkg/ui/models.go create mode 100644 pkg/ui/pages.go create mode 100644 pkg/ui/request.go create mode 100644 pkg/ui/utils.go diff --git a/go.mod b/go.mod index e30558fe..71aaf91a 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.33.0 + maragu.dev/gomponents v1.0.0 ) require ( diff --git a/go.sum b/go.sum index 5426abac..4c9d8d8a 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= @@ -104,8 +102,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -123,8 +119,6 @@ github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= @@ -245,3 +239,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4= +maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index b7f9edd2..0e0b28f6 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -15,6 +15,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/redirect" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/templates" ) @@ -226,7 +227,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error { msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) return redirect.New(ctx). - Route(routeNameHome). + Route(routenames.Home). Go() } @@ -237,7 +238,7 @@ func (h *Auth) Logout(ctx echo.Context) error { msg.Danger(ctx, "An error occurred. Please try again.") } return redirect.New(ctx). - Route(routeNameHome). + Route(routenames.Home). Go() } @@ -312,7 +313,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { h.sendVerificationEmail(ctx, u) return redirect.New(ctx). - Route(routeNameHome). + Route(routenames.Home). Go() } @@ -410,7 +411,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { if err != nil { msg.Warning(ctx, "The link is either invalid or has expired.") return redirect.New(ctx). - Route(routeNameHome). + Route(routenames.Home). Go() } @@ -449,6 +450,6 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { msg.Success(ctx, "Your email has been successfully verified.") return redirect.New(ctx). - Route(routeNameHome). + Route(routenames.Home). Go() } diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 4c5f264c..9112e518 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -2,30 +2,18 @@ package handlers import ( "fmt" + "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/form" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" -) - -const ( - routeNameContact = "contact" - routeNameContactSubmit = "contact.submit" + "github.com/mikestefanello/pagoda/pkg/ui" ) type ( Contact struct { mail *services.MailClient - *services.TemplateRenderer - } - - contactForm struct { - Email string `form:"email" validate:"required,email"` - Department string `form:"department" validate:"required,oneof=sales marketing hr"` - Message string `form:"message" validate:"required"` - form.Submission } ) @@ -34,28 +22,21 @@ func init() { } func (h *Contact) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer h.mail = c.Mail return nil } func (h *Contact) Routes(g *echo.Group) { - g.GET("/contact", h.Page).Name = routeNameContact - g.POST("/contact", h.Submit).Name = routeNameContactSubmit + g.GET("/contact", h.Page).Name = routenames.Contact + g.POST("/contact", h.Submit).Name = routenames.ContactSubmit } func (h *Contact) Page(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageContact - p.Title = "Contact us" - p.Form = form.Get[contactForm](ctx) - - return h.RenderPage(ctx, p) + return ui.ContactUs(ctx, form.Get[ui.ContactForm](ctx)) } func (h *Contact) Submit(ctx echo.Context) error { - var input contactForm + var input ui.ContactForm err := form.Submit(ctx, &input) diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index 481ec4a9..384171ea 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -6,13 +6,14 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/pkg/ui" "github.com/mikestefanello/pagoda/templates" ) const ( routeNameAbout = "about" - routeNameHome = "home" ) type ( @@ -20,11 +21,6 @@ type ( *services.TemplateRenderer } - post struct { - Title string - Body string - } - aboutData struct { ShowCacheWarning bool FrontendTabs []aboutTab @@ -47,11 +43,12 @@ func (h *Pages) Init(c *services.Container) error { } func (h *Pages) Routes(g *echo.Group) { - g.GET("/", h.Home).Name = routeNameHome + g.GET("/", h.Home).Name = routenames.Home + g.GET("/oldhome", h.HomeOld).Name = "oldhome" g.GET("/about", h.About).Name = routeNameAbout } -func (h *Pages) Home(ctx echo.Context) error { +func (h *Pages) HomeOld(ctx echo.Context) error { p := page.New(ctx) p.Layout = templates.LayoutMain p.Name = templates.PageHome @@ -63,13 +60,23 @@ func (h *Pages) Home(ctx echo.Context) error { return h.RenderPage(ctx, p) } +func (h *Pages) Home(ctx echo.Context) error { + pgr := page.NewPager(ctx, 4) + p := h.fetchPosts(&pgr) + + return ui.Home(ctx, ui.Posts{ + Posts: p, + Pager: pgr, + }) +} + // fetchPosts is an mock example of fetching posts to illustrate how paging works -func (h *Pages) fetchPosts(pager *page.Pager) []post { +func (h *Pages) fetchPosts(pager *page.Pager) []ui.Post { pager.SetItems(20) - posts := make([]post, 20) + posts := make([]ui.Post, 20) for k := range posts { - posts[k] = post{ + posts[k] = ui.Post{ Title: fmt.Sprintf("Post example #%d", k+1), Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1), } diff --git a/pkg/ui/cache.go b/pkg/ui/cache.go new file mode 100644 index 00000000..5b1faa29 --- /dev/null +++ b/pkg/ui/cache.go @@ -0,0 +1 @@ +package ui diff --git a/pkg/ui/components.go b/pkg/ui/components.go new file mode 100644 index 00000000..97ac28ab --- /dev/null +++ b/pkg/ui/components.go @@ -0,0 +1,226 @@ +package ui + +import ( + "strings" + + "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/routenames" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func head(r *request) Node { + return Head( + Meta(Charset("utf-8")), + Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), + Link(Rel("icon"), Href(file("favicon.png"))), + TitleEl(Text(r.Title)), + If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))), + If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))), + Link(Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"), Rel("stylesheet")), + Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")), + Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()), + ) +} + +func flashMessages(r *request) Node { + var g Group + for _, typ := range []msg.Type{ + msg.TypeSuccess, + msg.TypeInfo, + msg.TypeWarning, + msg.TypeDanger, + } { + for _, str := range msg.Get(r.Context, typ) { + g = append(g, notification(typ, str)) + } + } + + return g +} + +func notification(typ msg.Type, text string) Node { + var class string + + switch typ { + case msg.TypeSuccess: + class = "success" + case msg.TypeInfo: + class = "info" + case msg.TypeWarning: + class = "warning" + case msg.TypeDanger: + class = "danger" + } + + return Div( + Class("notification is-"+class), + Attr("x-data", "{show: true}"), + Attr("x-show", "show"), + Button( + Class("delete"), + Attr("@click", "show = false"), + ), + Text(text), + ) +} + +func message(class, header string, body Node) Node { + return Article( + Class("message "+class), + If(header != "", Div( + Class("message-header"), + P(Text(header)), + )), + Div( + Class("message-body"), + body, + ), + ) +} + +func sidebarMenu(r *request) Node { + return Aside( + Class("menu"), + Attr("hx-boost", "true"), + P( + Class("menu-label"), + Text("General"), + ), + Ul( + Class("menu-list"), + menuLink(r, "Dashboard", routenames.Home), + menuLink(r, "About", "about"), + menuLink(r, "Contact", routenames.Contact), + menuLink(r, "Cache", "cache"), + menuLink(r, "Task", "task"), + menuLink(r, "Files", "files"), + ), + P( + Class("menu-label"), + Text("Account"), + ), + Ul( + Class("menu-list"), + If(r.IsAuth, menuLink(r, "Logout", "logout")), + If(!r.IsAuth, menuLink(r, "Login", "login")), + If(!r.IsAuth, menuLink(r, "Register", "register")), + If(!r.IsAuth, menuLink(r, "Forgot password", "forgot_password")), + ), + ) +} + +func menuLink(r *request, title, routeName string, routeParams ...string) Node { + href := r.path(routeName, routeParams...) + + return Li( + A( + Href(href), + Text(title), + If(href == r.Path, Class("is-active")), + ), + ) +} + +func navBar(r *request) Node { + return Nav( + Class("navbar is-dark"), + Div( + Class("container"), + Div( + Class("navbar-brand"), + Attr("hx-boost", "true"), + A( + Href(r.path(routenames.Home)), + Class("navbar-item"), + Text("Pagoda"), + ), + ), + Div( + ID("navbarMenu"), + Class("navbar-menu"), + Div( + Class("navbar-end"), + search(r), + ), + ), + ), + ) +} + +func search(r *request) Node { + return Div( + Class("search mr-2 mt-1"), + Attr("x-data", "{modal:false}"), + Input( + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"), + ), + Div( + Class("modal"), + Attr(":class", "modal ? 'is-active' : ''"), + Attr("x-show", "modal == true"), + Div( + Class("modal-background"), + ), + Div( + Class("modal-content"), + Attr("@click.outside", "modal = false;"), + Div( + Class("box"), + H2( + Class("subtitle"), + Text("Search"), + ), + P( + Class("control"), + Input( + Attr("hx-get", r.path("search")), + Attr("hx-trigger", "keyup changed delay:500ms"), + Attr("hx-target", "#results"), + Name("query"), + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("x-ref", "input"), + ), + ), + Div( + Class("block"), + ), + Div( + ID("results"), + ), + ), + ), + Button( + Class("modal-close is-large"), + Aria("label", "close"), + ), + ), + ) +} + +func footer(r *request) Node { + return Group{ + If(len(r.CSRF) > 0, Script( + Raw(` + document.body.addEventListener('htmx:configRequest', function(evt) { + if (evt.detail.verb !== "get") { + evt.detail.parameters['csrf'] = '`+r.CSRF+`'; + } + }) + `), + )), + Script(Raw(` + document.body.addEventListener('htmx:beforeSwap', function(evt) { + if (evt.detail.xhr.status >= 400){ + evt.detail.shouldSwap = true; + evt.detail.target = htmx.find("body"); + } + }); + `)), + } +} diff --git a/pkg/ui/form.go b/pkg/ui/form.go new file mode 100644 index 00000000..4dffab0b --- /dev/null +++ b/pkg/ui/form.go @@ -0,0 +1,158 @@ +package ui + +import ( + "github.com/mikestefanello/pagoda/pkg/form" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type ( + input struct { + form form.Form + formField string + name string + inputType string + label string + value string + } + + radios struct { + form form.Form + formField string + name string + label string + value string + options []radio + } + + radio struct { + value string + label string + } + + textarea struct { + form form.Form + formField string + name string + label string + value string + } +) + +func formSubmit(label string) Node { + return Div( + Class("field is-grouped"), + Div( + Class("control"), + Button( + Class("button is-link"), + Text(label), + ), + ), + ) +} + +func formTextarea(el textarea) Node { + return Div( + Class("field"), + Label( + For("name"), + Class("label"), + Text(el.label), + ), + Div( + Class("control"), + Textarea( + ID(el.name), + Name(el.name), + Class("textarea "+formFieldStatusClass(el.form, el.formField)), + Text(el.value), + ), + ), + formFieldErrors(el.form, el.formField), + ) +} + +func formRadios(el radios) Node { + buttons := make(Group, 0, len(el.options)) + for _, opt := range el.options { + buttons = append(buttons, Label( + Class("radio"), + Input( + Type("radio"), + Name(el.name), + Value(opt.value), + If(el.value == opt.value, Checked()), + ), + Text(" "+opt.label), + )) + } + + return Div( + Class("control field"), + Label(Class("label"), Text(el.label)), + Div( + Class("radios"), + buttons, + ), + formFieldErrors(el.form, el.formField), + ) +} + +func formInput(el input) Node { + return Div( + Class("field"), + Label( + Class("label"), + For(el.name), + Text(el.label), + ), + Div( + Class("control"), + Input( + ID(el.name), + Name(el.name), + Type(el.inputType), + Class("input "+formFieldStatusClass(el.form, el.formField)), + Value(el.value), + ), + ), + formFieldErrors(el.form, el.formField), + ) +} + +func formFieldStatusClass(fm form.Form, formField string) string { + switch { + case !fm.IsSubmitted(): + return "" + case fm.FieldHasErrors(formField): + return "is-danger" + default: + return "is-success" + } +} + +func formFieldErrors(fm form.Form, field string) Node { + errs := fm.GetFieldErrors(field) + if len(errs) == 0 { + return nil + } + + g := make(Group, 0, len(errs)) + for _, err := range errs { + g = append(g, P( + Class("help is-danger"), + Text(err), + )) + } + + return g +} + +func csrf(r *request) Node { + return Input( + Type("hidden"), + Name("csrf"), + Value(r.CSRF), + ) +} diff --git a/pkg/ui/forms.go b/pkg/ui/forms.go new file mode 100644 index 00000000..49408136 --- /dev/null +++ b/pkg/ui/forms.go @@ -0,0 +1,56 @@ +package ui + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type ( + ContactForm struct { + Email string `form:"email" validate:"required,email"` + Department string `form:"department" validate:"required,oneof=sales marketing hr"` + Message string `form:"message" validate:"required"` + form.Submission + } +) + +func (f *ContactForm) render(r *request) Node { + return Form( + ID("contact"), + Method(http.MethodPost), + Attr("hx-post", r.path(routenames.ContactSubmit)), + formInput(input{ + form: f, + formField: "Email", + name: "email", + inputType: "email", + label: "Email address", + value: f.Email, + }), + formRadios(radios{ + form: f, + formField: "Department", + name: "department", + label: "Department", + value: f.Department, + options: []radio{ + {value: "sales", label: "Sales"}, + {value: "marketing", label: "Marketing"}, + {value: "hr", label: "HR"}, + }, + }), + formTextarea(textarea{ + form: f, + formField: "Message", + name: "message", + label: "Message", + value: f.Message, + }), + formSubmit("Submit"), + csrf(r), + ) +} diff --git a/pkg/ui/layouts.go b/pkg/ui/layouts.go new file mode 100644 index 00000000..3e01b908 --- /dev/null +++ b/pkg/ui/layouts.go @@ -0,0 +1,35 @@ +package ui + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func primaryLayout(r *request, content Node) Node { + return Doctype( + HTML( + Lang("en"), + head(r), + Body( + navBar(r), + Div( + Class("container mt-5"), + Div( + Class("columns"), + Div( + Class("column is-2"), + sidebarMenu(r), + ), + Div( + Class("column is-10"), + If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), + flashMessages(r), + content, + ), + ), + ), + footer(r), + ), + ), + ) +} diff --git a/pkg/ui/models.go b/pkg/ui/models.go new file mode 100644 index 00000000..5071ece3 --- /dev/null +++ b/pkg/ui/models.go @@ -0,0 +1,15 @@ +package ui + +import ( + "github.com/mikestefanello/pagoda/pkg/page" +) + +type ( + Posts struct { + Posts []Post + Pager page.Pager + } + Post struct { + Title, Body string + } +) diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go new file mode 100644 index 00000000..a4c283b0 --- /dev/null +++ b/pkg/ui/pages.go @@ -0,0 +1,57 @@ +package ui + +import ( + "github.com/labstack/echo/v4" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Home(ctx echo.Context, posts Posts) error { + r := newRequest(ctx) + r.Title = "Home" + r.Metatags.Description = "This is the home page." + r.Metatags.Keywords = []string{"Software", "Coding", "Go"} + + g := make(Group, 0, len(posts.Posts)+1) + g = append(g, Div(Class("dashboard"), Text("hello"))) + + for _, post := range posts.Posts { + g = append(g, Div( + Class("post"), + H3(Text(post.Title)), + Span(Text(post.Body)), + )) + } + + return r.render(primaryLayout, g) +} + +func ContactUs(ctx echo.Context, form *ContactForm) error { + r := newRequest(ctx) + r.Title = "Contact us" + r.Metatags.Description = "Get in touch with us." + + g := make(Group, 0) + + if r.Htmx.Target != "contact" { + g = append(g, message( + "is-link", + "", + Group{ + P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")), + P(Text("Only the form below will update async upon submission.")), + })) + } + + if form.IsDone() { + g = append(g, message( + "is-large is-success", + "Thank you!", + Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."), + )) + } else { + g = append(g, form.render(r)) + } + + return r.render(primaryLayout, g) +} diff --git a/pkg/ui/request.go b/pkg/ui/request.go new file mode 100644 index 00000000..42d59753 --- /dev/null +++ b/pkg/ui/request.go @@ -0,0 +1,85 @@ +package ui + +import ( + "github.com/labstack/echo/v4" + echomw "github.com/labstack/echo/v4/middleware" + "github.com/mikestefanello/pagoda/ent" + "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/htmx" + "maragu.dev/gomponents" +) + +type layoutFunc func(*request, gomponents.Node) gomponents.Node + +type request struct { + // AppName stores the name of the application. + // If omitted, the configuration value will be used. + AppName string + + // Title stores the title of the page + Title string + + // Context stores the request context + Context echo.Context + + // Path stores the path of the current request + Path string + + // IsHome stores whether the requested page is the home page or not + IsHome bool + + // IsAuth stores whether the user is authenticated + IsAuth bool + + // AuthUser stores the authenticated user + AuthUser *ent.User + + // Metatags stores metatag values + Metatags struct { + // Description stores the description metatag value + Description string + + // Keywords stores the keywords metatag values + Keywords []string + } + + // CSRF stores the CSRF token for the given request. + // This will only be populated if the CSRF middleware is in effect for the given request. + // If this is populated, all forms must include this value otherwise the requests will be rejected. + CSRF string + + Htmx htmx.Request +} + +func newRequest(ctx echo.Context) *request { + p := &request{ + Context: ctx, + Path: ctx.Request().URL.Path, + Htmx: htmx.GetRequest(ctx), + } + + p.IsHome = p.Path == "/" + + if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil { + p.CSRF = csrf.(string) + } + + if u := ctx.Get(context.AuthenticatedUserKey); u != nil { + p.IsAuth = true + p.AuthUser = u.(*ent.User) + } + + return p +} + +func (r *request) path(routeName string, routeParams ...string) string { + return r.Context.Echo().Reverse(routeName, routeParams) +} + +func (r *request) render(layout layoutFunc, node gomponents.Node) error { + if r.Htmx.Enabled && !r.Htmx.Boosted { + return node.Render(r.Context.Response().Writer) + } + + return layout(r, node).Render(r.Context.Response().Writer) +} diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go new file mode 100644 index 00000000..b61bce4b --- /dev/null +++ b/pkg/ui/utils.go @@ -0,0 +1,17 @@ +package ui + +import ( + "fmt" + + "github.com/labstack/gommon/random" + "github.com/mikestefanello/pagoda/config" +) + +var ( + // CacheBuster stores a random string used as a cache buster for static files. + cacheBuster = random.String(10) +) + +func file(filepath string) string { + return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster) +} From d09b347fd41a3cc929e3e556b45bf379167e3dea Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sat, 22 Feb 2025 15:18:49 -0500 Subject: [PATCH 02/30] Migrated more auth forms. --- pkg/handlers/auth.go | 84 +++++++-------------------- pkg/redirect/redirect.go | 2 +- pkg/routenames/names.go | 13 +++++ pkg/ui/components.go | 15 +++++ pkg/ui/form.go | 32 +++++----- pkg/ui/forms.go | 122 ++++++++++++++++++++++++++++++++++++++- pkg/ui/layouts.go | 45 ++++++++++++++- pkg/ui/pages.go | 33 ++++++++++- 8 files changed, 264 insertions(+), 82 deletions(-) create mode 100644 pkg/routenames/names.go diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 0e0b28f6..bf4aaccc 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -17,20 +17,15 @@ import ( "github.com/mikestefanello/pagoda/pkg/redirect" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/pkg/ui" "github.com/mikestefanello/pagoda/templates" ) const ( - routeNameForgotPassword = "forgot_password" - routeNameForgotPasswordSubmit = "forgot_password.submit" - routeNameLogin = "login" - routeNameLoginSubmit = "login.submit" - routeNameLogout = "logout" - routeNameRegister = "register" - routeNameRegisterSubmit = "register.submit" - routeNameResetPassword = "reset_password" - routeNameResetPasswordSubmit = "reset_password.submit" - routeNameVerifyEmail = "verify_email" + routeNameLogout = "logout" + routeNameResetPassword = "reset_password" + routeNameResetPasswordSubmit = "reset_password.submit" + routeNameVerifyEmail = "verify_email" ) type ( @@ -41,25 +36,6 @@ type ( *services.TemplateRenderer } - forgotPasswordForm struct { - Email string `form:"email" validate:"required,email"` - form.Submission - } - - loginForm struct { - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - form.Submission - } - - registerForm struct { - Name string `form:"name" validate:"required"` - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - form.Submission - } - resetPasswordForm struct { Password string `form:"password" validate:"required"` ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` @@ -84,12 +60,12 @@ func (h *Auth) Routes(g *echo.Group) { g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail noAuth := g.Group("/user", middleware.RequireNoAuthentication()) - noAuth.GET("/login", h.LoginPage).Name = routeNameLogin - noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit - noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister - noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit - noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword - noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit + noAuth.GET("/login", h.LoginPage).Name = routenames.Login + noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit + noAuth.GET("/register", h.RegisterPage).Name = routenames.Register + noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit + noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword + noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit resetGroup := noAuth.Group("/password/reset", middleware.LoadUser(h.orm), @@ -100,17 +76,11 @@ func (h *Auth) Routes(g *echo.Group) { } func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutAuth - p.Name = templates.PageForgotPassword - p.Title = "Forgot password" - p.Form = form.Get[forgotPasswordForm](ctx) - - return h.RenderPage(ctx, p) + return ui.ForgotPassword(ctx, form.Get[ui.ForgotPasswordForm](ctx)) } func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { - var input forgotPasswordForm + var input ui.ForgotPasswordForm succeed := func() error { form.Clear(ctx) @@ -169,17 +139,11 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { } func (h *Auth) LoginPage(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutAuth - p.Name = templates.PageLogin - p.Title = "Log in" - p.Form = form.Get[loginForm](ctx) - - return h.RenderPage(ctx, p) + return ui.Login(ctx, form.Get[ui.LoginForm](ctx)) } func (h *Auth) LoginSubmit(ctx echo.Context) error { - var input loginForm + var input ui.LoginForm authFailed := func() error { input.SetFieldError("Email", "") @@ -224,7 +188,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error { return fail(err, "unable to log in user") } - msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) + msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name)) return redirect.New(ctx). Route(routenames.Home). @@ -243,17 +207,11 @@ func (h *Auth) Logout(ctx echo.Context) error { } func (h *Auth) RegisterPage(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutAuth - p.Name = templates.PageRegister - p.Title = "Register" - p.Form = form.Get[registerForm](ctx) - - return h.RenderPage(ctx, p) + return ui.Register(ctx, form.Get[ui.RegisterForm](ctx)) } func (h *Auth) RegisterSubmit(ctx echo.Context) error { - var input registerForm + var input ui.RegisterForm err := form.Submit(ctx, &input) @@ -288,7 +246,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { case *ent.ConstraintError: msg.Warning(ctx, "A user with this email address already exists. Please log in.") return redirect.New(ctx). - Route(routeNameLogin). + Route(routenames.Login). Go() default: return fail(err, "unable to create user") @@ -303,7 +261,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { ) msg.Info(ctx, "Your account has been created.") return redirect.New(ctx). - Route(routeNameLogin). + Route(routenames.Login). Go() } @@ -398,7 +356,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { msg.Success(ctx, "Your password has been updated.") return redirect.New(ctx). - Route(routeNameLogin). + Route(routenames.Login). Go() } diff --git a/pkg/redirect/redirect.go b/pkg/redirect/redirect.go index 3e971799..3f08b436 100644 --- a/pkg/redirect/redirect.go +++ b/pkg/redirect/redirect.go @@ -24,7 +24,7 @@ type Redirect struct { func New(ctx echo.Context) *Redirect { return &Redirect{ ctx: ctx, - status: http.StatusFound, + status: http.StatusTemporaryRedirect, } } diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go new file mode 100644 index 00000000..e2c8b95e --- /dev/null +++ b/pkg/routenames/names.go @@ -0,0 +1,13 @@ +package routenames + +const ( + Home = "home" + Contact = "contact" + ContactSubmit = "contact.submit" + Login = "login" + LoginSubmit = "login.submit" + Register = "register" + RegisterSubmit = "register.submit" + ForgotPassword = "forgot_password" + ForgotPasswordSubmit = "forgot_password.submit" +) diff --git a/pkg/ui/components.go b/pkg/ui/components.go index 97ac28ab..61f6fdef 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -224,3 +224,18 @@ func footer(r *request) Node { `)), } } + +func button(class, label string) Node { + return Button( + Class("button "+class), + Text(label), + ) +} + +func buttonLink(href, class, label string) Node { + return A( + Href(href), + Class("button "+class), + Text(label), + ) +} diff --git a/pkg/ui/form.go b/pkg/ui/form.go index 4dffab0b..c11f7eee 100644 --- a/pkg/ui/form.go +++ b/pkg/ui/form.go @@ -8,12 +8,13 @@ import ( type ( input struct { - form form.Form - formField string - name string - inputType string - label string - value string + form form.Form + formField string + name string + inputType string + label string + value string + placeholder string } radios struct { @@ -39,16 +40,18 @@ type ( } ) -func formSubmit(label string) Node { +func formControlGroup(controls ...Node) Node { + g := make(Group, 0, len(controls)) + for _, control := range controls { + g = append(g, Div( + Class("control"), + control, + )) + } + return Div( Class("field is-grouped"), - Div( - Class("control"), - Button( - Class("button is-link"), - Text(label), - ), - ), + g, ) } @@ -113,6 +116,7 @@ func formInput(el input) Node { ID(el.name), Name(el.name), Type(el.inputType), + If(el.placeholder != "", Placeholder(el.placeholder)), Class("input "+formFieldStatusClass(el.form, el.formField)), Value(el.value), ), diff --git a/pkg/ui/forms.go b/pkg/ui/forms.go index 49408136..fe4deb75 100644 --- a/pkg/ui/forms.go +++ b/pkg/ui/forms.go @@ -16,6 +16,25 @@ type ( Message string `form:"message" validate:"required"` form.Submission } + + LoginForm struct { + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + form.Submission + } + + RegisterForm struct { + Name string `form:"name" validate:"required"` + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + form.Submission + } + + ForgotPasswordForm struct { + Email string `form:"email" validate:"required,email"` + form.Submission + } ) func (f *ContactForm) render(r *request) Node { @@ -50,7 +69,108 @@ func (f *ContactForm) render(r *request) Node { label: "Message", value: f.Message, }), - formSubmit("Submit"), + formControlGroup( + button("is-link", "Submit"), + ), + csrf(r), + ) +} + +func (f *LoginForm) render(r *request) Node { + return Form( + ID("login"), + Method(http.MethodPost), + Attr("hx-boost", "true"), + Action(r.path(routenames.LoginSubmit)), + flashMessages(r), + formInput(input{ + form: f, + formField: "Email", + name: "email", + inputType: "email", + label: "Email address", + value: f.Email, + }), + formInput(input{ + form: f, + formField: "Password", + name: "password", + inputType: "password", + label: "Password", + placeholder: "******", + }), + formControlGroup( + button("is-link", "Login"), + buttonLink(r.path(routenames.Home), "is-light", "Cancel"), + ), + csrf(r), + ) +} + +func (f *RegisterForm) render(r *request) Node { + return Form( + ID("register"), + Method(http.MethodPost), + Attr("hx-boost", "true"), + Action(r.path(routenames.RegisterSubmit)), + formInput(input{ + form: f, + formField: "Name", + name: "name", + inputType: "text", + label: "Name", + value: f.Name, + }), + formInput(input{ + form: f, + formField: "Email", + name: "email", + inputType: "email", + label: "Email address", + value: f.Email, + }), + formInput(input{ + form: f, + formField: "Password", + name: "password", + inputType: "password", + label: "Password", + placeholder: "******", + }), + formInput(input{ + form: f, + formField: "PasswordConfirm", + name: "password-confirm", + inputType: "password", + label: "Confirm password", + placeholder: "******", + }), + formControlGroup( + button("is-primary", "Register"), + buttonLink(r.path(routenames.Home), "is-light", "Cancel"), + ), + csrf(r), + ) +} + +func (f *ForgotPasswordForm) render(r *request) Node { + return Form( + ID("forgot-password"), + Method(http.MethodPost), + Attr("hx-boost", "true"), + Action(r.path(routenames.ForgotPasswordSubmit)), + formInput(input{ + form: f, + formField: "Email", + name: "email", + inputType: "email", + label: "Email address", + value: f.Email, + }), + formControlGroup( + button("is-primary", "Reset password"), + buttonLink(r.path(routenames.Home), "is-light", "Cancel"), + ), csrf(r), ) } diff --git a/pkg/ui/layouts.go b/pkg/ui/layouts.go index 3e01b908..6e7edbb2 100644 --- a/pkg/ui/layouts.go +++ b/pkg/ui/layouts.go @@ -1,11 +1,12 @@ package ui import ( + "github.com/mikestefanello/pagoda/pkg/routenames" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) -func primaryLayout(r *request, content Node) Node { +func layoutPrimary(r *request, content Node) Node { return Doctype( HTML( Lang("en"), @@ -33,3 +34,45 @@ func primaryLayout(r *request, content Node) Node { ), ) } + +func layoutAuth(r *request, content Node) Node { + return Doctype( + HTML( + Lang("en"), + head(r), + Body( + Section( + Class("hero is-fullheight"), + Div( + Class("hero-body"), + Div( + Class("container"), + Div( + Class("columns is-centered"), + Div( + Class("column is-half"), + If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), + Div( + Class("notification"), + flashMessages(r), + content, + Div( + Class("content is-small has-text-centered"), + Attr("hx-boost", "true"), + A(Href(r.path(routenames.Login)), Text("Login")), + Raw(" ◌ "), + A(Href(r.path("register")), Text("Create an account")), + Raw(" ◌ "), + A(Href(r.path("forgot_password")), Text("Forgot password")), + ), + ), + ), + ), + ), + ), + ), + footer(r), + ), + ), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index a4c283b0..666a103c 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -23,7 +23,7 @@ func Home(ctx echo.Context, posts Posts) error { )) } - return r.render(primaryLayout, g) + return r.render(layoutPrimary, g) } func ContactUs(ctx echo.Context, form *ContactForm) error { @@ -53,5 +53,34 @@ func ContactUs(ctx echo.Context, form *ContactForm) error { g = append(g, form.render(r)) } - return r.render(primaryLayout, g) + return r.render(layoutPrimary, g) +} + +func Login(ctx echo.Context, form *LoginForm) error { + r := newRequest(ctx) + r.Title = "Login" + + return r.render(layoutAuth, form.render(r)) +} + +func Register(ctx echo.Context, form *RegisterForm) error { + r := newRequest(ctx) + r.Title = "Register" + + return r.render(layoutAuth, form.render(r)) +} + +func ForgotPassword(ctx echo.Context, form *ForgotPasswordForm) error { + r := newRequest(ctx) + r.Title = "Forgot password" + + g := Group{ + Div( + Class("content"), + P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")), + ), + form.render(r), + } + + return r.render(layoutAuth, g) } From 57d3926255dc59d51bb3e4890cb79abb3267970d Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:16:57 -0500 Subject: [PATCH 03/30] Migrated additonal handlers. --- pkg/handlers/auth.go | 39 ++++-------------- pkg/handlers/pages.go | 2 +- pkg/handlers/search.go | 31 ++++----------- pkg/handlers/task.go | 34 ++++------------ pkg/msg/msg.go | 1 + pkg/page/page.go | 5 +-- pkg/routenames/names.go | 7 ++++ pkg/ui/components.go | 10 +++-- pkg/ui/form.go | 4 ++ pkg/ui/forms.go | 78 ++++++++++++++++++++++++++++++++++-- pkg/ui/layouts.go | 2 +- pkg/ui/models.go | 81 ++++++++++++++++++++++++++++++++++++++ pkg/ui/pages.go | 85 +++++++++++++++++++++++++++++++++++----- static/gopher.png | Bin 2029 -> 11035 bytes 14 files changed, 279 insertions(+), 100 deletions(-) diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index bf4aaccc..562c20ee 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -13,19 +13,10 @@ import ( "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/redirect" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/ui" - "github.com/mikestefanello/pagoda/templates" -) - -const ( - routeNameLogout = "logout" - routeNameResetPassword = "reset_password" - routeNameResetPasswordSubmit = "reset_password.submit" - routeNameVerifyEmail = "verify_email" ) type ( @@ -33,13 +24,6 @@ type ( auth *services.AuthClient mail *services.MailClient orm *ent.Client - *services.TemplateRenderer - } - - resetPasswordForm struct { - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - form.Submission } ) @@ -48,7 +32,6 @@ func init() { } func (h *Auth) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer h.orm = c.ORM h.auth = c.Auth h.mail = c.Mail @@ -56,8 +39,8 @@ func (h *Auth) Init(c *services.Container) error { } func (h *Auth) Routes(g *echo.Group) { - g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout - g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail + g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routenames.Logout + g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail noAuth := g.Group("/user", middleware.RequireNoAuthentication()) noAuth.GET("/login", h.LoginPage).Name = routenames.Login @@ -71,8 +54,8 @@ func (h *Auth) Routes(g *echo.Group) { middleware.LoadUser(h.orm), middleware.LoadValidPasswordToken(h.auth), ) - resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword - resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit + resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword + resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit } func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { @@ -123,7 +106,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { ) // Email the user - url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) + url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token) err = h.mail. Compose(). To(u.Email). @@ -287,7 +270,7 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { } // Send the email - url := ctx.Echo().Reverse(routeNameVerifyEmail, token) + url := ctx.Echo().Reverse(routenames.VerifyEmail, token) err = h.mail. Compose(). To(usr.Email). @@ -307,17 +290,11 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { } func (h *Auth) ResetPasswordPage(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutAuth - p.Name = templates.PageResetPassword - p.Title = "Reset password" - p.Form = form.Get[resetPasswordForm](ctx) - - return h.RenderPage(ctx, p) + return ui.ResetPassword(ctx, form.Get[ui.ResetPasswordForm](ctx)) } func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { - var input resetPasswordForm + var input ui.ResetPasswordForm err := form.Submit(ctx, &input) diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index 384171ea..d271f5f2 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -70,7 +70,7 @@ func (h *Pages) Home(ctx echo.Context) error { }) } -// fetchPosts is an mock example of fetching posts to illustrate how paging works +// fetchPosts is a mock example of fetching posts to illustrate how paging works. func (h *Pages) fetchPosts(pager *page.Pager) []ui.Post { pager.SetItems(20) posts := make([]ui.Post, 20) diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 06ebd307..2ebb2d61 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -5,22 +5,13 @@ import ( "math/rand" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" + "github.com/mikestefanello/pagoda/pkg/ui" ) -const routeNameSearch = "search" - type ( - Search struct { - *services.TemplateRenderer - } - - searchResult struct { - Title string - URL string - } + Search struct{} ) func init() { @@ -28,33 +19,27 @@ func init() { } func (h *Search) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer return nil } func (h *Search) Routes(g *echo.Group) { - g.GET("/search", h.Page).Name = routeNameSearch + g.GET("/search", h.Page).Name = routenames.Search } func (h *Search) Page(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageSearch - - // Fake search results - var results []searchResult + // Fake search results. + results := make([]*ui.SearchResult, 0, 5) if search := ctx.QueryParam("query"); search != "" { for i := 0; i < 5; i++ { title := "Lorem ipsum example ddolor sit amet" index := rand.Intn(len(title)) title = title[:index] + search + title[index:] - results = append(results, searchResult{ + results = append(results, &ui.SearchResult{ Title: title, URL: fmt.Sprintf("https://www.%s.com", search), }) } } - p.Data = results - return h.RenderPage(ctx, p) + return ui.SearchResults(ctx, results) } diff --git a/pkg/handlers/task.go b/pkg/handlers/task.go index aae95641..ab95303a 100644 --- a/pkg/handlers/task.go +++ b/pkg/handlers/task.go @@ -2,34 +2,23 @@ package handlers import ( "fmt" + "time" + "github.com/mikestefanello/backlite" "github.com/mikestefanello/pagoda/pkg/msg" - "time" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/form" - "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/tasks" - "github.com/mikestefanello/pagoda/templates" -) - -const ( - routeNameTask = "task" - routeNameTaskSubmit = "task.submit" ) type ( Task struct { tasks *backlite.Client - *services.TemplateRenderer - } - - taskForm struct { - Delay int `form:"delay" validate:"gte=0"` - Message string `form:"message" validate:"required"` - form.Submission } ) @@ -38,28 +27,21 @@ func init() { } func (h *Task) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer h.tasks = c.Tasks return nil } func (h *Task) Routes(g *echo.Group) { - g.GET("/task", h.Page).Name = routeNameTask - g.POST("/task", h.Submit).Name = routeNameTaskSubmit + g.GET("/task", h.Page).Name = routenames.Task + g.POST("/task", h.Submit).Name = routenames.TaskSubmit } func (h *Task) Page(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageTask - p.Title = "Create a task" - p.Form = form.Get[taskForm](ctx) - - return h.RenderPage(ctx, p) + return ui.AddTask(ctx, form.Get[ui.TaskForm](ctx)) } func (h *Task) Submit(ctx echo.Context) error { - var input taskForm + var input ui.TaskForm err := form.Submit(ctx, &input) diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go index e8274f54..9a2adf37 100644 --- a/pkg/msg/msg.go +++ b/pkg/msg/msg.go @@ -60,6 +60,7 @@ func Set(ctx echo.Context, typ Type, message string) { // Get gets flash messages of a given type from the session storage. // Errors will be logged and not returned. +// TODO return all types with a single call. func Get(ctx echo.Context, typ Type) []string { var msgs []string diff --git a/pkg/page/page.go b/pkg/page/page.go index e5bbd8ee..7f260d35 100644 --- a/pkg/page/page.go +++ b/pkg/page/page.go @@ -5,14 +5,13 @@ import ( "net/http" "time" + echomw "github.com/labstack/echo/v4/middleware" "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/htmx" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/templates" - echomw "github.com/labstack/echo/v4/middleware" - "github.com/labstack/echo/v4" ) @@ -122,7 +121,7 @@ type Page struct { } } -// New creates and initiatizes a new Page for a given request context +// New creates and initializes a new Page for a given request context func New(ctx echo.Context) Page { p := Page{ Context: ctx, diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index e2c8b95e..15a1cb05 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -10,4 +10,11 @@ const ( RegisterSubmit = "register.submit" ForgotPassword = "forgot_password" ForgotPasswordSubmit = "forgot_password.submit" + Logout = "logout" + VerifyEmail = "verify_email" + ResetPassword = "reset_password" + ResetPasswordSubmit = "reset_password.submit" + Search = "search" + Task = "task" + TaskSubmit = "task.submit" ) diff --git a/pkg/ui/components.go b/pkg/ui/components.go index 61f6fdef..bf3d37c4 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -82,7 +82,7 @@ func message(class, header string, body Node) Node { func sidebarMenu(r *request) Node { return Aside( Class("menu"), - Attr("hx-boost", "true"), + hxBoost(), P( Class("menu-label"), Text("General"), @@ -129,7 +129,7 @@ func navBar(r *request) Node { Class("container"), Div( Class("navbar-brand"), - Attr("hx-boost", "true"), + hxBoost(), A( Href(r.path(routenames.Home)), Class("navbar-item"), @@ -177,7 +177,7 @@ func search(r *request) Node { P( Class("control"), Input( - Attr("hx-get", r.path("search")), + Attr("hx-get", r.path(routenames.Search)), Attr("hx-trigger", "keyup changed delay:500ms"), Attr("hx-target", "#results"), Name("query"), @@ -239,3 +239,7 @@ func buttonLink(href, class, label string) Node { Text(label), ) } + +func hxBoost() Node { + return Attr("hx-boost", "true") +} diff --git a/pkg/ui/form.go b/pkg/ui/form.go index c11f7eee..7f7bc3fa 100644 --- a/pkg/ui/form.go +++ b/pkg/ui/form.go @@ -15,6 +15,7 @@ type ( label string value string placeholder string + help string } radios struct { @@ -37,6 +38,7 @@ type ( name string label string value string + help string } ) @@ -72,6 +74,7 @@ func formTextarea(el textarea) Node { Text(el.value), ), ), + If(el.help != "", P(Class("help"), Text(el.help))), formFieldErrors(el.form, el.formField), ) } @@ -121,6 +124,7 @@ func formInput(el input) Node { Value(el.value), ), ), + If(el.help != "", P(Class("help"), Text(el.help))), formFieldErrors(el.form, el.formField), ) } diff --git a/pkg/ui/forms.go b/pkg/ui/forms.go index fe4deb75..e025ccc7 100644 --- a/pkg/ui/forms.go +++ b/pkg/ui/forms.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "net/http" "github.com/mikestefanello/pagoda/pkg/form" @@ -35,6 +36,18 @@ type ( Email string `form:"email" validate:"required,email"` form.Submission } + + ResetPasswordForm struct { + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + form.Submission + } + + TaskForm struct { + Delay int `form:"delay" validate:"gte=0"` + Message string `form:"message" validate:"required"` + form.Submission + } ) func (f *ContactForm) render(r *request) Node { @@ -80,7 +93,7 @@ func (f *LoginForm) render(r *request) Node { return Form( ID("login"), Method(http.MethodPost), - Attr("hx-boost", "true"), + hxBoost(), Action(r.path(routenames.LoginSubmit)), flashMessages(r), formInput(input{ @@ -111,7 +124,7 @@ func (f *RegisterForm) render(r *request) Node { return Form( ID("register"), Method(http.MethodPost), - Attr("hx-boost", "true"), + hxBoost(), Action(r.path(routenames.RegisterSubmit)), formInput(input{ form: f, @@ -157,7 +170,7 @@ func (f *ForgotPasswordForm) render(r *request) Node { return Form( ID("forgot-password"), Method(http.MethodPost), - Attr("hx-boost", "true"), + hxBoost(), Action(r.path(routenames.ForgotPasswordSubmit)), formInput(input{ form: f, @@ -174,3 +187,62 @@ func (f *ForgotPasswordForm) render(r *request) Node { csrf(r), ) } + +func (f *ResetPasswordForm) render(r *request) Node { + return Form( + ID("reset-password"), + Method(http.MethodPost), + hxBoost(), + Action(r.Path), + formInput(input{ + form: f, + formField: "Password", + name: "password", + inputType: "password", + label: "Password", + placeholder: "******", + }), + formInput(input{ + form: f, + formField: "PasswordConfirm", + name: "password-confirm", + inputType: "password", + label: "Confirm password", + placeholder: "******", + }), + formControlGroup( + button("is-primary", "Update password"), + ), + csrf(r), + ) +} + +func (f *TaskForm) render(r *request) Node { + return Form( + ID("task"), + Method(http.MethodPost), + Attr("hx-post", r.path(routenames.TaskSubmit)), + flashMessages(r), + formInput(input{ + form: f, + formField: "Delay", + name: "delay", + inputType: "number", + label: "Delay (in seconds)", + help: "How long to wait until the task is executed", + value: fmt.Sprint(f.Delay), + }), + formTextarea(textarea{ + form: f, + formField: "Message", + name: "message", + label: "Message", + value: f.Message, + help: "The message the task will output to the log", + }), + formControlGroup( + button("is-link", "Add task to queue"), + ), + csrf(r), + ) +} diff --git a/pkg/ui/layouts.go b/pkg/ui/layouts.go index 6e7edbb2..71260c5b 100644 --- a/pkg/ui/layouts.go +++ b/pkg/ui/layouts.go @@ -58,7 +58,7 @@ func layoutAuth(r *request, content Node) Node { content, Div( Class("content is-small has-text-centered"), - Attr("hx-boost", "true"), + hxBoost(), A(Href(r.path(routenames.Login)), Text("Login")), Raw(" ◌ "), A(Href(r.path("register")), Text("Create an account")), diff --git a/pkg/ui/models.go b/pkg/ui/models.go index 5071ece3..36ba9b60 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models.go @@ -1,7 +1,11 @@ package ui import ( + "fmt" + "github.com/mikestefanello/pagoda/pkg/page" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" ) type ( @@ -12,4 +16,81 @@ type ( Post struct { Title, Body string } + + SearchResult struct { + Title string + URL string + } ) + +func (p *Posts) render(path string) Node { + g := make(Group, 0, len(p.Posts)) + for _, post := range p.Posts { + g = append(g, post.render()) + } + + return Div( + ID("posts"), + g, + Div( + Class("field is-grouped is-grouped-centered"), + P( + Class("control"), + Button( + Class("button is-primary"), + Attr("hx-swap", "outerHTML"), + Attr("hx-get", fmt.Sprintf("%s?page=%d", path, p.Pager.Page-1)), + Attr("hx-target", "#posts"), + Text("Previous page"), + ), + ), + P( + Class("control"), + Button( + Class("button is-primary"), + Attr("hx-swap", "outerHTML"), + Attr("hx-get", fmt.Sprintf("%s?page=%d", path, p.Pager.Page+1)), + Attr("hx-target", "#posts"), + Text("Next page"), + ), + ), + ), + ) +} + +func (p *Post) render() Node { + return Article( + Class("media"), + Figure( + Class("media-left"), + P( + Class("image is-64x64"), + Img( + Src(file("gopher.png")), + Alt("Gopher"), + ), + ), + ), + Div( + Class("media-content"), + Div( + Class("content"), + P( + Strong( + Text(p.Title), + ), + Br(), + Text(p.Body), + ), + ), + ), + ) +} + +func (s *SearchResult) render() Node { + return A( + Class("panel-block"), + Href(s.URL), + Text(s.Title), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index 666a103c..4b173e2a 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -1,28 +1,55 @@ package ui import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/routenames" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) func Home(ctx echo.Context, posts Posts) error { r := newRequest(ctx) - r.Title = "Home" r.Metatags.Description = "This is the home page." r.Metatags.Keywords = []string{"Software", "Coding", "Go"} - g := make(Group, 0, len(posts.Posts)+1) - g = append(g, Div(Class("dashboard"), Text("hello"))) + g := make(Group, 0) - for _, post := range posts.Posts { - g = append(g, Div( - Class("post"), - H3(Text(post.Title)), - Span(Text(post.Body)), - )) + if r.Htmx.Target != "posts" { + var hello string + if r.IsAuth { + hello = fmt.Sprintf("Hello, %s", r.AuthUser.Name) + } else { + hello = "Hello" + } + + g = append(g, + Section( + Class("hero is-info welcome is-small mb-5"), + Div( + Class("hero-body"), + Div( + Class("container"), + H1( + Class("title"), + Text(hello), + ), + H2( + Class("subtitle"), + If(!r.IsAuth, Text("Please login in to your account.")), + If(r.IsAuth, Text("Welcome back!")), + ), + ), + ), + ), + H2(Class("title"), Text("Recent posts")), + H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")), + ) } + g = append(g, posts.render(r.path(routenames.Home))) + return r.render(layoutPrimary, g) } @@ -84,3 +111,43 @@ func ForgotPassword(ctx echo.Context, form *ForgotPasswordForm) error { return r.render(layoutAuth, g) } + +func ResetPassword(ctx echo.Context, form *ResetPasswordForm) error { + r := newRequest(ctx) + r.Title = "Reset your password" + + return r.render(layoutAuth, form.render(r)) +} + +func SearchResults(ctx echo.Context, results []*SearchResult) error { + r := newRequest(ctx) + + g := make(Group, 0, len(results)) + for _, result := range results { + g = append(g, result.render()) + } + + return r.render(layoutPrimary, g) +} + +func AddTask(ctx echo.Context, form *TaskForm) error { + r := newRequest(ctx) + r.Title = "Create a task" + r.Metatags.Description = "Test creating a task to see how it works." + + g := make(Group, 0) + + if r.Htmx.Target != "task" { + g = append(g, message( + "is-link", + "", + Group{ + P(Raw("Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.")), + P(Text("See pkg/tasks and the README for more information.")), + })) + } + + g = append(g, form.render(r)) + + return r.render(layoutPrimary, g) +} diff --git a/static/gopher.png b/static/gopher.png index 0266be0cf7f9de5326bf713605d0e68a30816762..24fe5c1d865253c2c49619bfa13bda2114d7a70f 100644 GIT binary patch literal 11035 zcmV+$E9BIPP)EX>4Tx04R}tkv&MmKp2MKrWQpi4i*t{$WWcEh>AFB6^c+H)C#RSm|Xe?O&XFE z7e~Rh;NZ_<)xpJCR|i)?5c~mg7n~Gbq{Q!%LW>yhIPT%S?|r%Z4iM^Prdb{1fTr7K zDiIem*;TRg6$1KchA<{G%b1g-Bz(u$Jpz2ci}5V~x<5ycnzI-X5Q%4*VcNtS#M7I$ z!FiuJ!ius=d`>)S(glehxvqHp#<}3Kz%wIeIyFxmAr=d5th6yJni}yGaa7fG$`>*o ztDLtuYvn3y-jlyDoYPm9xlU^c2`pj>5=1DdqJ%PR#Aw$^v5=zkxQ~C(^-JVZ$W;O( z#{w$QAiI9>Klt5SD?d5mC57Wa=*4k9hJnsqpiy(2?_(i|qi@OreYZgOn%B4HIZhvdH1#TZ0~{Oz zBL&J{_jz|$d*A-8Y4-02X%%v#yirO2NG6(gMNXw&uBd7-gD3W-nX6iya$FcjA0C87{eIG zFoq!w55bj>?0LB|5Q`9F1Vl&@PLOu%P_!*SPah`HFwR4sy91GUI6Nie{)Dm9vVvn0 z4MwvC)$O9HXup1F!^$?<;t?GyfSOaQ%go<&yi%s&}W;d)J%Zcm$To)B)Wni z;Gf4W{N{ZLW2W@^TTS%dt0I$te7W9O?5s1s1TH9xPT2PtGU z`v?7GE^BZ9s_M>3G&|%U>b-bNrbL@0E0qngz@Q=ITOZn{EF7 zp-Cg;z=&wj+)z{H&p=D!QiPYH!8mXv6Jj!`0p<+8b*% zM?H8BKNa@E#(P(rK=)z-YD6-dHErzVMr07v$&tYM4ZQ5@@kW zQA``IfB5O?p>fImw=smEvT(2X`rW04o(|`21^Ic!XGyj(zz>0($n??HZ-}v)!S8MYP1^IbLv*+ce1J6pba%aMX z8O9OWHxm#MgCt4Fih{{%>(#BklyJ5-Q-7kAV|%wzU37rN@zY6~FoTeoM1&B0_+L+z zx7JrBZ(V))VnkOBiDuk#&lmHb{LPK_knjN?qp{*R>tFfpZq4IajU;_9e%v(csOuLH z5FUNTRaFd~1iJEu#ns+QWzl{PZ~PR+Xd*H_i#?zG1CQDR%W8HPx_daeMkv#{qpZ^&G$ucIJ-xWz9uPbjRC@P-RDYFTP zjN!z-uW6|+kBm#r?1)Vnv-f1_0ezT6y&yg2lQl2H&vaWs^|c%_0^bd zei%%aUXNu9457BHkWsT2bSH}>VY2xl%U6EbOp+x0f~if8r_#H55pCpZgDo(G z91yfHJZVIaPo|elcI=@M)Rh&{-dKmM7_j&Up7Zmwk~#%g2`g#YK0`p1V+S= zGI^Hl?r=_OI8l06d}`LV@{)rU!zAiTqLi!y4JV54jm?@A+>lWd zCW{SwNI3psk@$y3b(4M<8@0!a+3@P`b!SukuYse$bRmcbVUr}O4JZS41Dhp96mR+T zU2#p2sFO%b0;1wx9KY~hQ+Ps}G*}5@vikWPQ0!=HrsBvxO1E#My66B=ApxYP#FH2w zOK5NqvMi(N8ntze96NE6BPGXZba;r(nncRv>xfFt#9%UC)Nyh<9qjn{?=&3P$}Klv z$JneLk(>g!p}E(ukR< zQ*V%>(nm`JdCDbO#?p&w>mm3ZIG$bMIU$Snx-58!U z5?SfnUocs1eY*9I=0*zgULs6war@w)4B^mXbdIUecz{z97>q zJ3e`f+cJW=^Y&W@Mnad1cUOGE^RF#06GGfkke|12$arUc#F5&{OAf9I3yU_?9xa?* zad;Q)jdcXaB%GHdMvL{lglMWR=kpi;lRKto@WZG6m8i&w3l`0mWtoKdIOfdGreM{3 z9IL1yGBsmRL~^z^Qxs)I5rpzjL8~BQhZdTPPtKi~j29;m>E~x- zX7&`0Y}-I#*~v~K4UDMOrH7fB=+DFnV+R>`l1NEPBr!J9Z{6mv7RIM#EiW%Q*m~ZF zVknSE79v=ZknQ#orLcTgbr`1F*W<1x89*!l6BJn`s#*zJCos(ie1HUIR`_qg|=f1|Rpnm;`AW7|E8<~|!ca(0hTpW$j}PxicA6OuH- z9vnWX36y5hFJiN8a3iBNm52U`L7#}3_(dG*%gB^+L!2aPpY7J+d6U=-HM@~(;&L8 zQ(IThvE$`5x3nUJxFA;(78=5xv&U0addR1C{$WuxG&BzC+_iP}ytM57(|<=tN1gs` zvsw+!Ev-w2s5%pgCYxO{3?iMw+0saD;XX!Zjp`TScJ1EBzdij+ZolU-zW4i%M9x>8k}Z=H$vl zPMra<_Rt9MK@yxTH5w|85fd3oWJE-tZP%{b$S;5U7lZ&=miW=X{R?CIK|WuQlAMTI zeh5#8vtLs)0A{NnhfX#P>bypy5h2d_9j`B6$$>*f#70N3di^$B9?wW*Lhvrc98!jL zh&3?8SN+xP&z71>rcE5vXH2O4WCg$e?-x%u*EF4!l%zgM>MIP2!i_T~(NtaTlR?$u z7fD-N`+(1zm=I55T=xfQ$G$>7%HNEtYFI61GcaN(dS@XfA9I4vzR7J3HAIAm_1SLI z=B=pO84pO7B`Pbs&kDHRZVn$Ae1VSi)MT1#d`nIYNgT=X6XgRwr`2lVXHP$Y&3bM$ z|3^5XA^B+G+Tx<;O?GBc| z_aR}SA&kmMAJj#KhKA6x-M7TVz{ptk9$8D~sDVeWM`w-VZ!i3gwd*!fUS2^+NHDW! zWiu*c1joDt4IGLj@_GJGoMi+{veK^@OHb-<-{GTt>(L(&7ZpZhb1O|P?fm559~tyT z1qBAs(c0|WKp2edt<%Wyr~{sa1&E7{;eq?^IX4#N4LK-7QJuNZmMr~IXI)jAB_NRY z_P%2*>1ioFe(&+996eD*Q;XLgNr;OZ{F9+5Xdd4q*S3UF96Nseq6~C>V>5UwoD4Y< zwYl0_d=CtCkv#U`a1Iuh^x5Y6nc4VR&!5^laZLK4RoP&S^Wc~Sw(l#tCFhXfo5;v;{^KX#>s3`mh6VGZfB#pE7a(=S;c%ju ze4h)G6$3l#1rDe4f)29N*};j5Y87uOoFPY|ldZK?gCtQrPPfMK^1eNr8Plim^8Y@= z-0P=cHJeamnE<=7+`yE{6E0Xf%fW+306yQE|E@mtB#}g;u6n9Fe6)*% zu6J?uC6Xsy$6N2M^hpf_2KW;m9(KVAB!po5u6+bW4a`87B#D;5B#KKf%+h>S)%ft! z&q3mi9#!bo&Y^<*JU4Kpt)X^MDlIB46OT2RZC~xU?AO%L(8%_~#|Vsw?Tc-=o$cuA zx#fvsMvi4={)P+EpF>9qIZ$}?E1<{0847VCfE`WM<=4lI^ew+wSJl(G*owi(xLfb$ zs5I(Z^~ezwbYG23_UCS}@9y{3Y6w6{{cZ5>5luEE*Th-@&DGNAD|5)!(j84ZEX5#?;3&hVXq$H5Fyd}`eQ|^tG(5CyQs?m%d*1cJMZU}|8JU3mi5@Sx#BpLMf*?x?H3eEm0uEH z7Hk_(U&>Eb@Vh_1+yp$nHGg@pOdJwRhT>DRoCtA8MCzy*dq{*&Z6!tNw%E~Zvl9>% z$BNhf%IulbE~#2;Y;5AE&;F6|cRlLeashC6IN0>YpQ+fony2ob$Kv_3X>V<%wBlSF zaAaHrR2Je1h{QiE@^lPr`OD8UW722>0|I;qF*Nau-~5kyyrSm5g8aNK=lkuDAW?bA zLE=-hM%Y5aXGA3XKKW=kYXUbgJQ}Ai^Th}6Ff)5H7K`PgUxTV@JomyC6~Al9cE$lA=Nwl@!CAY2#RY%WTGuNak7>uTK%I}3Gq*B}BVBLy5rcEA?&35q$Vd=VH`FkI-de1SY-Sr@HmlWNd zAHPXkeJ%IjaTDgw2(~0i1lavZii;*QJ(;ZZWD;T`F&Yh+Oh&%izMGKDX{T#a+C#!| zN@m`E=`SS3#}E}6(c@|sP1D%2^=p3m%TI;F< zx>khk2aC`(^_&&6k|dLL^$}Vp5Yfi`W|Vi$rrreny$0rqg52S93#oF~) zjWRJ2VLdME7D7;R{3L&Ue+}dA`3^lTq4E!lBz@XV=t0pGR=2V1Xcb=QPmdjRjw$kD5cm*c|+V z!v;84CIAM!@^MI!NNCy-9ga3u97~Nz$?(lKQPsZ75K9u_iRs+<@Y6J$EaR2+UsCi~91TK9fjSJwX4Y z*C|^mG%gwSmRy>u%E>=bN`1j*TI;HCwYQ+F9%O?Fi+>=2k+Fovr!eKwU*I1Wi7YGq zJdd{oj;AvU-C9>gTz2>QwBm{eqBE597J=_4iJZeEI)g83%L*SIHR~3iwwDq@ple#c zs;qSzK$DrUgjB*3QoG+2A&_LL#|?meI=3Jt+Fm5bZ@N2H%WAD*+i~g-A{W%8$$P`0G5ugYx}QexE&pYh2NkgraX`{&|?g# zS^S&_myQ#D8We)1jU753$ddZ&L0dM$QD*tsMU+h)|(&UnL%gjwGE{ z?UHPg^lO?#IZKw#62kMX^F4`OZ}yq*I*B1bHdv5k(-{(+Wn{}{0}+{#nC%vCyUrUf zx*f9*3UtXVI3v}(l8(-Ix$|98WMsul3`v%~Z6ygwl96S_OBka8gUNXMIHw5K_1LG+ zaq3znh#Z;5z7=2NYCWAsWC9^GfaJ6xL86?8U-uKL^TmOAn_eOr%%R9i&-p~MK_+TU zG*OxHm~ECWH>^{wo%8vWbT}h9q^{EA3V*UC z2U)SA80<)L?*T-`q>yrb8bNX1HCu|&I5eWx=Rr$tC6XlTz(IPb3Oi&;bk~xld~(Nj4+PrarwQYlxM!8#1s2SWinNquIhxM|1T_WF<%kR=xegf@@>lSV({B1VBcW+aK%fa#gDux3Dx;MA=KZ~Ljej&Qcz4G zy&B6st2>Fr>?Fd|qR;W`3?_4*>Te{+iRJ{DV5;A7_BYlt*zH4og|2Co7Hp?%%N}eY z0ZhF=#~ZosFZmj3FqxxVk|h1(?H3m4LzYB~m%MHes^bqrXy56{jh7WGiYcV){7;`y z%nGTqM-Uhj+@p79=wm6h$K}M(vJb+&M*4{4aMo{JhyA8p20KgYYImR*3>eH_NvL@| zG*?wqTUB(+eHR!5JN3(aSn89l5Gri?(G*z9zWDacw{yy0Ibj6Zr@q^0(gy?uf z2>p(ptP#Z+h++)z>6qpKGwC;FU=8ZIT9{(&+XxrBPDx1_3E|#~Y(|5^b;8*jg` zpngb_Xz_!u#_GE3BhV-ItiHmK72ooG{Gp9LN8CamrEBr~PG1G}Hs zgBzO>N735PaD;>pXe$Mw3yRhipmw+csQ7XxPd#=cAt9NVO$Oe|+rpL|<@i~R%$$;d zEW?^jrKgD{d`#)pHI3?`{ld}O_~C`PUsqg_tDFa4%@Vrj3lJISsxJ!$a|E*N+XgJ5 z>0zYI8i~Q&dpk*z`W>D23y-C6%NJy(cuToRl0ajY($Xm)_%8Ee4_^>@1p} zMN5l=zr41E(y}UIqJmDhRo!miRA>27uAh{`{kgM8{_496-txB@|2Sj*V(F?UQO<*} z%o4i#={YXEq+l>dpcn&vGF)YYOzP}(V#Xx&YwE(+8TzQSOx8EVQC5Ct&0lMq1J&Iz zKv&(n-Im?YgumTNd8M}|_x*QI;@c0*BrZ0DmR4`|7mLM2Wwm!##>lJ`lBZ1c>0FgZ z_K}emg9M}}M;j+*#y%mC0s*9N-_DtB6V+*!JelC%wzQG|ig{858A?EJ>s!M&j&nQ&ZbQ z#)w!R`PLN7W+R_`ewg^FqshAUCKMlBd)4iteD5~wHj5^NpsB^d-omPCNk{{(PjuPC zt#>VX*Ww86)#lZMLl4qo!*MRN`7Q27HqSQUX(cC~=LoFdOiQTKe z_S>=+9N)E<;&oqxuJgNJEW&QL(A@06Vm4A-TE(kxZR7TNqwsh%R&OXGdin$sCy)1e zg1D;WAZwoc{7_7R zrxK-nxf8UulyKc6IhbsIK8}Kf)gExUpC=rRO%$%*LdVH!W=@XdaB)4~ee^~?Tf3Lf zH%pxW$V?gKF_Wo-WZv6BI1^Icuzfk?YEb1(0$uE@ZziX`E>BgG;CBLi?!@o`t6fJdjD# zZZQwF4b{^GLPL@yT#h#Ug2S##5}oQx)W|eK5@Ol1bqB{k+=37SlikX=ocRPsMGpAb zClBwz?P%Lrke^p_Rgq}%L(A+!_bd`Ze_Yo+*{7wSB%>GtQH;LZJ>Gy>2b!lDp}S6N zW<~L%wXV9`4n0>ZOjaxDH(p2h$TZ5f?PA0Y+1P@E27EkS(>SzYCBR=VbYBe@hd8fW zblo#ULc34bI_~J6`y-(k>|U4MXG2z?YfdyzYZvl#8F*754JVKGxL4TKilCS%#((41 zK_92O^biflijN~npIosd%6Vv607AW42<;oX=9~*6N}Lmmv7i`(dtF)48*zm0MpN4n zniC;<&z&+F1F1Z`hw-=M`c$=bpnK3?EfBiKzBL~J{9nQ9<<2XHM2jDK%_4+8T?l

*3l2eqUWzi8;KS_v@BTY?(ger z>*F9ox)Mf`jozGQ+1&Zr*fVK_-dRLI^^!)MdcUxh$*k6B%65Lqh#7PGH7kFHNV;$H za#sn8zurL8$+8mQ`HOXqK1r1G;L-)U*6~*maoq`H>Xk5Ff_TnI7~w|f>LnR8izS@e z+CplN6%m!%|3$Gv(?K3uBxLpB6cbi!3`GTNiA>4p_f&?isl9F?eloWBmnrH7wi9Mwm*(QvYi(72TT4Z5mg z970m);Ko%{9@|K7@5;&Tr@pxBfxjxC$AL5xSP0Ak zA};kn+WnKLE!jrJk-fx@n$SN_#^b?sl~?{~s5r)^Wq(2l@oYhU-p7~Xoab1@-+X*k zC{PTn2L9vi7Zw~E%n5QHc+o0gDsUH&1H^ENvIpAQj^n0=xsU%Gi+^ChhQngF4>%>{ zN-(6?yzmT;hT69zN!-6>^}E{TBGE)3;O!R{tiRAFyYqp+nqTD?gJEcWudk*LkVVec7UG-&pdzSrYQ~ z!0o`TKnfQ&HKiT+qQhDHMPt+6UuE2ITj1!M?&w#wGK-&m(3{;|Y8AHb<^RUfSo;}> z+=Bdf+b-q#uZR%s!o`1(On$a>39^ClKn9Qq!~ym``YJkYx1)HgTkHY~B*E7AUVp3| zAbVc!ZNR&uZeC>0y#Cfc2_h>7%)TbJT_%(t+{LD4e?oOR-w{H3aO;}o&P&~gD>lx5 z*OC_{p(7&^BtZxe4EW>S)lTaqP74yXk|e5xE~)P?d#u-3L-xGf#lW)IF;i^g<}dDZ zeVD;)!FY*lS^EOjg8aOXE@yv~Yl%eVB?rsnQ!`)HRaL4y zyn9l`;XOvb;4o}Kp*=1*)HD@EQG8yEc(LGev~h6#N;bdozoM^Cap*rbOT&;2|U_E+Q=p zo1cxR7bO!K>`j}}b;0}9X;`CEFAi~Zo%W`Bs!I=1wsQ-WhjuZ3L?nymP9QBg8iOK} z_ep{0y)_4t3fAORTq$E`7^aUG1^Ic$fM3p-clUn^A!gSU?f=f5H)h^`ia-E|ODd># z1QHh-i()hnE{RPDbWNkZsgCBF3Tn!Vs5-Kj`eTJUNGE`IfWk2&V_(ili#>g!k*P68 zK0lBRyf;juD->Jum%D)cTfTAM!g;gDp8meI&B?3FH*4j!t7OG!mcmm;;U6AFKzKA3 z|3FMu8)iR$3`P@@pifG5w3Qgdpik8na;%^{CqJw%t4}^;;o9_S0(Y zA0HLC%j;UFfn?Dq}x!;;Q26}lR}0%!qF0;PE8Cl4Y?dnB2? zTRzJ>XZgyP`FWZ?FZbznUmjR_%Z=j|FPR-k5>3M-y5b?GrX@$6jz$(9tzhj}rJrui z&)ePk)z&VyROh}`lBIKLlpV0(ox;%p7tji#5hRBIu{A%h-(FQge%@+Dmj2hzX8!l& zgb3dMY)8F7S~*OjD>W@Ej?rBsn1phttKz9c9fRt-gGATAl~^@s&DQ!zAiC^6~lWAMdQ$x~D9{ z?a@Sghv#<%`FV}Q<1!p4@-=Q04crKvlmwf$tjW`dM`akp7{)M$F^pjhV;H^R{{#Ac VAL3E^Ij{f#002ovPDHLkV1me6ttbEh literal 2029 zcmVC0003jP)t-s|NsAL z*W~~J0QS{zwIQ>?eq(9mJV)4aR8Y;0@*00!vi=b4t4Ucb!O*4C$`q_(uQNt>mssi^=eF8~b_ zkBo~&lbsg;03lpsf_{C-$H#YfcUxOqJ9vOcKRy}&0G^zjDQ0cmy^;VVDIX6DJ~cBv zhL8XoAOJNwH+zH^PgZ7PVH7ATJUl#1OiXfZY^98CiFI1LpL@=*hkIvDHZ3avKt?GY z8zxg(BS=geHaZ*}94aa*5D*Xy85%h`ITkK34HzLfBpgykHYyDOE+-xv7ZD=>05lp6 z5GOEgS3{bBVo5tJOiofGBPot`S7J;xvX^r#ArC7g96>iKZDyH>00009a7bBm000XU z000XU0RWnu7ytkO2XskIMF-^!7#1-fiT-D0000J5NkliV}KENamuw5%p0>t<IfhNlV)tHp3L?JllNiY?S$TN$4<hf8Wyct2YVwh(cTfo&WE4F2%Op9@$WCk%jfg0gXb@ovvm@}h8Z|UVjoU%vJ zQj=RyvrBE~xMT)^=n?=uW0@d4@i`4Ad=<$m4nV_F^WHTVrU0x&`rtxy0BGN;((m~M z*v+zOU^kVuEg-o@`ihn52`OTgd~n(3(g9FFr-P*LrMP5J^4#f4VTlj$^@bc-r~tpZ z-W*I9$tGN4LScGuVmd7`&5d{NHO~%7=Awm54}k(CoV4>!LzN{%WS4|57K6xrz^3Qe zbYR*SdOBZJAFyywsY2#@neIIpfS5>t)B6-~KByM+L@9H(_$>_aS=Thev$?_!4gh9y zJ(rrHH8yX+5n>!V-Vq;8+*zyNNH~>~*~T7h5Y1H-J5X?;B;0{&`ykhYKAlUJdN_cl ztFe5ubx#^TCzA4N zH`|qES?zYavKnI#XI>Qz+%8b}?3gCcihAs|XUL-fc0D&@PvklAvQ@Z-S_r8Zz@IUu zHKwX6e~`OgH7RCeGP|hHi9S+QZ3KHzg5T9xOndJ&1f;PU0Pr&=W7FKwQ&1_%!AgMgC1fbHd*;d`n`fxAz(M$LpA^s>O9bQg$_M)JPZc0I7HczKuVxp9y@u zdm&^g*7v2z7vGfA64YVsi2wl98BGTd zfIFjU3II650GtT`KqZAnzyR@lzyKl+D1LGPT>!u_0wAFWybvWr0%E~c&;X@?hX9oi zh;TwZ5%4t3VlFv~IH<&Zz7`M*a9=L_KmSQ7E+G<-6XJ4G0cm+Yjshs4w`LOwC)kXH zyhZ|)gLMJ47m_kkzf{ESP7UluCTnYgaYF?nXaK+jM4vYa-M2UZu@qcL0wn4`AnCgz z03PlauRQ=qLi9^6aQG*CkLC(B#{FQA2Pi zcPHx+q%OL0ECyoq581Ub?T^9tBhbk;KciQL{N(1#^t#wxpMKApq@+49cHX(huf&qo znA04)zGpsW23lr!pA)ZCi+Cif0tVr$CXcuQK5?(Ef~hYzgrboAWO~Ov!ajJ!AO>jx z0I%41#y&fDx05aC7x0(P8fc%P?-_=sQm^+4(NvCXDQa-Dk z0JI8oJFvFpy4v4y5v$PGnAY|23xOTD6EqFeu$Pp!vX!lDWn=OmEGdNLJiaz)00000 LNkvXXu0mjfufm`m From 247e1272a1326a198de0b5a49c420157513a2729 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 08:58:01 -0500 Subject: [PATCH 04/30] Added context caching. Cache HTMX request in context. --- pkg/context/context.go | 31 +++++++++++++++++++++++++------ pkg/context/context_test.go | 23 +++++++++++++++++++++++ pkg/htmx/htmx.go | 31 +++++++++++++++++-------------- pkg/htmx/htmx_test.go | 6 ++++++ pkg/page/page.go | 2 +- pkg/ui/request.go | 2 +- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/pkg/context/context.go b/pkg/context/context.go index 95e18ad9..fa1a001a 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -3,29 +3,48 @@ package context import ( "context" "errors" + + "github.com/labstack/echo/v4" ) const ( - // AuthenticatedUserKey is the key value used to store the authenticated user in context + // AuthenticatedUserKey is the key used to store the authenticated user in context. AuthenticatedUserKey = "auth_user" - // UserKey is the key value used to store a user in context + // UserKey is the key used to store a user in context. UserKey = "user" - // FormKey is the key value used to store a form in context + // FormKey is the key used to store a form in context. FormKey = "form" - // PasswordTokenKey is the key value used to store a password token in context + // PasswordTokenKey is the key used to store a password token in context. PasswordTokenKey = "password_token" - // LoggerKey is the key value used to store a structured logger in context + // LoggerKey is the key used to store a structured logger in context. LoggerKey = "logger" - // SessionKey is the key value used to store the session data in context + // SessionKey is the key used to store the session data in context. SessionKey = "session" + + // HTMXRequestKey is the key used to store the HTMX request data in context. + HTMXRequestKey = "htmx" ) // IsCanceledError determines if an error is due to a context cancelation func IsCanceledError(err error) bool { return errors.Is(err, context.Canceled) } + +// Cache checks if a value of a given type exists in the Echo context for a given key and returns that, otherwise +// it will use a callback to generate a value, which is stored in the context then returned. This allows you to +// only generate items only once for a given request. +func Cache[T any](ctx echo.Context, key string, gen func(echo.Context) T) T { + if val := ctx.Get(key); val != nil { + if v, ok := val.(T); ok { + return v + } + } + val := gen(ctx) + ctx.Set(key, val) + return val +} diff --git a/pkg/context/context_test.go b/pkg/context/context_test.go index cee5bc7c..536716af 100644 --- a/pkg/context/context_test.go +++ b/pkg/context/context_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" ) @@ -22,3 +23,25 @@ func TestIsCanceled(t *testing.T) { assert.False(t, IsCanceledError(errors.New("test error"))) } + +func TestCache(t *testing.T) { + ctx := echo.New().NewContext(nil, nil) + + key := "testing" + value := "hello" + called := 0 + callback := func(ctx echo.Context) string { + called++ + return value + } + + assert.Nil(t, ctx.Get(key)) + + got := Cache(ctx, key, callback) + assert.Equal(t, value, got) + assert.Equal(t, 1, called) + + got = Cache(ctx, key, callback) + assert.Equal(t, value, got) + assert.Equal(t, 1, called) +} diff --git a/pkg/htmx/htmx.go b/pkg/htmx/htmx.go index cabc9188..e897c42d 100644 --- a/pkg/htmx/htmx.go +++ b/pkg/htmx/htmx.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/context" ) // Request headers: https://htmx.org/docs/#request-headers @@ -28,7 +29,7 @@ const ( ) type ( - // Request contains data that HTMX provides during requests + // Request contains data that HTMX provides during requests. Request struct { Enabled bool Boosted bool @@ -39,7 +40,7 @@ type ( Prompt string } - // Response contain data that the server can communicate back to HTMX + // Response contain data that the server can communicate back to HTMX. Response struct { PushURL string Redirect string @@ -52,20 +53,22 @@ type ( } ) -// GetRequest extracts HTMX data from the request -func GetRequest(ctx echo.Context) Request { - return Request{ - Enabled: ctx.Request().Header.Get(HeaderRequest) == "true", - Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true", - Trigger: ctx.Request().Header.Get(HeaderTrigger), - TriggerName: ctx.Request().Header.Get(HeaderTriggerName), - Target: ctx.Request().Header.Get(HeaderTarget), - Prompt: ctx.Request().Header.Get(HeaderPrompt), - HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true", - } +// GetRequest extracts HTMX data from the request, +func GetRequest(ctx echo.Context) *Request { + return context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request { + return &Request{ + Enabled: ctx.Request().Header.Get(HeaderRequest) == "true", + Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true", + Trigger: ctx.Request().Header.Get(HeaderTrigger), + TriggerName: ctx.Request().Header.Get(HeaderTriggerName), + Target: ctx.Request().Header.Get(HeaderTarget), + Prompt: ctx.Request().Header.Get(HeaderPrompt), + HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true", + } + }) } -// Apply applies data from a Response to a server response +// Apply applies data from a Response to a server response. func (r Response) Apply(ctx echo.Context) { if r.PushURL != "" { ctx.Response().Header().Set(HeaderPushURL, r.PushURL) diff --git a/pkg/htmx/htmx_test.go b/pkg/htmx/htmx_test.go index 93fa06ef..4963197f 100644 --- a/pkg/htmx/htmx_test.go +++ b/pkg/htmx/htmx_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/tests" "github.com/stretchr/testify/assert" @@ -29,6 +30,11 @@ func TestSetRequest(t *testing.T) { assert.Equal(t, "b", r.TriggerName) assert.Equal(t, "c", r.Target) assert.Equal(t, "d", r.Prompt) + + cached := context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request { + return nil + }) + assert.Equal(t, r, cached) } func TestResponse_Apply(t *testing.T) { diff --git a/pkg/page/page.go b/pkg/page/page.go index 7f260d35..48f5e116 100644 --- a/pkg/page/page.go +++ b/pkg/page/page.go @@ -99,7 +99,7 @@ type Page struct { // HTMX provides the ability to interact with the HTMX library HTMX struct { // Request contains the information provided by HTMX about the current request - Request htmx.Request + Request *htmx.Request // Response contains values to pass back to HTMX Response *htmx.Response diff --git a/pkg/ui/request.go b/pkg/ui/request.go index 42d59753..91ca7bdd 100644 --- a/pkg/ui/request.go +++ b/pkg/ui/request.go @@ -48,7 +48,7 @@ type request struct { // If this is populated, all forms must include this value otherwise the requests will be rejected. CSRF string - Htmx htmx.Request + Htmx *htmx.Request } func newRequest(ctx echo.Context) *request { From 8c5396867a83a53172d77871cb9b731e3610e945 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 09:28:58 -0500 Subject: [PATCH 05/30] Use gomponents for email body. --- pkg/handlers/auth.go | 2 +- pkg/services/container.go | 2 +- pkg/services/mail.go | 85 ++++++++++++++++----------------------- pkg/ui/emails.go | 16 ++++++++ 4 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 pkg/ui/emails.go diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 562c20ee..c7264b88 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -275,7 +275,7 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { Compose(). To(usr.Email). Subject("Confirm your email address"). - Body(fmt.Sprintf("Click here to confirm your email address: %s", url)). + Component(ui.ConfirmEmailAddressEmail(usr.Name, url)). Send(ctx) if err != nil { diff --git a/pkg/services/container.go b/pkg/services/container.go index 05c9cae7..db5e04e4 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -204,7 +204,7 @@ func (c *Container) initTemplateRenderer() { // initMail initialize the mail client func (c *Container) initMail() { var err error - c.Mail, err = NewMailClient(c.Config, c.TemplateRenderer) + c.Mail, err = NewMailClient(c.Config) if err != nil { panic(fmt.Sprintf("failed to create mail client: %v", err)) } diff --git a/pkg/services/mail.go b/pkg/services/mail.go index b4b21c38..0115c038 100644 --- a/pkg/services/mail.go +++ b/pkg/services/mail.go @@ -1,11 +1,12 @@ package services import ( + "bytes" "errors" - "fmt" "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/pkg/log" + "maragu.dev/gomponents" "github.com/labstack/echo/v4" ) @@ -14,32 +15,27 @@ type ( // MailClient provides a client for sending email // This is purposely not completed because there are many different methods and services // for sending email, many of which are very different. Choose what works best for you - // and populate the methods below + // and populate the methods below. For now, emails will just be logged. MailClient struct { - // config stores application configuration + // config stores application configuration. config *config.Config - - // templates stores the template renderer - templates *TemplateRenderer } - // mail represents an email to be sent + // mail represents an email to be sent. mail struct { - client *MailClient - from string - to string - subject string - body string - template string - templateData any + client *MailClient + from string + to string + subject string + body string + component gomponents.Node } ) -// NewMailClient creates a new MailClient -func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient, error) { +// NewMailClient creates a new MailClient. +func NewMailClient(cfg *config.Config) (*MailClient, error) { return &MailClient{ - config: cfg, - templates: templates, + config: cfg, }, nil } @@ -61,22 +57,16 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error { switch { case email.to == "": return errors.New("email cannot be sent without a to address") - case email.body == "" && email.template == "": - return errors.New("email cannot be sent without a body or template") + case email.body == "" && email.component == nil: + return errors.New("email cannot be sent without a body or component to render") } - // Check if a template was supplied - if email.template != "" { - // Parse and execute template - buf, err := m.templates. - Parse(). - Group("mail"). - Key(email.template). - Base(email.template). - Files(fmt.Sprintf("emails/%s", email.template)). - Execute(email.templateData) - - if err != nil { + // Check if a component was supplied. + if email.component != nil { + // Render the component and use as the body. + // TODO pool the buffers? + buf := bytes.NewBuffer(nil) + if err := email.component.Render(buf); err != nil { return err } @@ -92,47 +82,42 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error { } // TODO: Finish based on your mail sender of choice! + log.Ctx(ctx).Info("sending email", + "to", email.to, + "subject", email.subject, + "body", email.body, + ) return nil } -// From sets the email from address +// From sets the email from address. func (m *mail) From(from string) *mail { m.from = from return m } -// To sets the email address this email will be sent to +// To sets the email address this email will be sent to. func (m *mail) To(to string) *mail { m.to = to return m } -// Subject sets the subject line of the email +// Subject sets the subject line of the email. func (m *mail) Subject(subject string) *mail { m.subject = subject return m } -// Body sets the body of the email -// This is not required and will be ignored if a template via Template() +// Body sets the body of the email. +// This is not required and will be ignored if a component is set via Component(). func (m *mail) Body(body string) *mail { m.body = body return m } -// Template sets the template to be used to produce the body of the email -// The template name should only include the filename without the extension or directory. -// The template must reside within the emails sub-directory. -// The funcmap will be automatically added to the template. -// Use TemplateData() to supply the data that will be passed in to the template. -func (m *mail) Template(template string) *mail { - m.template = template - return m -} - -// TemplateData sets the data that will be passed to the template specified when calling Template() -func (m *mail) TemplateData(data any) *mail { - m.templateData = data +// Component sets a renderable component to use as the body of the email. +func (m *mail) Component(component gomponents.Node) *mail { + m.component = component return m } diff --git a/pkg/ui/emails.go b/pkg/ui/emails.go new file mode 100644 index 00000000..1cbeb17c --- /dev/null +++ b/pkg/ui/emails.go @@ -0,0 +1,16 @@ +package ui + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func ConfirmEmailAddressEmail(username, url string) Node { + return Group{ + Strong(Textf("Hello %s,", username)), + Br(), + P(Text("Please click on the following link to confirm your email address:")), + Br(), + A(Href(url), Text(url)), + } +} From 9269c985417a256376cabfb44133f64058c414f8 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 09:42:14 -0500 Subject: [PATCH 06/30] Added Air support. --- .air.toml | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 3 ++- Makefile | 31 +++++++++++++++++++------------ 3 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 .air.toml diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..88298899 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/web" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore index a8c4bda9..747fcd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea dbs -uploads \ No newline at end of file +uploads +tmp \ No newline at end of file diff --git a/Makefile b/Makefile index 7434d38f..2563f831 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,37 @@ -# Install Ent code-generation module +.PHONY: help +help: ## Print make targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + .PHONY: ent-install -ent-install: +ent-install: ## Install Ent code-generation module go get entgo.io/ent/cmd/ent -# Generate Ent code +.PHONY: air-install +air-install: ## Install air + go install github.com/air-verse/air@latest + .PHONY: ent-gen -ent-gen: +ent-gen: ## Generate Ent code go generate ./ent -# Create a new Ent entity .PHONY: ent-new -ent-new: +ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity) go run entgo.io/ent/cmd/ent new $(name) -# Run the application .PHONY: run -run: +run: ## Run the application clear go run cmd/web/main.go -# Run all tests +.PHONY: watch +watch: ## Run the application and watch for changes with air to automatically rebuild + clear + air + .PHONY: test -test: +test: ## Run all tests go test -count=1 -p 1 ./... -# Check for direct dependency updates .PHONY: check-updates -check-updates: +check-updates: ## Check for direct dependency updates go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\[" From 3d9d0bbabad3727e8b9a78a55bcda981d244b702 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 10:07:00 -0500 Subject: [PATCH 07/30] Migrated cache form. --- pkg/handlers/cache.go | 39 ++++++++++++--------------------------- pkg/routenames/names.go | 2 ++ pkg/ui/forms.go | 41 +++++++++++++++++++++++++++++++++++++++++ pkg/ui/pages.go | 7 +++++++ 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/pkg/handlers/cache.go b/pkg/handlers/cache.go index a0600f40..a321b732 100644 --- a/pkg/handlers/cache.go +++ b/pkg/handlers/cache.go @@ -2,28 +2,18 @@ package handlers import ( "errors" + "time" + "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/form" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" - "time" -) - -const ( - routeNameCache = "cache" - routeNameCacheSubmit = "cache.submit" + "github.com/mikestefanello/pagoda/pkg/ui" ) type ( Cache struct { cache *services.CacheClient - *services.TemplateRenderer - } - - cacheForm struct { - Value string `form:"value"` - form.Submission } ) @@ -32,43 +22,38 @@ func init() { } func (h *Cache) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer h.cache = c.Cache return nil } func (h *Cache) Routes(g *echo.Group) { - g.GET("/cache", h.Page).Name = routeNameCache - g.POST("/cache", h.Submit).Name = routeNameCacheSubmit + g.GET("/cache", h.Page).Name = routenames.Cache + g.POST("/cache", h.Submit).Name = routenames.CacheSubmit } func (h *Cache) Page(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageCache - p.Title = "Set a cache entry" - p.Form = form.Get[cacheForm](ctx) + f := form.Get[ui.CacheForm](ctx) - // Fetch the value from the cache + // Fetch the value from the cache. value, err := h.cache. Get(). Key("page_cache_example"). Fetch(ctx.Request().Context()) - // Store the value in the page, so it can be rendered, if found + // Store the value in the form, so it can be rendered, if found. switch { case err == nil: - p.Data = value.(string) + f.CurrentValue = value.(string) case errors.Is(err, services.ErrCacheMiss): default: return fail(err, "failed to fetch from cache") } - return h.RenderPage(ctx, p) + return ui.UpdateCache(ctx, f) } func (h *Cache) Submit(ctx echo.Context) error { - var input cacheForm + var input ui.CacheForm if err := form.Submit(ctx, &input); err != nil { return err diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index 15a1cb05..94a39534 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -17,4 +17,6 @@ const ( Search = "search" Task = "task" TaskSubmit = "task.submit" + Cache = "cache" + CacheSubmit = "cache.submit" ) diff --git a/pkg/ui/forms.go b/pkg/ui/forms.go index e025ccc7..fa5dc59b 100644 --- a/pkg/ui/forms.go +++ b/pkg/ui/forms.go @@ -48,6 +48,12 @@ type ( Message string `form:"message" validate:"required"` form.Submission } + + CacheForm struct { + CurrentValue string + Value string `form:"value"` + form.Submission + } ) func (f *ContactForm) render(r *request) Node { @@ -246,3 +252,38 @@ func (f *TaskForm) render(r *request) Node { csrf(r), ) } + +func (f *CacheForm) render(r *request) Node { + return Form( + ID("cache"), + Method(http.MethodPost), + Attr("hx-post", r.path(routenames.CacheSubmit)), + message( + "is-info", + "Test the cache", + Group{ + P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")), + P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")), + }, + ), + Label( + For("value"), + Class("value"), + Text("Value in cache: "), + ), + If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))), + If(f.CurrentValue == "", I(Text("(empty)"))), + formInput(input{ + form: f, + formField: "Value", + name: "value", + inputType: "text", + label: "Value", + value: f.Value, + }), + formControlGroup( + button("is-link", "Update cache"), + ), + csrf(r), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index 4b173e2a..f834049a 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -151,3 +151,10 @@ func AddTask(ctx echo.Context, form *TaskForm) error { return r.render(layoutPrimary, g) } + +func UpdateCache(ctx echo.Context, form *CacheForm) error { + r := newRequest(ctx) + r.Title = "Set a cache entry" + + return r.render(layoutPrimary, form.render(r)) +} From 85acd5af08db3b33a7caac137673d522de5c41b0 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 12:00:22 -0500 Subject: [PATCH 08/30] Migrated error page. --- pkg/handlers/error.go | 24 ++++++++---------------- pkg/handlers/router.go | 21 ++++++++++----------- pkg/msg/msg.go | 24 ++++++++++++------------ pkg/ui/models.go | 1 + pkg/ui/pages.go | 27 +++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 39 deletions(-) diff --git a/pkg/handlers/error.go b/pkg/handlers/error.go index fd5502b4..c4237714 100644 --- a/pkg/handlers/error.go +++ b/pkg/handlers/error.go @@ -6,27 +6,23 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/log" - "github.com/mikestefanello/pagoda/pkg/page" - "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" + "github.com/mikestefanello/pagoda/pkg/ui" ) -type Error struct { - *services.TemplateRenderer -} +type Error struct{} func (e *Error) Page(err error, ctx echo.Context) { if ctx.Response().Committed || context.IsCanceledError(err) { return } - // Determine the error status code + // Determine the error status code. code := http.StatusInternalServerError if he, ok := err.(*echo.HTTPError); ok { code = he.Code } - // Log the error + // Log the error. logger := log.Ctx(ctx) switch { case code >= 500: @@ -35,15 +31,11 @@ func (e *Error) Page(err error, ctx echo.Context) { logger.Warn(err.Error()) } - // Render the error page - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageError - p.Title = http.StatusText(code) - p.StatusCode = code - p.HTMX.Request.Enabled = false + // Set the status code. + ctx.Response().Status = code - if err = e.RenderPage(ctx, p); err != nil { + // Render the error page. + if err = ui.Error(ctx, code); err != nil { log.Ctx(ctx).Error("failed to render error page", "error", err, ) diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index e72e82b8..58d5dd4d 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -10,23 +10,23 @@ import ( "github.com/mikestefanello/pagoda/pkg/services" ) -// BuildRouter builds the router +// BuildRouter builds the router. func BuildRouter(c *services.Container) error { - // Static files with proper cache control - // funcmap.File() should be used in templates to append a cache key to the URL in order to break cache - // after each server restart + // Static files with proper cache control. + // ui.file() should be used in ui components to append a cache key to the URL in order to break cache + // after each server restart. c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)). Static(config.StaticPrefix, config.StaticDir) - // Non-static file route group + // Non-static file route group. g := c.Web.Group("") - // Force HTTPS, if enabled + // Force HTTPS, if enabled. if c.Config.HTTP.TLS.Enabled { g.Use(echomw.HTTPSRedirect()) } - // Create a cookie store for session data + // Create a cookie store for session data. cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey)) cookieStore.Options.HttpOnly = true cookieStore.Options.Secure = true @@ -56,11 +56,10 @@ func BuildRouter(c *services.Container) error { }), ) - // Error handler - err := Error{c.TemplateRenderer} - c.Web.HTTPErrorHandler = err.Page + // Error handler. + c.Web.HTTPErrorHandler = new(Error).Page - // Initialize and register all handlers + // Initialize and register all handlers. for _, h := range GetHandlers() { if err := h.Init(c); err != nil { return err diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go index 9a2adf37..b4fe9a42 100644 --- a/pkg/msg/msg.go +++ b/pkg/msg/msg.go @@ -7,44 +7,44 @@ import ( "github.com/mikestefanello/pagoda/pkg/session" ) -// Type is a message type +// Type is a message type.s type Type string const ( - // TypeSuccess represents a success message type + // TypeSuccess represents a success message type. TypeSuccess Type = "success" - // TypeInfo represents a info message type + // TypeInfo represents a info message type. TypeInfo Type = "info" - // TypeWarning represents a warning message type + // TypeWarning represents a warning message type. TypeWarning Type = "warning" - // TypeDanger represents a danger message type + // TypeDanger represents a danger message type. TypeDanger Type = "danger" ) const ( - // sessionName stores the name of the session which contains flash messages + // sessionName stores the name of the session which contains flash messages. sessionName = "msg" ) -// Success sets a success flash message +// Success sets a success flash message. func Success(ctx echo.Context, message string) { Set(ctx, TypeSuccess, message) } -// Info sets an info flash message +// Info sets an info flash message. func Info(ctx echo.Context, message string) { Set(ctx, TypeInfo, message) } -// Warning sets a warning flash message +// Warning sets a warning flash message. func Warning(ctx echo.Context, message string) { Set(ctx, TypeWarning, message) } -// Danger sets a danger flash message +// Danger sets a danger flash message. func Danger(ctx echo.Context, message string) { Set(ctx, TypeDanger, message) } @@ -77,7 +77,7 @@ func Get(ctx echo.Context, typ Type) []string { return msgs } -// getSession gets the flash message session +// getSession gets the flash message session. func getSession(ctx echo.Context) (*sessions.Session, error) { sess, err := session.Get(ctx, sessionName) if err != nil { @@ -88,7 +88,7 @@ func getSession(ctx echo.Context) (*sessions.Session, error) { return sess, err } -// save saves the flash message session +// save saves the flash message session. func save(ctx echo.Context, sess *sessions.Session) { if err := sess.Save(ctx.Request(), ctx.Response()); err != nil { log.Ctx(ctx).Error("failed to set flash message", diff --git a/pkg/ui/models.go b/pkg/ui/models.go index 36ba9b60..70aa2709 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models.go @@ -13,6 +13,7 @@ type ( Posts []Post Pager page.Pager } + Post struct { Title, Body string } diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index f834049a..d920fd16 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "net/http" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/routenames" @@ -158,3 +159,29 @@ func UpdateCache(ctx echo.Context, form *CacheForm) error { return r.render(layoutPrimary, form.render(r)) } + +func Error(ctx echo.Context, code int) error { + r := newRequest(ctx) + r.Title = http.StatusText(code) + var body Node + + switch code { + case http.StatusInternalServerError: + body = Text("Please try again.") + case http.StatusForbidden, http.StatusUnauthorized: + body = Text("You are not authorized to view the requested page.") + case http.StatusNotFound: + body = Group{ + Text("Click "), + A( + Href(r.path(routenames.Home)), + Text("here"), + ), + Text(" to go return home."), + } + default: + body = Text("Something went wrong.") + } + + return r.render(layoutPrimary, P(body)) +} From 179999b4cceee113012a44ddc9932c8e7dfcd581 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 14:47:50 -0500 Subject: [PATCH 09/30] Migrated files page. --- pkg/handlers/auth.go | 52 ++++++++++++++++++++--------------------- pkg/handlers/cache.go | 10 ++++---- pkg/handlers/files.go | 44 +++++++++------------------------- pkg/handlers/pages.go | 3 +-- pkg/log/log.go | 6 ++--- pkg/routenames/names.go | 2 ++ pkg/services/mail.go | 12 +++++----- pkg/ui/form.go | 21 +++++++++++++++++ pkg/ui/models.go | 14 +++++++++++ pkg/ui/pages.go | 51 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 138 insertions(+), 77 deletions(-) diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index c7264b88..3b54fad2 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -19,13 +19,11 @@ import ( "github.com/mikestefanello/pagoda/pkg/ui" ) -type ( - Auth struct { - auth *services.AuthClient - mail *services.MailClient - orm *ent.Client - } -) +type Auth struct { + auth *services.AuthClient + mail *services.MailClient + orm *ent.Client +} func init() { Register(new(Auth)) @@ -81,7 +79,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { return err } - // Attempt to load the user + // Attempt to load the user. u, err := h.orm.User. Query(). Where(user.Email(strings.ToLower(input.Email))). @@ -95,7 +93,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { return fail(err, "error querying user during forgot password") } - // Generate the token + // Generate the token. token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID) if err != nil { return fail(err, "error generating password reset token") @@ -105,7 +103,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { "user_id", u.ID, ) - // Email the user + // Email the user. url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token) err = h.mail. Compose(). @@ -145,7 +143,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error { return err } - // Attempt to load the user + // Attempt to load the user. u, err := h.orm.User. Query(). Where(user.Email(strings.ToLower(input.Email))). @@ -159,13 +157,13 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error { return fail(err, "error querying user during login") } - // Check if the password is correct + // Check if the password is correct. err = h.auth.CheckPassword(input.Password, u.Password) if err != nil { return authFailed() } - // Log the user in + // Log the user in. err = h.auth.Login(ctx, u.ID) if err != nil { return fail(err, "unable to log in user") @@ -206,13 +204,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { return err } - // Hash the password + // Hash the password. pwHash, err := h.auth.HashPassword(input.Password) if err != nil { return fail(err, "unable to hash password") } - // Attempt creating the user + // Attempt creating the user. u, err := h.orm.User. Create(). SetName(input.Name). @@ -235,7 +233,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { return fail(err, "unable to create user") } - // Log the user in + // Log the user in. err = h.auth.Login(ctx, u.ID) if err != nil { log.Ctx(ctx).Error("unable to log user in", @@ -250,7 +248,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { msg.Success(ctx, "Your account has been created. You are now logged in.") - // Send the verification email + // Send the verification email. h.sendVerificationEmail(ctx, u) return redirect.New(ctx). @@ -259,7 +257,7 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error { } func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { - // Generate a token + // Generate a token. token, err := h.auth.GenerateEmailVerificationToken(usr.Email) if err != nil { log.Ctx(ctx).Error("unable to generate email verification token", @@ -269,7 +267,7 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { return } - // Send the email + // Send the email. url := ctx.Echo().Reverse(routenames.VerifyEmail, token) err = h.mail. Compose(). @@ -306,16 +304,16 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { return err } - // Hash the new password + // Hash the new password. hash, err := h.auth.HashPassword(input.Password) if err != nil { return fail(err, "unable to hash password") } - // Get the requesting user + // Get the requesting user. usr := ctx.Get(context.UserKey).(*ent.User) - // Update the user + // Update the user. _, err = usr. Update(). SetPassword(hash). @@ -325,7 +323,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { return fail(err, "unable to update password") } - // Delete all password tokens for this user + // Delete all password tokens for this user. err = h.auth.DeletePasswordTokens(ctx, usr.ID) if err != nil { return fail(err, "unable to delete password tokens") @@ -340,7 +338,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { func (h *Auth) VerifyEmail(ctx echo.Context) error { var usr *ent.User - // Validate the token + // Validate the token. token := ctx.Param("token") email, err := h.auth.ValidateEmailVerificationToken(token) if err != nil { @@ -350,7 +348,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { Go() } - // Check if it matches the authenticated user + // Check if it matches the authenticated user. if u := ctx.Get(context.AuthenticatedUserKey); u != nil { authUser := u.(*ent.User) @@ -359,7 +357,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { } } - // Query to find a matching user, if needed + // Query to find a matching user, if needed. if usr == nil { usr, err = h.orm.User. Query(). @@ -371,7 +369,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error { } } - // Verify the user, if needed + // Verify the user, if needed. if !usr.Verified { usr, err = usr. Update(). diff --git a/pkg/handlers/cache.go b/pkg/handlers/cache.go index a321b732..b6cce9d4 100644 --- a/pkg/handlers/cache.go +++ b/pkg/handlers/cache.go @@ -11,11 +11,9 @@ import ( "github.com/mikestefanello/pagoda/pkg/ui" ) -type ( - Cache struct { - cache *services.CacheClient - } -) +type Cache struct { + cache *services.CacheClient +} func init() { Register(new(Cache)) @@ -59,7 +57,7 @@ func (h *Cache) Submit(ctx echo.Context) error { return err } - // Set the cache + // Set the cache. err := h.cache. Set(). Key("page_cache_example"). diff --git a/pkg/handlers/files.go b/pkg/handlers/files.go index 1dfe9c55..d7afc01b 100644 --- a/pkg/handlers/files.go +++ b/pkg/handlers/files.go @@ -7,69 +7,47 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/templates" + "github.com/mikestefanello/pagoda/pkg/ui" "github.com/spf13/afero" ) -const ( - routeNameFiles = "files" - routeNameFilesSubmit = "files.submit" -) - -type ( - Files struct { - files afero.Fs - *services.TemplateRenderer - } - - File struct { - Name string - Size int64 - Modified string - } -) +type Files struct { + files afero.Fs +} func init() { Register(new(Files)) } func (h *Files) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer h.files = c.Files return nil } func (h *Files) Routes(g *echo.Group) { - g.GET("/files", h.Page).Name = routeNameFiles - g.POST("/files", h.Submit).Name = routeNameFilesSubmit + g.GET("/files", h.Page).Name = routenames.Files + g.POST("/files", h.Submit).Name = routenames.FilesSubmit } func (h *Files) Page(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageFiles - p.Title = "Upload a file" - - // Send a list of all uploaded files to the template to be rendered. + // Compile a list of all uploaded files to be rendered. info, err := afero.ReadDir(h.files, "") if err != nil { return err } - files := make([]File, 0) + files := make([]*ui.File, 0) for _, file := range info { - files = append(files, File{ + files = append(files, &ui.File{ Name: file.Name(), Size: file.Size(), Modified: file.ModTime().Format(time.DateTime), }) } - p.Data = files - - return h.RenderPage(ctx, p) + return ui.UploadFile(ctx, files) } func (h *Files) Submit(ctx echo.Context) error { diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index d271f5f2..ddd0c8cb 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -62,10 +62,9 @@ func (h *Pages) HomeOld(ctx echo.Context) error { func (h *Pages) Home(ctx echo.Context) error { pgr := page.NewPager(ctx, 4) - p := h.fetchPosts(&pgr) return ui.Home(ctx, ui.Posts{ - Posts: p, + Posts: h.fetchPosts(&pgr), Pager: pgr, }) } diff --git a/pkg/log/log.go b/pkg/log/log.go index ebafd287..51c6448f 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -7,12 +7,12 @@ import ( "github.com/mikestefanello/pagoda/pkg/context" ) -// Set sets a logger in the context +// Set sets a logger in the context. func Set(ctx echo.Context, logger *slog.Logger) { ctx.Set(context.LoggerKey, logger) } -// Ctx returns the logger stored in context, or provides the default logger if one is not present +// Ctx returns the logger stored in context, or provides the default logger if one is not present. func Ctx(ctx echo.Context) *slog.Logger { if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok { return l @@ -21,7 +21,7 @@ func Ctx(ctx echo.Context) *slog.Logger { return Default() } -// Default returns the default logger +// Default returns the default logger. func Default() *slog.Logger { return slog.Default() } diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index 94a39534..2f235df4 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -19,4 +19,6 @@ const ( TaskSubmit = "task.submit" Cache = "cache" CacheSubmit = "cache.submit" + Files = "files" + FilesSubmit = "files.submit" ) diff --git a/pkg/services/mail.go b/pkg/services/mail.go index 0115c038..22e7d3d8 100644 --- a/pkg/services/mail.go +++ b/pkg/services/mail.go @@ -39,7 +39,7 @@ func NewMailClient(cfg *config.Config) (*MailClient, error) { }, nil } -// Compose creates a new email +// Compose creates a new email. func (m *MailClient) Compose() *mail { return &mail{ client: m, @@ -47,12 +47,12 @@ func (m *MailClient) Compose() *mail { } } -// skipSend determines if mail sending should be skipped +// skipSend determines if mail sending should be skipped. func (m *MailClient) skipSend() bool { return m.config.App.Environment != config.EnvProduction } -// send attempts to send the email +// send attempts to send the email. func (m *MailClient) send(email *mail, ctx echo.Context) error { switch { case email.to == "": @@ -73,7 +73,7 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error { email.body = buf.String() } - // Check if mail sending should be skipped + // Check if mail sending should be skipped. if m.skipSend() { log.Ctx(ctx).Debug("skipping email delivery", "to", email.to, @@ -81,7 +81,7 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error { return nil } - // TODO: Finish based on your mail sender of choice! + // TODO: Finish based on your mail sender of choice or stop logging below! log.Ctx(ctx).Info("sending email", "to", email.to, "subject", email.subject, @@ -121,7 +121,7 @@ func (m *mail) Component(component gomponents.Node) *mail { return m } -// Send attempts to send the email +// Send attempts to send the email. func (m *mail) Send(ctx echo.Context) error { return m.client.send(m, ctx) } diff --git a/pkg/ui/form.go b/pkg/ui/form.go index 7f7bc3fa..84b56e66 100644 --- a/pkg/ui/form.go +++ b/pkg/ui/form.go @@ -129,6 +129,27 @@ func formInput(el input) Node { ) } +func formFile(name, label string) Node { + return Div( + Class("field file"), + Label( + Class("file-label"), + Input( + Class("file-input"), + Type("file"), + Name(name), + ), + Span( + Class("file-cta"), + Span( + Class("file-label"), + Text(label), + ), + ), + ), + ) +} + func formFieldStatusClass(fm form.Form, formField string) string { switch { case !fm.IsSubmitted(): diff --git a/pkg/ui/models.go b/pkg/ui/models.go index 70aa2709..bd6e7e47 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models.go @@ -22,6 +22,12 @@ type ( Title string URL string } + + File struct { + Name string + Size int64 + Modified string + } ) func (p *Posts) render(path string) Node { @@ -95,3 +101,11 @@ func (s *SearchResult) render() Node { Text(s.Title), ) } + +func (f *File) render() Node { + return Tr( + Td(Text(f.Name)), + Td(Text(fmt.Sprint(f.Size))), + Td(Text(f.Modified)), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index d920fd16..38afd387 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -185,3 +185,54 @@ func Error(ctx echo.Context, code int) error { return r.render(layoutPrimary, P(body)) } + +func UploadFile(ctx echo.Context, files []*File) error { + r := newRequest(ctx) + r.Title = "Upload a file" + + fileList := make(Group, 0, len(files)) + for _, file := range files { + fileList = append(fileList, file.render()) + } + + n := Group{ + message( + "is-link", + "", + P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")), + ), + Hr(), + Form( + ID("files"), + Method(http.MethodPost), + Action(r.path(routenames.FilesSubmit)), + EncType("multipart/form-data"), + formFile("file", "Choose a file.. "), + formControlGroup( + button("is-link", "Upload"), + ), + csrf(r), + ), + Hr(), + H3( + Class("title"), + Text("Uploaded files"), + ), + message("is-warning", "", P(Text("Below are all files in the configured upload directory."))), + Table( + Class("table"), + THead( + Tr( + Th(Text("Filename")), + Th(Text("Size")), + Th(Text("Modified on")), + ), + ), + TBody( + fileList, + ), + ), + } + + return r.render(layoutPrimary, n) +} From 055560f906f4e967302f81b894a6d27293bfba17 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 16:16:10 -0500 Subject: [PATCH 10/30] Migrated about page. --- pkg/handlers/pages.go | 80 ++--------------------------------------- pkg/routenames/names.go | 1 + pkg/ui/components.go | 49 +++++++++++++++++++++++++ pkg/ui/pages.go | 53 +++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 77 deletions(-) diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index ddd0c8cb..500319dc 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -2,62 +2,27 @@ package handlers import ( "fmt" - "html/template" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/ui" - "github.com/mikestefanello/pagoda/templates" ) -const ( - routeNameAbout = "about" -) - -type ( - Pages struct { - *services.TemplateRenderer - } - - aboutData struct { - ShowCacheWarning bool - FrontendTabs []aboutTab - BackendTabs []aboutTab - } - - aboutTab struct { - Title string - Body template.HTML - } -) +type Pages struct{} func init() { Register(new(Pages)) } func (h *Pages) Init(c *services.Container) error { - h.TemplateRenderer = c.TemplateRenderer return nil } func (h *Pages) Routes(g *echo.Group) { g.GET("/", h.Home).Name = routenames.Home - g.GET("/oldhome", h.HomeOld).Name = "oldhome" - g.GET("/about", h.About).Name = routeNameAbout -} - -func (h *Pages) HomeOld(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageHome - p.Metatags.Description = "Welcome to the homepage." - p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"} - p.Pager = page.NewPager(ctx, 4) - p.Data = h.fetchPosts(&p.Pager) - - return h.RenderPage(ctx, p) + g.GET("/about", h.About).Name = routenames.About } func (h *Pages) Home(ctx echo.Context) error { @@ -84,44 +49,5 @@ func (h *Pages) fetchPosts(pager *page.Pager) []ui.Post { } func (h *Pages) About(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageAbout - p.Title = "About" - - // This page will be cached! - p.Cache.Enabled = true - p.Cache.Tags = []string{"page_about", "page:list"} - - // A simple example of how the Data field can contain anything you want to send to the templates - // even though you wouldn't normally send markup like this - p.Data = aboutData{ - ShowCacheWarning: true, - FrontendTabs: []aboutTab{ - { - Title: "HTMX", - Body: template.HTML(`Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.`), - }, - { - Title: "Alpine.js", - Body: template.HTML(`Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.`), - }, - { - Title: "Bulma", - Body: template.HTML(`Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.`), - }, - }, - BackendTabs: []aboutTab{ - { - Title: "Echo", - Body: template.HTML(`High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.`), - }, - { - Title: "Ent", - Body: template.HTML(`Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.`), - }, - }, - } - - return h.RenderPage(ctx, p) + return ui.About(ctx) } diff --git a/pkg/routenames/names.go b/pkg/routenames/names.go index 2f235df4..a3c98aa1 100644 --- a/pkg/routenames/names.go +++ b/pkg/routenames/names.go @@ -2,6 +2,7 @@ package routenames const ( Home = "home" + About = "about" Contact = "contact" ContactSubmit = "contact.submit" Login = "login" diff --git a/pkg/ui/components.go b/pkg/ui/components.go index bf3d37c4..30a1cbe6 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "github.com/mikestefanello/pagoda/pkg/msg" @@ -9,6 +10,10 @@ import ( . "maragu.dev/gomponents/html" ) +type tab struct { + title, body string +} + func head(r *request) Node { return Head( Meta(Charset("utf-8")), @@ -243,3 +248,47 @@ func buttonLink(href, class, label string) Node { func hxBoost() Node { return Attr("hx-boost", "true") } + +func tabs(heading, description string, items []tab) Node { + renderTitles := func() Node { + g := make(Group, 0, len(items)) + for i, item := range items { + g = append(g, Li( + Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)), + Attr("@click", fmt.Sprintf("tab = %d", i)), + A(Text(item.title)), + )) + } + return g + } + + renderBodies := func() Node { + g := make(Group, 0, len(items)) + for i, item := range items { + g = append(g, Div( + Attr("x-show", fmt.Sprintf("tab == %d", i)), + P(Raw(" "+item.body)), + )) + } + return g + } + + return Div( + P( + Class("subtitle mt-5"), + Text(heading), + ), + P( + Class("mb-4"), + Text(description), + ), + Div( + Attr("x-data", "{tab: 0}"), + Div( + Class("tabs"), + Ul(renderTitles()), + ), + renderBodies(), + ), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go index 38afd387..9080b41d 100644 --- a/pkg/ui/pages.go +++ b/pkg/ui/pages.go @@ -51,6 +51,17 @@ func Home(ctx echo.Context, posts Posts) error { g = append(g, posts.render(r.path(routenames.Home))) + if r.Htmx.Target != "posts" { + g = append(g, message( + "is-small is-warning mt-5", + "Serving files", + Group{ + Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted."), + Text("Static files also contain cache-control headers which are configured via middleware."), + }, + )) + } + return r.render(layoutPrimary, g) } @@ -236,3 +247,45 @@ func UploadFile(ctx echo.Context, files []*File) error { return r.render(layoutPrimary, n) } + +func About(ctx echo.Context) error { + r := newRequest(ctx) + r.Title = "About" + r.Metatags.Description = "Learn a little about what's included in Pagoda." + + return r.render(layoutPrimary, Group{ + tabs( + "Frontend", + "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", + []tab{ + { + title: "HTMX", + body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.", + }, + { + title: "Alpine.js", + body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.", + }, + { + title: "Bulma", + body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.", + }, + }, + ), + Div(Class("mb-4")), + tabs( + "Backend", + "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.", + []tab{ + { + title: "Echo", + body: "High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.", + }, + { + title: "Ent", + body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.", + }, + }, + ), + }) +} From 149005cb91147c68e1d31481d5cb6e0ca815213b Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 23 Feb 2025 16:25:15 -0500 Subject: [PATCH 11/30] Removed templates, template renderer, funcmap, and page. --- pkg/funcmap/funcmap.go | 56 ---- pkg/funcmap/funcmap_test.go | 52 ---- pkg/handlers/pages.go | 6 +- pkg/handlers/router.go | 2 +- pkg/middleware/cache.go | 56 +--- pkg/middleware/cache_test.go | 70 +++-- pkg/page/page.go | 161 ----------- pkg/page/page_test.go | 77 ----- pkg/{page => pager}/pager.go | 28 +- pkg/{page => pager}/pager_test.go | 6 +- pkg/services/container.go | 10 - pkg/services/container_test.go | 1 - pkg/services/template_renderer.go | 376 ------------------------- pkg/services/template_renderer_test.go | 198 ------------- pkg/ui/models.go | 8 +- templates/components/core.gohtml | 42 --- templates/components/forms.gohtml | 9 - templates/components/messages.gohtml | 21 -- templates/emails/test.gohtml | 1 - templates/layouts/auth.gohtml | 35 --- templates/layouts/htmx.gohtml | 1 - templates/layouts/main.gohtml | 93 ------ templates/pages/about.gohtml | 41 --- templates/pages/cache.gohtml | 36 --- templates/pages/contact.gohtml | 70 ----- templates/pages/error.gohtml | 11 - templates/pages/files.gohtml | 51 ---- templates/pages/forgot-password.gohtml | 23 -- templates/pages/home.gohtml | 82 ------ templates/pages/login.gohtml | 28 -- templates/pages/register.gohtml | 41 --- templates/pages/reset-password.gohtml | 24 -- templates/pages/search.gohtml | 5 - templates/pages/task.gohtml | 43 --- templates/templates.go | 55 ---- templates/templates_test.go | 18 -- 36 files changed, 71 insertions(+), 1766 deletions(-) delete mode 100644 pkg/funcmap/funcmap.go delete mode 100644 pkg/funcmap/funcmap_test.go delete mode 100644 pkg/page/page.go delete mode 100644 pkg/page/page_test.go rename pkg/{page => pager}/pager.go (71%) rename pkg/{page => pager}/pager_test.go (90%) delete mode 100644 pkg/services/template_renderer.go delete mode 100644 pkg/services/template_renderer_test.go delete mode 100644 templates/components/core.gohtml delete mode 100644 templates/components/forms.gohtml delete mode 100644 templates/components/messages.gohtml delete mode 100644 templates/emails/test.gohtml delete mode 100644 templates/layouts/auth.gohtml delete mode 100644 templates/layouts/htmx.gohtml delete mode 100644 templates/layouts/main.gohtml delete mode 100644 templates/pages/about.gohtml delete mode 100644 templates/pages/cache.gohtml delete mode 100644 templates/pages/contact.gohtml delete mode 100644 templates/pages/error.gohtml delete mode 100644 templates/pages/files.gohtml delete mode 100644 templates/pages/forgot-password.gohtml delete mode 100644 templates/pages/home.gohtml delete mode 100644 templates/pages/login.gohtml delete mode 100644 templates/pages/register.gohtml delete mode 100644 templates/pages/reset-password.gohtml delete mode 100644 templates/pages/search.gohtml delete mode 100644 templates/pages/task.gohtml delete mode 100644 templates/templates.go delete mode 100644 templates/templates_test.go diff --git a/pkg/funcmap/funcmap.go b/pkg/funcmap/funcmap.go deleted file mode 100644 index 2b5dc83e..00000000 --- a/pkg/funcmap/funcmap.go +++ /dev/null @@ -1,56 +0,0 @@ -package funcmap - -import ( - "fmt" - "html/template" - "strings" - - "github.com/Masterminds/sprig" - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/random" - "github.com/mikestefanello/pagoda/config" -) - -var ( - // CacheBuster stores a random string used as a cache buster for static files. - CacheBuster = random.String(10) -) - -type funcMap struct { - web *echo.Echo -} - -// NewFuncMap provides a template function map -func NewFuncMap(web *echo.Echo) template.FuncMap { - fm := &funcMap{web: web} - - // See http://masterminds.github.io/sprig/ for all provided funcs - funcs := sprig.FuncMap() - - // Include all the custom functions - funcs["file"] = fm.file - funcs["link"] = fm.link - funcs["url"] = fm.url - - return funcs -} - -// file appends a cache buster to a given filepath so it can remain cached until the app is restarted -func (fm *funcMap) file(filepath string) string { - return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster) -} - -// link outputs HTML for a link element, providing the ability to dynamically set the active class -func (fm *funcMap) link(url, text, currentPath string, classes ...string) template.HTML { - if currentPath == url { - classes = append(classes, "is-active") - } - - html := fmt.Sprintf(`%s`, strings.Join(classes, " "), url, text) - return template.HTML(html) -} - -// url generates a URL from a given route name and optional parameters -func (fm *funcMap) url(routeName string, params ...any) string { - return fm.web.Reverse(routeName, params...) -} diff --git a/pkg/funcmap/funcmap_test.go b/pkg/funcmap/funcmap_test.go deleted file mode 100644 index b3b3b2bb..00000000 --- a/pkg/funcmap/funcmap_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package funcmap - -import ( - "fmt" - "testing" - - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/config" - - "github.com/stretchr/testify/assert" -) - -func TestNewFuncMap(t *testing.T) { - f := NewFuncMap(echo.New()) - assert.NotNil(t, f["link"]) - assert.NotNil(t, f["file"]) - assert.NotNil(t, f["url"]) -} - -func TestLink(t *testing.T) { - f := new(funcMap) - - link := string(f.link("/abc", "Text", "/abc")) - expected := `Text` - assert.Equal(t, expected, link) - - link = string(f.link("/abc", "Text", "/abc", "first", "second")) - expected = `Text` - assert.Equal(t, expected, link) - - link = string(f.link("/abc", "Text", "/def")) - expected = `Text` - assert.Equal(t, expected, link) -} - -func TestFile(t *testing.T) { - f := new(funcMap) - - file := f.file("test.png") - expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster) - assert.Equal(t, expected, file) -} - -func TestUrl(t *testing.T) { - f := new(funcMap) - f.web = echo.New() - f.web.GET("/mypath/:id", func(c echo.Context) error { - return nil - }).Name = "test" - out := f.url("test", 5) - assert.Equal(t, "/mypath/5", out) -} diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index 500319dc..dd1002d5 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/pager" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/ui" @@ -26,7 +26,7 @@ func (h *Pages) Routes(g *echo.Group) { } func (h *Pages) Home(ctx echo.Context) error { - pgr := page.NewPager(ctx, 4) + pgr := pager.NewPager(ctx, 4) return ui.Home(ctx, ui.Posts{ Posts: h.fetchPosts(&pgr), @@ -35,7 +35,7 @@ func (h *Pages) Home(ctx echo.Context) error { } // fetchPosts is a mock example of fetching posts to illustrate how paging works. -func (h *Pages) fetchPosts(pager *page.Pager) []ui.Post { +func (h *Pages) fetchPosts(pager *pager.Pager) []ui.Post { pager.SetItems(20) posts := make([]ui.Post, 20) diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index 58d5dd4d..0e3077e7 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -47,7 +47,7 @@ func BuildRouter(c *services.Container) error { }), middleware.Session(cookieStore), middleware.LoadAuthenticatedUser(c.Auth), - middleware.ServeCachedPage(c.TemplateRenderer), + middleware.ServeCachedPage, echomw.CSRFWithConfig(echomw.CSRFConfig{ TokenLookup: "form:csrf", CookieHTTPOnly: true, diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go index 1701dc21..2a718a0a 100644 --- a/pkg/middleware/cache.go +++ b/pkg/middleware/cache.go @@ -1,62 +1,32 @@ package middleware import ( - "errors" "fmt" "net/http" "time" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/log" - "github.com/mikestefanello/pagoda/pkg/services" - "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/context" ) // ServeCachedPage attempts to load a page from the cache by matching on the complete request URL // If a page is cached for the requested URL, it will be served here and the request terminated. // Any request made by an authenticated user or that is not a GET will be skipped. -func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(ctx echo.Context) error { - // Skip non GET requests - if ctx.Request().Method != http.MethodGet { - return next(ctx) - } - - // Skip if the user is authenticated - if ctx.Get(context.AuthenticatedUserKey) != nil { - return next(ctx) - } - - // Attempt to load from cache - page, err := t.GetCachedPage(ctx, ctx.Request().URL.String()) +func ServeCachedPage(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { - if err != nil { - switch { - case errors.Is(err, services.ErrCacheMiss): - case context.IsCanceledError(err): - return nil - default: - log.Ctx(ctx).Error("failed getting cached page", - "error", err, - ) - } - - return next(ctx) - } - - // Set any headers - if page.Headers != nil { - for k, v := range page.Headers { - ctx.Response().Header().Set(k, v) - } - } - - log.Ctx(ctx).Debug("serving cached page") + // Skip non GET requests + if ctx.Request().Method != http.MethodGet { + return next(ctx) + } - return ctx.HTMLBlob(page.StatusCode, page.HTML) + // Skip if the user is authenticated + if ctx.Get(context.AuthenticatedUserKey) != nil { + return next(ctx) } + + // TODO keep this functionality? + return next(ctx) } } diff --git a/pkg/middleware/cache_test.go b/pkg/middleware/cache_test.go index fcd7fb48..c092d5da 100644 --- a/pkg/middleware/cache_test.go +++ b/pkg/middleware/cache_test.go @@ -1,51 +1,47 @@ package middleware import ( - "net/http" "testing" "time" - "github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/tests" - "github.com/mikestefanello/pagoda/templates" - - "github.com/stretchr/testify/require" "github.com/stretchr/testify/assert" ) -func TestServeCachedPage(t *testing.T) { - // Cache a page - ctx, rec := tests.NewContext(c.Web, "/cache") - p := page.New(ctx) - p.Layout = templates.LayoutHTMX - p.Name = templates.PageHome - p.Cache.Enabled = true - p.Cache.Expiration = time.Minute - p.StatusCode = http.StatusCreated - p.Headers["a"] = "b" - p.Headers["c"] = "d" - err := c.TemplateRenderer.RenderPage(ctx, p) - output := rec.Body.Bytes() - require.NoError(t, err) - - // Request the URL of the cached page - ctx, rec = tests.NewContext(c.Web, "/cache") - err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) - assert.NoError(t, err) - assert.Equal(t, p.StatusCode, ctx.Response().Status) - assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a")) - assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c")) - assert.Equal(t, output, rec.Body.Bytes()) - - // Login and try again - tests.InitSession(ctx) - err = c.Auth.Login(ctx, usr.ID) - require.NoError(t, err) - _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth)) - err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) - assert.Nil(t, err) -} +// TODO keep this? +//func TestServeCachedPage(t *testing.T) { +// // Cache a page +// ctx, rec := tests.NewContext(c.Web, "/cache") +// p := page.New(ctx) +// p.Layout = templates.LayoutHTMX +// p.Name = templates.PageHome +// p.Cache.Enabled = true +// p.Cache.Expiration = time.Minute +// p.StatusCode = http.StatusCreated +// p.Headers["a"] = "b" +// p.Headers["c"] = "d" +// err := c.TemplateRenderer.RenderPage(ctx, p) +// output := rec.Body.Bytes() +// require.NoError(t, err) +// +// // Request the URL of the cached page +// ctx, rec = tests.NewContext(c.Web, "/cache") +// err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) +// assert.NoError(t, err) +// assert.Equal(t, p.StatusCode, ctx.Response().Status) +// assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a")) +// assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c")) +// assert.Equal(t, output, rec.Body.Bytes()) +// +// // Login and try again +// tests.InitSession(ctx) +// err = c.Auth.Login(ctx, usr.ID) +// require.NoError(t, err) +// _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth)) +// err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) +// assert.Nil(t, err) +//} func TestCacheControl(t *testing.T) { ctx, _ := tests.NewContext(c.Web, "/") diff --git a/pkg/page/page.go b/pkg/page/page.go deleted file mode 100644 index 48f5e116..00000000 --- a/pkg/page/page.go +++ /dev/null @@ -1,161 +0,0 @@ -package page - -import ( - "html/template" - "net/http" - "time" - - echomw "github.com/labstack/echo/v4/middleware" - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/htmx" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/templates" - - "github.com/labstack/echo/v4" -) - -// Page consists of all data that will be used to render a page response for a given route. -// While it's not required for a handler to render a Page on a route, this is the common data -// object that will be passed to the templates, making it easy for all handlers to share -// functionality both on the back and frontend. The Page can be expanded to include anything else -// your app wants to support. -// Methods on this page also then become available in the templates, which can be more useful than -// the funcmap if your methods require data stored in the page, such as the context. -type Page struct { - // AppName stores the name of the application. - // If omitted, the configuration value will be used. - AppName string - - // Title stores the title of the page - Title string - - // Context stores the request context - Context echo.Context - - // Path stores the path of the current request - Path string - - // URL stores the URL of the current request - URL string - - // Data stores whatever additional data that needs to be passed to the templates. - // This is what the handler uses to pass the content of the page. - Data any - - // Form stores a struct that represents a form on the page. - // This should be a struct with fields for each form field, using both "form" and "validate" tags - // It should also contain form.FormSubmission if you wish to have validation - // messages and markup presented to the user - Form any - - // Layout stores the name of the layout base template file which will be used when the page is rendered. - // This should match a template file located within the layouts directory inside the templates directory. - // The template extension should not be included in this value. - Layout templates.Layout - - // Name stores the name of the page as well as the name of the template file which will be used to render - // the content portion of the layout template. - // This should match a template file located within the pages directory inside the templates directory. - // The template extension should not be included in this value. - Name templates.Page - - // IsHome stores whether the requested page is the home page or not - IsHome bool - - // IsAuth stores whether the user is authenticated - IsAuth bool - - // AuthUser stores the authenticated user - AuthUser *ent.User - - // StatusCode stores the HTTP status code that will be returned - StatusCode int - - // Metatags stores metatag values - Metatags struct { - // Description stores the description metatag value - Description string - - // Keywords stores the keywords metatag values - Keywords []string - } - - // Pager stores a pager which can be used to page lists of results - Pager Pager - - // CSRF stores the CSRF token for the given request. - // This will only be populated if the CSRF middleware is in effect for the given request. - // If this is populated, all forms must include this value otherwise the requests will be rejected. - CSRF string - - // Headers stores a list of HTTP headers and values to be set on the response - Headers map[string]string - - // RequestID stores the ID of the given request. - // This will only be populated if the request ID middleware is in effect for the given request. - RequestID string - - // HTMX provides the ability to interact with the HTMX library - HTMX struct { - // Request contains the information provided by HTMX about the current request - Request *htmx.Request - - // Response contains values to pass back to HTMX - Response *htmx.Response - } - - // Cache stores values for caching the response of this page - Cache struct { - // Enabled dictates if the response of this page should be cached. - // Cached responses are served via middleware. - Enabled bool - - // Expiration stores the amount of time that the cache entry should live for before expiring. - // If omitted, the configuration value will be used. - Expiration time.Duration - - // Tags stores a list of tags to apply to the cache entry. - // These are useful when invalidating cache for dynamic events such as entity operations. - Tags []string - } -} - -// New creates and initializes a new Page for a given request context -func New(ctx echo.Context) Page { - p := Page{ - Context: ctx, - Path: ctx.Request().URL.Path, - URL: ctx.Request().URL.String(), - StatusCode: http.StatusOK, - Pager: NewPager(ctx, DefaultItemsPerPage), - Headers: make(map[string]string), - RequestID: ctx.Response().Header().Get(echo.HeaderXRequestID), - } - - p.IsHome = p.Path == "/" - - if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil { - p.CSRF = csrf.(string) - } - - if u := ctx.Get(context.AuthenticatedUserKey); u != nil { - p.IsAuth = true - p.AuthUser = u.(*ent.User) - } - - p.HTMX.Request = htmx.GetRequest(ctx) - - return p -} - -// GetMessages gets all flash messages for a given type. -// This allows for easy access to flash messages from the templates. -func (p Page) GetMessages(typ msg.Type) []template.HTML { - strs := msg.Get(p.Context, typ) - ret := make([]template.HTML, len(strs)) - for k, v := range strs { - ret[k] = template.HTML(v) - } - return ret -} diff --git a/pkg/page/page_test.go b/pkg/page/page_test.go deleted file mode 100644 index 1ab10cbc..00000000 --- a/pkg/page/page_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package page - -import ( - "net/http" - "testing" - - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/pkg/tests" - - echomw "github.com/labstack/echo/v4/middleware" - "github.com/stretchr/testify/assert" -) - -func TestNew(t *testing.T) { - e := echo.New() - ctx, _ := tests.NewContext(e, "/") - p := New(ctx) - assert.Same(t, ctx, p.Context) - assert.Equal(t, "/", p.Path) - assert.Equal(t, "/", p.URL) - assert.Equal(t, http.StatusOK, p.StatusCode) - assert.Equal(t, NewPager(ctx, DefaultItemsPerPage), p.Pager) - assert.Empty(t, p.Headers) - assert.True(t, p.IsHome) - assert.False(t, p.IsAuth) - assert.Empty(t, p.CSRF) - assert.Empty(t, p.RequestID) - assert.False(t, p.Cache.Enabled) - - ctx, _ = tests.NewContext(e, "/abc?def=123") - usr := &ent.User{ - ID: 1, - } - ctx.Set(context.AuthenticatedUserKey, usr) - ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf") - p = New(ctx) - assert.Equal(t, "/abc", p.Path) - assert.Equal(t, "/abc?def=123", p.URL) - assert.False(t, p.IsHome) - assert.True(t, p.IsAuth) - assert.Equal(t, usr, p.AuthUser) - assert.Equal(t, "csrf", p.CSRF) -} - -func TestPage_GetMessages(t *testing.T) { - ctx, _ := tests.NewContext(echo.New(), "/") - tests.InitSession(ctx) - p := New(ctx) - - // Set messages - msgTests := make(map[msg.Type][]string) - msgTests[msg.TypeWarning] = []string{ - "abc", - "def", - } - msgTests[msg.TypeInfo] = []string{ - "123", - "456", - } - for typ, values := range msgTests { - for _, value := range values { - msg.Set(ctx, typ, value) - } - } - - // Get the messages - for typ, values := range msgTests { - msgs := p.GetMessages(typ) - - for i, message := range msgs { - assert.Equal(t, values[i], string(message)) - } - } -} diff --git a/pkg/page/pager.go b/pkg/pager/pager.go similarity index 71% rename from pkg/page/pager.go rename to pkg/pager/pager.go index 85f530fb..f0d939bf 100644 --- a/pkg/page/pager.go +++ b/pkg/pager/pager.go @@ -1,4 +1,4 @@ -package page +package pager import ( "math" @@ -8,29 +8,29 @@ import ( ) const ( - // DefaultItemsPerPage stores the default amount of items per page + // DefaultItemsPerPage stores the default amount of items per page. DefaultItemsPerPage = 20 - // PageQueryKey stores the query key used to indicate the current page - PageQueryKey = "page" + // QueryKey stores the query key used to indicate the current page. + QueryKey = "page" ) -// Pager provides a mechanism to allow a user to page results via a query parameter +// Pager provides a mechanism to allow a user to page results via a query parameter. type Pager struct { - // Items stores the total amount of items in the result set + // Items stores the total amount of items in the result set. Items int - // Page stores the current page number + // Page stores the current page number. Page int - // ItemsPerPage stores the amount of items to display per page + // ItemsPerPage stores the amount of items to display per page. ItemsPerPage int - // Pages stores the total amount of pages in the result set + // Pages stores the total amount of pages in the result set. Pages int } -// NewPager creates a new Pager +// NewPager creates a new Pager. func NewPager(ctx echo.Context, itemsPerPage int) Pager { p := Pager{ ItemsPerPage: itemsPerPage, @@ -38,7 +38,7 @@ func NewPager(ctx echo.Context, itemsPerPage int) Pager { Page: 1, } - if page := ctx.QueryParam(PageQueryKey); page != "" { + if page := ctx.QueryParam(QueryKey); page != "" { if pageInt, err := strconv.Atoi(page); err == nil { if pageInt > 0 { p.Page = pageInt @@ -67,18 +67,18 @@ func (p *Pager) SetItems(items int) { } // IsBeginning determines if the pager is at the beginning of the pages -func (p Pager) IsBeginning() bool { +func (p *Pager) IsBeginning() bool { return p.Page == 1 } // IsEnd determines if the pager is at the end of the pages -func (p Pager) IsEnd() bool { +func (p *Pager) IsEnd() bool { return p.Page >= p.Pages } // GetOffset determines the offset of the results in order to get the items for // the current page -func (p Pager) GetOffset() int { +func (p *Pager) GetOffset() int { if p.Page == 0 { p.Page = 1 } diff --git a/pkg/page/pager_test.go b/pkg/pager/pager_test.go similarity index 90% rename from pkg/page/pager_test.go rename to pkg/pager/pager_test.go index bfb58806..44cace91 100644 --- a/pkg/page/pager_test.go +++ b/pkg/pager/pager_test.go @@ -1,4 +1,4 @@ -package page +package pager import ( "fmt" @@ -19,11 +19,11 @@ func TestNewPager(t *testing.T) { assert.Equal(t, 0, pgr.Items) assert.Equal(t, 1, pgr.Pages) - ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, 2)) + ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, 2)) pgr = NewPager(ctx, 10) assert.Equal(t, 2, pgr.Page) - ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, -2)) + ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, -2)) pgr = NewPager(ctx, 10) assert.Equal(t, 1, pgr.Page) } diff --git a/pkg/services/container.go b/pkg/services/container.go index db5e04e4..9bd074dc 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -16,7 +16,6 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/ent" - "github.com/mikestefanello/pagoda/pkg/funcmap" "github.com/mikestefanello/pagoda/pkg/log" // Require by ent @@ -53,9 +52,6 @@ type Container struct { // Auth stores an authentication client Auth *AuthClient - // TemplateRenderer stores a service to easily render and cache templates - TemplateRenderer *TemplateRenderer - // Tasks stores the task client Tasks *backlite.Client } @@ -71,7 +67,6 @@ func NewContainer() *Container { c.initFiles() c.initORM() c.initAuth() - c.initTemplateRenderer() c.initMail() c.initTasks() return c @@ -196,11 +191,6 @@ func (c *Container) initAuth() { c.Auth = NewAuthClient(c.Config, c.ORM) } -// initTemplateRenderer initializes the template renderer -func (c *Container) initTemplateRenderer() { - c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Cache, funcmap.NewFuncMap(c.Web)) -} - // initMail initialize the mail client func (c *Container) initMail() { var err error diff --git a/pkg/services/container_test.go b/pkg/services/container_test.go index 877578bc..7f4a5d41 100644 --- a/pkg/services/container_test.go +++ b/pkg/services/container_test.go @@ -16,6 +16,5 @@ func TestNewContainer(t *testing.T) { assert.NotNil(t, c.ORM) assert.NotNil(t, c.Mail) assert.NotNil(t, c.Auth) - assert.NotNil(t, c.TemplateRenderer) assert.NotNil(t, c.Tasks) } diff --git a/pkg/services/template_renderer.go b/pkg/services/template_renderer.go deleted file mode 100644 index 92cb172c..00000000 --- a/pkg/services/template_renderer.go +++ /dev/null @@ -1,376 +0,0 @@ -package services - -import ( - "bytes" - "errors" - "fmt" - "html/template" - "io/fs" - "net/http" - "sync" - - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/context" - "github.com/mikestefanello/pagoda/pkg/log" - "github.com/mikestefanello/pagoda/pkg/page" - "github.com/mikestefanello/pagoda/templates" -) - -// cachedPageGroup stores the cache group for cached pages -const cachedPageGroup = "page" - -type ( - // TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of - // templates while also providing caching and/or hot-reloading depending on your current environment - TemplateRenderer struct { - // templateCache stores a cache of parsed page templates - templateCache sync.Map - - // funcMap stores the template function map - funcMap template.FuncMap - - // config stores application configuration - config *config.Config - - // cache stores the cache client - cache *CacheClient - } - - // TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache - TemplateParsed struct { - // Template is the parsed template - Template *template.Template - - // build stores the build data used to parse the template - build *templateBuild - } - - // templateBuild stores the build data used to parse a template - templateBuild struct { - group string - key string - base string - files []string - directories []string - } - - // templateBuilder handles chaining a template parse operation - templateBuilder struct { - build *templateBuild - renderer *TemplateRenderer - } - - // CachedPage is what is used to store a rendered Page in the cache - CachedPage struct { - // URL stores the URL of the requested page - URL string - - // HTML stores the complete HTML of the rendered Page - HTML []byte - - // StatusCode stores the HTTP status code - StatusCode int - - // Headers stores the HTTP headers - Headers map[string]string - } -) - -// NewTemplateRenderer creates a new TemplateRenderer -func NewTemplateRenderer(cfg *config.Config, cache *CacheClient, fm template.FuncMap) *TemplateRenderer { - return &TemplateRenderer{ - templateCache: sync.Map{}, - funcMap: fm, - config: cfg, - cache: cache, - } -} - -// Parse creates a template build operation -func (t *TemplateRenderer) Parse() *templateBuilder { - return &templateBuilder{ - renderer: t, - build: &templateBuild{}, - } -} - -// RenderPage renders a Page as an HTTP response -func (t *TemplateRenderer) RenderPage(ctx echo.Context, page page.Page) error { - var buf *bytes.Buffer - var err error - templateGroup := "page" - - // Page name is required - if page.Name == "" { - return echo.NewHTTPError(http.StatusInternalServerError, "page render failed due to missing name") - } - - // Use the app name in configuration if a value was not set - if page.AppName == "" { - page.AppName = t.config.App.Name - } - - // Check if this is an HTMX non-boosted request which indicates that only partial - // content should be rendered - if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted { - // Switch the layout which will only render the page content - page.Layout = templates.LayoutHTMX - - // Alter the template group so this is cached separately - templateGroup = "page:htmx" - } - - // Parse and execute the templates for the Page - // As mentioned in the documentation for the Page struct, the templates used for the page will be: - // 1. The layout/base template specified in Page.Layout - // 2. The content template specified in Page.Name - // 3. All templates within the components directory - // Also included is the function map provided by the funcmap package - buf, err = t. - Parse(). - Group(templateGroup). - Key(string(page.Name)). - Base(string(page.Layout)). - Files( - fmt.Sprintf("layouts/%s", page.Layout), - fmt.Sprintf("pages/%s", page.Name), - ). - Directories("components"). - Execute(page) - - if err != nil { - return echo.NewHTTPError( - http.StatusInternalServerError, - fmt.Sprintf("failed to parse and execute templates: %s", err), - ) - } - - // Set the status code - ctx.Response().Status = page.StatusCode - - // Set any headers - for k, v := range page.Headers { - ctx.Response().Header().Set(k, v) - } - - // Apply the HTMX response, if one - if page.HTMX.Response != nil { - page.HTMX.Response.Apply(ctx) - } - - // Cache this page, if caching was enabled - t.cachePage(ctx, page, buf) - - return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes()) -} - -// cachePage caches the HTML for a given Page if the Page has caching enabled -func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *bytes.Buffer) { - if !page.Cache.Enabled || page.IsAuth { - return - } - - // If no expiration time was provided, default to the configuration value - if page.Cache.Expiration == 0 { - page.Cache.Expiration = t.config.Cache.Expiration.Page - } - - // Extract the headers - headers := make(map[string]string) - for k, v := range ctx.Response().Header() { - headers[k] = v[0] - } - - // The request URL is used as the cache key so the middleware can serve the - // cached page on matching requests - key := ctx.Request().URL.String() - cp := &CachedPage{ - URL: key, - HTML: html.Bytes(), - Headers: headers, - StatusCode: ctx.Response().Status, - } - - err := t.cache. - Set(). - Group(cachedPageGroup). - Key(key). - Tags(page.Cache.Tags...). - Expiration(page.Cache.Expiration). - Data(cp). - Save(ctx.Request().Context()) - - switch { - case err == nil: - log.Ctx(ctx).Debug("cached page") - case !context.IsCanceledError(err): - log.Ctx(ctx).Error("failed to cache page", - "error", err, - ) - } -} - -// GetCachedPage attempts to fetch a cached page for a given URL -func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedPage, error) { - p, err := t.cache. - Get(). - Group(cachedPageGroup). - Key(url). - Fetch(ctx.Request().Context()) - - if err != nil { - return nil, err - } - - return p.(*CachedPage), nil -} - -// getCacheKey gets a cache key for a given group and ID -func (t *TemplateRenderer) getCacheKey(group, key string) string { - if group != "" { - return fmt.Sprintf("%s:%s", group, key) - } - return key -} - -// parse parses a set of templates and caches them for quick execution -// If the application environment is set to local, the cache will be bypassed and templates will be -// parsed upon each request so hot-reloading is possible without restarts. -// Also included will be the function map provided by the funcmap package. -func (t *TemplateRenderer) parse(build *templateBuild) (*TemplateParsed, error) { - var tp *TemplateParsed - var err error - - switch { - case build.key == "": - return nil, errors.New("cannot parse template without key") - case len(build.files) == 0 && len(build.directories) == 0: - return nil, errors.New("cannot parse template without files or directories") - case build.base == "": - return nil, errors.New("cannot parse template without base") - } - - // Generate the cache key - cacheKey := t.getCacheKey(build.group, build.key) - - // Check if the template has not yet been parsed or if the app environment is local, so that - // templates reflect changes without having the restart the server - if tp, err = t.Load(build.group, build.key); err != nil || t.config.App.Environment == config.EnvLocal { - // Initialize the parsed template with the function map - parsed := template.New(build.base + config.TemplateExt). - Funcs(t.funcMap) - - // Format the requested files - for k, v := range build.files { - build.files[k] = fmt.Sprintf("%s%s", v, config.TemplateExt) - } - - // Include all files within the requested directories - for k, v := range build.directories { - build.directories[k] = fmt.Sprintf("%s/*%s", v, config.TemplateExt) - } - - // Get the templates - var tpl fs.FS - if t.config.App.Environment == config.EnvLocal { - tpl = templates.GetOS() - } else { - tpl = templates.Get() - } - - // Parse the templates - parsed, err = parsed.ParseFS(tpl, append(build.files, build.directories...)...) - if err != nil { - return nil, err - } - - // Store the template so this process only happens once - tp = &TemplateParsed{ - Template: parsed, - build: build, - } - t.templateCache.Store(cacheKey, tp) - } - - return tp, nil -} - -// Load loads a template from the cache -func (t *TemplateRenderer) Load(group, key string) (*TemplateParsed, error) { - load, ok := t.templateCache.Load(t.getCacheKey(group, key)) - if !ok { - return nil, errors.New("uncached page template requested") - } - - tmpl, ok := load.(*TemplateParsed) - if !ok { - return nil, errors.New("unable to cast cached template") - } - - return tmpl, nil -} - -// Execute executes a template with the given data and provides the output -func (t *TemplateParsed) Execute(data any) (*bytes.Buffer, error) { - if t.Template == nil { - return nil, errors.New("cannot execute template: template not initialized") - } - - buf := new(bytes.Buffer) - err := t.Template.ExecuteTemplate(buf, t.build.base+config.TemplateExt, data) - if err != nil { - return nil, err - } - - return buf, nil -} - -// Group sets the cache group for the template being built -func (t *templateBuilder) Group(group string) *templateBuilder { - t.build.group = group - return t -} - -// Key sets the cache key for the template being built -func (t *templateBuilder) Key(key string) *templateBuilder { - t.build.key = key - return t -} - -// Base sets the name of the base template to be used during template parsing and execution. -// This should be only the file name without a directory or extension. -func (t *templateBuilder) Base(base string) *templateBuilder { - t.build.base = base - return t -} - -// Files sets a list of template files to include in the parse. -// This should not include the file extension and the paths should be relative to the templates directory. -func (t *templateBuilder) Files(files ...string) *templateBuilder { - t.build.files = files - return t -} - -// Directories sets a list of directories that all template files within will be parsed. -// The paths should be relative to the templates directory. -func (t *templateBuilder) Directories(directories ...string) *templateBuilder { - t.build.directories = directories - return t -} - -// Store parsed the templates and stores them in the cache -func (t *templateBuilder) Store() (*TemplateParsed, error) { - return t.renderer.parse(t.build) -} - -// Execute executes the template with the given data. -// If the template has not already been cached, this will parse and cache the template -func (t *templateBuilder) Execute(data any) (*bytes.Buffer, error) { - tp, err := t.Store() - if err != nil { - return nil, err - } - - return tp.Execute(data) -} diff --git a/pkg/services/template_renderer_test.go b/pkg/services/template_renderer_test.go deleted file mode 100644 index d9c6633a..00000000 --- a/pkg/services/template_renderer_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package services - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/htmx" - "github.com/mikestefanello/pagoda/pkg/page" - "github.com/mikestefanello/pagoda/pkg/tests" - "github.com/mikestefanello/pagoda/templates" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTemplateRenderer(t *testing.T) { - group := "test" - id := "parse" - - // Should not exist yet - _, err := c.TemplateRenderer.Load(group, id) - assert.Error(t, err) - - // Parse in to the cache - tpl, err := c.TemplateRenderer. - Parse(). - Group(group). - Key(id). - Base("htmx"). - Files("layouts/htmx", "pages/error"). - Directories("components"). - Store() - require.NoError(t, err) - - // Should exist now - parsed, err := c.TemplateRenderer.Load(group, id) - require.NoError(t, err) - - // Check that all expected templates are included - expectedTemplates := make(map[string]bool) - expectedTemplates["htmx"+config.TemplateExt] = true - expectedTemplates["error"+config.TemplateExt] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } - for _, v := range parsed.Template.Templates() { - delete(expectedTemplates, v.Name()) - } - assert.Empty(t, expectedTemplates) - - data := struct { - StatusCode int - }{ - StatusCode: 500, - } - buf, err := tpl.Execute(data) - require.NoError(t, err) - require.NotNil(t, buf) - assert.Contains(t, buf.String(), "Please try again") - - buf, err = c.TemplateRenderer. - Parse(). - Group(group). - Key(id). - Base("htmx"). - Files("htmx", "pages/error"). - Directories("components"). - Execute(data) - - require.NoError(t, err) - require.NotNil(t, buf) - assert.Contains(t, buf.String(), "Please try again") -} - -func TestTemplateRenderer_RenderPage(t *testing.T) { - setup := func() (echo.Context, *httptest.ResponseRecorder, page.Page) { - ctx, rec := tests.NewContext(c.Web, "/test/TestTemplateRenderer_RenderPage") - tests.InitSession(ctx) - - p := page.New(ctx) - p.Name = "home" - p.Layout = "main" - p.Cache.Enabled = false - p.Headers["A"] = "b" - p.Headers["C"] = "d" - p.StatusCode = http.StatusCreated - return ctx, rec, p - } - - t.Run("missing name", func(t *testing.T) { - // Rendering should fail if the Page has no name - ctx, _, p := setup() - p.Name = "" - err := c.TemplateRenderer.RenderPage(ctx, p) - assert.Error(t, err) - }) - - t.Run("no page cache", func(t *testing.T) { - ctx, _, p := setup() - err := c.TemplateRenderer.RenderPage(ctx, p) - require.NoError(t, err) - - // Check status code and headers - assert.Equal(t, http.StatusCreated, ctx.Response().Status) - for k, v := range p.Headers { - assert.Equal(t, v, ctx.Response().Header().Get(k)) - } - - // Check the template cache - parsed, err := c.TemplateRenderer.Load("page", string(p.Name)) - require.NoError(t, err) - - // Check that all expected templates were parsed. - // This includes the name, layout and all components - expectedTemplates := make(map[string]bool) - expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true - expectedTemplates[fmt.Sprintf("%s%s", p.Layout, config.TemplateExt)] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } - - for _, v := range parsed.Template.Templates() { - delete(expectedTemplates, v.Name()) - } - assert.Empty(t, expectedTemplates) - }) - - t.Run("htmx rendering", func(t *testing.T) { - ctx, _, p := setup() - p.HTMX.Request.Enabled = true - p.HTMX.Response = &htmx.Response{ - Trigger: "trigger", - } - err := c.TemplateRenderer.RenderPage(ctx, p) - require.NoError(t, err) - - // Check HTMX header - assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger)) - - // Check the template cache - parsed, err := c.TemplateRenderer.Load("page:htmx", string(p.Name)) - require.NoError(t, err) - - // Check that all expected templates were parsed. - // This includes the name, htmx and all components - expectedTemplates := make(map[string]bool) - expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true - expectedTemplates["htmx"+config.TemplateExt] = true - components, err := templates.Get().ReadDir("components") - require.NoError(t, err) - for _, f := range components { - expectedTemplates[f.Name()] = true - } - - for _, v := range parsed.Template.Templates() { - delete(expectedTemplates, v.Name()) - } - assert.Empty(t, expectedTemplates) - }) - - t.Run("page cache", func(t *testing.T) { - ctx, rec, p := setup() - p.Cache.Enabled = true - p.Cache.Tags = []string{"tag1"} - err := c.TemplateRenderer.RenderPage(ctx, p) - require.NoError(t, err) - - // Fetch from the cache - cp, err := c.TemplateRenderer.GetCachedPage(ctx, p.URL) - require.NoError(t, err) - - // Compare the cached page - assert.Equal(t, p.URL, cp.URL) - assert.Equal(t, p.Headers, cp.Headers) - assert.Equal(t, p.StatusCode, cp.StatusCode) - assert.Equal(t, rec.Body.Bytes(), cp.HTML) - - // Clear the tag - err = c.Cache. - Flush(). - Tags(p.Cache.Tags[0]). - Execute(context.Background()) - require.NoError(t, err) - - // Refetch from the cache and expect no results - _, err = c.TemplateRenderer.GetCachedPage(ctx, p.URL) - assert.Error(t, err) - }) -} diff --git a/pkg/ui/models.go b/pkg/ui/models.go index bd6e7e47..cf9e3062 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models.go @@ -3,7 +3,7 @@ package ui import ( "fmt" - "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/pager" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) @@ -11,7 +11,7 @@ import ( type ( Posts struct { Posts []Post - Pager page.Pager + Pager pager.Pager } Post struct { @@ -46,7 +46,7 @@ func (p *Posts) render(path string) Node { Button( Class("button is-primary"), Attr("hx-swap", "outerHTML"), - Attr("hx-get", fmt.Sprintf("%s?page=%d", path, p.Pager.Page-1)), + Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)), Attr("hx-target", "#posts"), Text("Previous page"), ), @@ -56,7 +56,7 @@ func (p *Posts) render(path string) Node { Button( Class("button is-primary"), Attr("hx-swap", "outerHTML"), - Attr("hx-get", fmt.Sprintf("%s?page=%d", path, p.Pager.Page+1)), + Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)), Attr("hx-target", "#posts"), Text("Next page"), ), diff --git a/templates/components/core.gohtml b/templates/components/core.gohtml deleted file mode 100644 index ddfc4822..00000000 --- a/templates/components/core.gohtml +++ /dev/null @@ -1,42 +0,0 @@ -{{define "metatags"}} - {{ .AppName }}{{ if .Title }} | {{ .Title }}{{ end }} - - - - - {{- if .Metatags.Description}} - - {{- end}} - {{- if .Metatags.Keywords}} - - {{- end}} -{{end}} - -{{define "css"}} - -{{end}} - -{{define "js"}} - - -{{end}} - -{{define "footer"}} - {{- if .CSRF}} - - {{end}} - -{{end}} \ No newline at end of file diff --git a/templates/components/forms.gohtml b/templates/components/forms.gohtml deleted file mode 100644 index d67e7164..00000000 --- a/templates/components/forms.gohtml +++ /dev/null @@ -1,9 +0,0 @@ -{{define "csrf"}} - -{{end}} - -{{define "field-errors"}} - {{- range .}} -

{{.}}

- {{- end}} -{{end}} \ No newline at end of file diff --git a/templates/components/messages.gohtml b/templates/components/messages.gohtml deleted file mode 100644 index ba0dd5a5..00000000 --- a/templates/components/messages.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{define "messages"}} - {{- range (.GetMessages "success")}} - {{template "message" dict "Type" "success" "Text" .}} - {{- end}} - {{- range (.GetMessages "info")}} - {{template "message" dict "Type" "info" "Text" .}} - {{- end}} - {{- range (.GetMessages "warning")}} - {{template "message" dict "Type" "warning" "Text" .}} - {{- end}} - {{- range (.GetMessages "danger")}} - {{template "message" dict "Type" "danger" "Text" .}} - {{- end}} -{{end}} - -{{define "message"}} -
- - {{.Text}} -
-{{end}} \ No newline at end of file diff --git a/templates/emails/test.gohtml b/templates/emails/test.gohtml deleted file mode 100644 index 189fd91c..00000000 --- a/templates/emails/test.gohtml +++ /dev/null @@ -1 +0,0 @@ -Test email template. See services/mail.go to provide your implementation. \ No newline at end of file diff --git a/templates/layouts/auth.gohtml b/templates/layouts/auth.gohtml deleted file mode 100644 index 6bbc9c06..00000000 --- a/templates/layouts/auth.gohtml +++ /dev/null @@ -1,35 +0,0 @@ - - - - {{template "metatags" .}} - {{template "css" .}} - {{template "js" .}} - - -
-
-
-
-
- {{- if .Title}} -

{{.Title}}

- {{- end}} -
- {{template "messages" .}} - {{template "content" .}} - - -
-
-
-
-
-
- - {{template "footer" .}} - - \ No newline at end of file diff --git a/templates/layouts/htmx.gohtml b/templates/layouts/htmx.gohtml deleted file mode 100644 index 9846ca5b..00000000 --- a/templates/layouts/htmx.gohtml +++ /dev/null @@ -1 +0,0 @@ -{{template "content" .}} \ No newline at end of file diff --git a/templates/layouts/main.gohtml b/templates/layouts/main.gohtml deleted file mode 100644 index 779dbc84..00000000 --- a/templates/layouts/main.gohtml +++ /dev/null @@ -1,93 +0,0 @@ - - - - {{template "metatags" .}} - {{template "css" .}} - {{template "js" .}} - - - - -
-
-
- -
- -
-
- {{- if .Title}} -

{{.Title}}

- {{- end}} - - {{template "messages" .}} - {{template "content" .}} -
-
-
-
- - {{template "footer" .}} - - - -{{define "search"}} - -{{end}} diff --git a/templates/pages/about.gohtml b/templates/pages/about.gohtml deleted file mode 100644 index 2905bb00..00000000 --- a/templates/pages/about.gohtml +++ /dev/null @@ -1,41 +0,0 @@ -{{define "content"}} - {{- if .Data.FrontendTabs}} -

Frontend

-

The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.

- {{template "tabs" .Data.FrontendTabs}} -
- {{- end}} - - {{- if .Data.BackendTabs}} -

Backend

-

The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.

- {{template "tabs" .Data.BackendTabs}} -
- {{end}} - - {{- if .Data.ShowCacheWarning}} -
-
-

Warning

-
-
- This route has caching enabled so hot-reloading in the local environment will not work. -
-
- {{- end}} -{{end}} - -{{define "tabs"}} -
-
-
    - {{- range $index, $tab := .}} -
  • {{.Title}}
  • - {{- end}} -
-
- {{- range $index, $tab := .}} -

→ {{.Body}}

- {{- end}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/cache.gohtml b/templates/pages/cache.gohtml deleted file mode 100644 index a0bb0afb..00000000 --- a/templates/pages/cache.gohtml +++ /dev/null @@ -1,36 +0,0 @@ -{{define "content"}} -
-
-
-

Test the cache

-
-
- This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page. - HTMX makes it easy to re-render the cached value after the form is submitted. -
-
- - - {{if .Data}} - {{.Data}} - {{- else}} - (empty) - {{- end}} -

- -
- -
- -
-
- -
-
- -
-
- - {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/contact.gohtml b/templates/pages/contact.gohtml deleted file mode 100644 index 0b3d476e..00000000 --- a/templates/pages/contact.gohtml +++ /dev/null @@ -1,70 +0,0 @@ -{{define "content"}} - {{- if not (eq .HTMX.Request.Target "contact")}} - - {{- end}} - - {{template "form" .}} -{{end}} - -{{define "form"}} - {{- if .Form.IsDone}} -
-
-

Thank you!

-
-
- No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled. -
-
- {{- else}} -
-
- -
- -
- {{template "field-errors" (.Form.GetFieldErrors "Email")}} -
- -
- -
- - - -
- {{template "field-errors" (.Form.GetFieldErrors "Department")}} -
- -
- -
- -
- {{template "field-errors" (.Form.GetFieldErrors "Message")}} -
- -
-
- -
-
- - {{template "csrf" .}} -
- {{- end}} -{{end}} \ No newline at end of file diff --git a/templates/pages/error.gohtml b/templates/pages/error.gohtml deleted file mode 100644 index c432fe30..00000000 --- a/templates/pages/error.gohtml +++ /dev/null @@ -1,11 +0,0 @@ -{{define "content"}} - {{if ge .StatusCode 500}} -

Please try again.

- {{else if or (eq .StatusCode 403) (eq .StatusCode 401)}} -

You are not authorized to view the requested page.

- {{else if eq .StatusCode 404}} -

Click {{link (url "home") "here" .Path}} to return home

- {{else}} -

Something went wrong

- {{end}} -{{end}} \ No newline at end of file diff --git a/templates/pages/files.gohtml b/templates/pages/files.gohtml deleted file mode 100644 index 9d0bd613..00000000 --- a/templates/pages/files.gohtml +++ /dev/null @@ -1,51 +0,0 @@ -{{define "content"}} - -
-
- -
- -
-
- -
-
- - {{template "csrf" .}} -
- -
-

Uploaded files

-
-
-

Below are all files in the configured upload directory.

-
-
- - - - - - - - - - {{- range .Data}} - - - - - - {{- end}} - -
FilenameSizeModified on
{{.Name}}{{.Size}}{{.Modified}}
-{{end}} \ No newline at end of file diff --git a/templates/pages/forgot-password.gohtml b/templates/pages/forgot-password.gohtml deleted file mode 100644 index b0589624..00000000 --- a/templates/pages/forgot-password.gohtml +++ /dev/null @@ -1,23 +0,0 @@ -{{define "content"}} -
-
-

Enter your email address and we'll email you a link that allows you to reset your password.

-
-
- -
- - {{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}} -
-
-
-

- -

-

- Cancel -

-
- {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/home.gohtml b/templates/pages/home.gohtml deleted file mode 100644 index 9c89fc10..00000000 --- a/templates/pages/home.gohtml +++ /dev/null @@ -1,82 +0,0 @@ -{{define "content"}} - {{- if not (eq .HTMX.Request.Target "posts")}} - {{template "top-content" .}} - {{- end}} - - {{template "posts" .}} - - {{- if not (eq .HTMX.Request.Target "posts")}} - {{template "file-msg" .}} - {{- end}} -{{end}} - -{{define "top-content"}} -
-
-
-

- Hello{{if .IsAuth}}, {{.AuthUser.Name}}{{end}} -

-

{{if .IsAuth}}Welcome back!{{else}}Please login in to your account.{{end}}

-
-
-
- -
-

Recent posts

-

- Below is an example of both paging and AJAX fetching using HTMX -

-
-{{end}} - -{{define "posts"}} -
- {{- range .Data}} -
-
-

- Gopher -

-
-
-
-

- {{.Title}} -
- {{.Body}} -

-
-
-
- {{- end}} - -
- {{- if not $.Pager.IsBeginning}} -

- -

- {{- end}} - {{- if not $.Pager.IsEnd}} -

- -

- {{- end}} -
-
-{{end}} - -{{define "file-msg"}} -
-
-
-

Serving files

- -
-
- In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. - Static files also contain cache-control headers which are configured via middleware. - You can also use AlpineJS to dismiss this message. -
-
-{{end}} \ No newline at end of file diff --git a/templates/pages/login.gohtml b/templates/pages/login.gohtml deleted file mode 100644 index a2613bcc..00000000 --- a/templates/pages/login.gohtml +++ /dev/null @@ -1,28 +0,0 @@ -{{define "content"}} -
- {{template "messages" .}} -
- -
- - {{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}} -
-
-
- -
- - {{template "field-errors" (.Form.Submission.GetFieldErrors "Password")}} -
-
-
-

- -

-

- Cancel -

-
- {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/register.gohtml b/templates/pages/register.gohtml deleted file mode 100644 index e55781fe..00000000 --- a/templates/pages/register.gohtml +++ /dev/null @@ -1,41 +0,0 @@ -{{define "content"}} -
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "Name")}} -
-
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "Email")}} -
-
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "Password")}} -
-
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}} -
-
-
-

- -

-

- Cancel -

-
- {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/reset-password.gohtml b/templates/pages/reset-password.gohtml deleted file mode 100644 index 54445142..00000000 --- a/templates/pages/reset-password.gohtml +++ /dev/null @@ -1,24 +0,0 @@ -{{define "content"}} -
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "Password")}} -
-
-
- -
- - {{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}} -
-
-
-

- -

-
- {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/pages/search.gohtml b/templates/pages/search.gohtml deleted file mode 100644 index dc32fb61..00000000 --- a/templates/pages/search.gohtml +++ /dev/null @@ -1,5 +0,0 @@ -{{define "content"}} - {{- range .Data}} - {{.Title}} - {{- end}} -{{end}} \ No newline at end of file diff --git a/templates/pages/task.gohtml b/templates/pages/task.gohtml deleted file mode 100644 index 6436c48e..00000000 --- a/templates/pages/task.gohtml +++ /dev/null @@ -1,43 +0,0 @@ -{{define "content"}} - {{- if not (eq .HTMX.Request.Target "task")}} - - {{- end}} - - {{template "form" .}} -{{end}} - -{{define "form"}} -
- {{template "messages" .}} -
- -
- -
-

How long to wait until the task is executed

- {{template "field-errors" (.Form.GetFieldErrors "Delay")}} -
- -
- -
- -
-

The message the task will output to the log

- {{template "field-errors" (.Form.GetFieldErrors "Message")}} -
- -
-
- -
-
- - {{template "csrf" .}} -
-{{end}} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go deleted file mode 100644 index 2162a81d..00000000 --- a/templates/templates.go +++ /dev/null @@ -1,55 +0,0 @@ -package templates - -import ( - "embed" - "io/fs" - "os" - "path" - "path/filepath" - "runtime" -) - -type ( - Layout string - Page string -) - -const ( - LayoutMain Layout = "main" - LayoutAuth Layout = "auth" - LayoutHTMX Layout = "htmx" -) - -const ( - PageAbout Page = "about" - PageCache Page = "cache" - PageContact Page = "contact" - PageError Page = "error" - PageFiles Page = "files" - PageForgotPassword Page = "forgot-password" - PageHome Page = "home" - PageLogin Page = "login" - PageRegister Page = "register" - PageResetPassword Page = "reset-password" - PageSearch Page = "search" - PageTask Page = "task" -) - -//go:embed * -var templates embed.FS - -// Get returns a file system containing all templates via embed.FS -func Get() embed.FS { - return templates -} - -// GetOS returns a file system containing all templates which will load the files directly from the operating system. -// This should only be used for local development in order to facilitate live reloading. -func GetOS() fs.FS { - // Gets the complete templates directory path - // This is needed in case this is called from a package outside of main, such as within tests - _, b, _, _ := runtime.Caller(0) - d := path.Join(path.Dir(b)) - p := filepath.Join(filepath.Dir(d), "templates") - return os.DirFS(p) -} diff --git a/templates/templates_test.go b/templates/templates_test.go deleted file mode 100644 index 9b28e348..00000000 --- a/templates/templates_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package templates - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGet(t *testing.T) { - _, err := Get().Open(fmt.Sprintf("pages/%s.gohtml", PageHome)) - require.NoError(t, err) -} - -func TestGetOS(t *testing.T) { - _, err := GetOS().Open(fmt.Sprintf("pages/%s.gohtml", PageHome)) - require.NoError(t, err) -} From ab7be4051f94bffdd13f3deee20334af687cde43 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:31:58 -0500 Subject: [PATCH 12/30] Removed unneeded form interface method. --- pkg/form/form.go | 25 +++++++++++-------------- pkg/form/submission.go | 28 +++++++++------------------- pkg/form/submission_test.go | 2 -- pkg/handlers/contact.go | 8 +++----- pkg/handlers/pages_test.go | 3 ++- pkg/handlers/search.go | 4 +--- pkg/handlers/task.go | 8 +++----- pkg/ui/components.go | 16 ++++++++-------- 8 files changed, 37 insertions(+), 57 deletions(-) diff --git a/pkg/form/form.go b/pkg/form/form.go index 6832d155..16177e15 100644 --- a/pkg/form/form.go +++ b/pkg/form/form.go @@ -5,7 +5,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/context" ) -// Form represents a form that can be submitted and validated +// Form represents a form that can be submitted and validated. type Form interface { // Submit marks the form as submitted, stores a pointer to it in the context, binds the request // values to the struct fields, and validates the input based on the struct tags. @@ -13,29 +13,26 @@ type Form interface { // Returns an echo.HTTPError if the request failed to process. Submit(c echo.Context, form any) error - // IsSubmitted returns true if the form was submitted + // IsSubmitted returns true if the form was submitted. IsSubmitted() bool - // IsValid returns true if the form has no validation errors + // IsValid returns true if the form has no validation errors. IsValid() bool - // IsDone returns true if the form was submitted and has no validation errors + // IsDone returns true if the form was submitted and has no validation errors. IsDone() bool - // FieldHasErrors returns true if a given struct field has validation errors + // FieldHasErrors returns true if a given struct field has validation errors. FieldHasErrors(fieldName string) bool - // SetFieldError sets a validation error message for a given struct field + // SetFieldError sets a validation error message for a given struct field. SetFieldError(fieldName string, message string) - // GetFieldErrors returns the validation errors for a given struct field + // GetFieldErrors returns the validation errors for a given struct field. GetFieldErrors(fieldName string) []string - - // GetFieldStatusClass returns a CSS class to be used for a given struct field - GetFieldStatusClass(fieldName string) string } -// Get gets a form from the context or initializes a new copy if one is not set +// Get gets a form from the context or initializes a new copy if one is not set. func Get[T any](ctx echo.Context) *T { if v := ctx.Get(context.FormKey); v != nil { return v.(*T) @@ -44,13 +41,13 @@ func Get[T any](ctx echo.Context) *T { return &v } -// Clear removes the form set in the context +// Clear removes the form set in the context. func Clear(ctx echo.Context) { ctx.Set(context.FormKey, nil) } -// Submit submits a form -// See Form.Submit() +// Submit submits a form. +// See Form.Submit(). func Submit(ctx echo.Context, form Form) error { return form.Submit(ctx, form) } diff --git a/pkg/form/submission.go b/pkg/form/submission.go index 88d898fe..48fdc8a1 100644 --- a/pkg/form/submission.go +++ b/pkg/form/submission.go @@ -13,25 +13,25 @@ import ( // Submission represents the state of the submission of a form, not including the form itself. // This satisfies the Form interface. type Submission struct { - // isSubmitted indicates if the form has been submitted + // isSubmitted indicates if the form has been submitted. isSubmitted bool - // errors stores a slice of error message strings keyed by form struct field name + // errors stores a slice of error message strings keyed by form struct field name. errors map[string][]string } func (f *Submission) Submit(ctx echo.Context, form any) error { f.isSubmitted = true - // Set in context so the form can later be retrieved + // Set in context so the form can later be retrieved. ctx.Set(context.FormKey, form) - // Bind the values from the incoming request to the form struct + // Bind the values from the incoming request to the form struct. if err := ctx.Bind(form); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err)) } - // Validate the form + // Validate the form. if err := ctx.Validate(form); err != nil { f.setErrorMessages(err) return err @@ -73,17 +73,7 @@ func (f *Submission) GetFieldErrors(fieldName string) []string { return f.errors[fieldName] } -func (f *Submission) GetFieldStatusClass(fieldName string) string { - if f.isSubmitted { - if f.FieldHasErrors(fieldName) { - return "is-danger" - } - return "is-success" - } - return "" -} - -// setErrorMessages sets errors messages on the submission for all fields that failed validation +// setErrorMessages sets errors messages on the submission for all fields that failed validation. func (f *Submission) setErrorMessages(err error) { // Only this is supported right now ves, ok := err.(validator.ValidationErrors) @@ -94,8 +84,8 @@ func (f *Submission) setErrorMessages(err error) { for _, ve := range ves { var message string - // Provide better error messages depending on the failed validation tag - // This should be expanded as you use additional tags in your validation + // Provide better error messages depending on the failed validation tag. + // This should be expanded as you use additional tags in your validation. switch ve.Tag() { case "required": message = "This field is required." @@ -109,7 +99,7 @@ func (f *Submission) setErrorMessages(err error) { message = "Invalid value." } - // Add the error + // Add the error. f.SetFieldError(ve.Field(), message) } } diff --git a/pkg/form/submission_test.go b/pkg/form/submission_test.go index 3b3a9dcb..e6b4f9bc 100644 --- a/pkg/form/submission_test.go +++ b/pkg/form/submission_test.go @@ -40,8 +40,6 @@ func TestFormSubmission(t *testing.T) { require.Len(t, form.GetFieldErrors("Name"), 1) assert.Len(t, form.GetFieldErrors("Email"), 0) assert.Equal(t, "This field is required.", form.GetFieldErrors("Name")[0]) - assert.Equal(t, "is-danger", form.GetFieldStatusClass("Name")) - assert.Equal(t, "is-success", form.GetFieldStatusClass("Email")) assert.False(t, form.IsDone()) formInCtx := Get[formTest](ctx) diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 9112e518..4708b4fe 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -11,11 +11,9 @@ import ( "github.com/mikestefanello/pagoda/pkg/ui" ) -type ( - Contact struct { - mail *services.MailClient - } -) +type Contact struct { + mail *services.MailClient +} func init() { Register(new(Contact)) diff --git a/pkg/handlers/pages_test.go b/pkg/handlers/pages_test.go index 85c591be..0cd8ff3e 100644 --- a/pkg/handlers/pages_test.go +++ b/pkg/handlers/pages_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/stretchr/testify/assert" ) @@ -11,7 +12,7 @@ import ( // this test package func TestPages__About(t *testing.T) { doc := request(t). - setRoute(routeNameAbout). + setRoute(routenames.About). get(). assertStatusCode(http.StatusOK). toDoc() diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 2ebb2d61..86c18847 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -10,9 +10,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/ui" ) -type ( - Search struct{} -) +type Search struct{} func init() { Register(new(Search)) diff --git a/pkg/handlers/task.go b/pkg/handlers/task.go index ab95303a..6f4d8a8e 100644 --- a/pkg/handlers/task.go +++ b/pkg/handlers/task.go @@ -16,11 +16,9 @@ import ( "github.com/mikestefanello/pagoda/pkg/tasks" ) -type ( - Task struct { - tasks *backlite.Client - } -) +type Task struct { + tasks *backlite.Client +} func init() { Register(new(Task)) diff --git a/pkg/ui/components.go b/pkg/ui/components.go index 30a1cbe6..f4ee46ab 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -95,11 +95,11 @@ func sidebarMenu(r *request) Node { Ul( Class("menu-list"), menuLink(r, "Dashboard", routenames.Home), - menuLink(r, "About", "about"), + menuLink(r, "About", routenames.About), menuLink(r, "Contact", routenames.Contact), - menuLink(r, "Cache", "cache"), - menuLink(r, "Task", "task"), - menuLink(r, "Files", "files"), + menuLink(r, "Cache", routenames.Cache), + menuLink(r, "Task", routenames.Task), + menuLink(r, "Files", routenames.Files), ), P( Class("menu-label"), @@ -107,10 +107,10 @@ func sidebarMenu(r *request) Node { ), Ul( Class("menu-list"), - If(r.IsAuth, menuLink(r, "Logout", "logout")), - If(!r.IsAuth, menuLink(r, "Login", "login")), - If(!r.IsAuth, menuLink(r, "Register", "register")), - If(!r.IsAuth, menuLink(r, "Forgot password", "forgot_password")), + If(r.IsAuth, menuLink(r, "Logout", routenames.Logout)), + If(!r.IsAuth, menuLink(r, "Login", routenames.Login)), + If(!r.IsAuth, menuLink(r, "Register", routenames.Register)), + If(!r.IsAuth, menuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)), ), ) } From c6c1bdd353afe7ef9ee0cf29a1d4fcc3f66e8ca6 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:44:09 -0500 Subject: [PATCH 13/30] Move app name to ui. --- config/config.go | 1 - config/config.yaml | 1 - pkg/ui/components.go | 2 +- pkg/ui/models.go | 8 ++++---- pkg/ui/utils.go | 6 +++++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/config/config.go b/config/config.go index dcc3fdf9..67597f5d 100644 --- a/config/config.go +++ b/config/config.go @@ -79,7 +79,6 @@ type ( // AppConfig stores application configuration AppConfig struct { - Name string Environment environment EncryptionKey string Timeout time.Duration diff --git a/config/config.yaml b/config/config.yaml index 5193bd46..70fedbb6 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -11,7 +11,6 @@ http: key: "" app: - name: "Pagoda" environment: "local" # Change this on any live environments encryptionKey: "?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf" diff --git a/pkg/ui/components.go b/pkg/ui/components.go index f4ee46ab..3a6ad082 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -19,7 +19,7 @@ func head(r *request) Node { Meta(Charset("utf-8")), Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), Link(Rel("icon"), Href(file("favicon.png"))), - TitleEl(Text(r.Title)), + TitleEl(Text(appName), If(r.Title != "", Text(" | "+r.Title))), If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))), If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))), Link(Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"), Rel("stylesheet")), diff --git a/pkg/ui/models.go b/pkg/ui/models.go index cf9e3062..e388722c 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models.go @@ -41,7 +41,7 @@ func (p *Posts) render(path string) Node { g, Div( Class("field is-grouped is-grouped-centered"), - P( + If(!p.Pager.IsBeginning(), P( Class("control"), Button( Class("button is-primary"), @@ -50,8 +50,8 @@ func (p *Posts) render(path string) Node { Attr("hx-target", "#posts"), Text("Previous page"), ), - ), - P( + )), + If(!p.Pager.IsEnd(), P( Class("control"), Button( Class("button is-primary"), @@ -60,7 +60,7 @@ func (p *Posts) render(path string) Node { Attr("hx-target", "#posts"), Text("Next page"), ), - ), + )), ), ) } diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go index b61bce4b..01e19346 100644 --- a/pkg/ui/utils.go +++ b/pkg/ui/utils.go @@ -7,8 +7,12 @@ import ( "github.com/mikestefanello/pagoda/config" ) +const ( + appName = "Pagoda" +) + var ( - // CacheBuster stores a random string used as a cache buster for static files. + // cacheBuster stores a random string used as a cache buster for static files. cacheBuster = random.String(10) ) From 99afc2208d4db03ec86d8d396129394e45484c41 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:09:23 -0500 Subject: [PATCH 14/30] Redo auth nav menu. --- pkg/ui/components.go | 15 +++++++++++++++ pkg/ui/layouts.go | 11 +---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pkg/ui/components.go b/pkg/ui/components.go index 3a6ad082..0ffba1c1 100644 --- a/pkg/ui/components.go +++ b/pkg/ui/components.go @@ -127,6 +127,21 @@ func menuLink(r *request, title, routeName string, routeParams ...string) Node { ) } +func authNavBar(r *request) Node { + return Nav( + Class("navbar"), + Div( + Class("navbar-menu"), + Div( + Class("navbar-start"), + A(Class("navbar-item"), Href(r.path(routenames.Login)), Text("Login")), + A(Class("navbar-item"), Href(r.path(routenames.Register)), Text("Create an account")), + A(Class("navbar-item"), Href(r.path(routenames.ForgotPassword)), Text("Forgot password")), + ), + ), + ) +} + func navBar(r *request) Node { return Nav( Class("navbar is-dark"), diff --git a/pkg/ui/layouts.go b/pkg/ui/layouts.go index 71260c5b..d2bb86a0 100644 --- a/pkg/ui/layouts.go +++ b/pkg/ui/layouts.go @@ -1,7 +1,6 @@ package ui import ( - "github.com/mikestefanello/pagoda/pkg/routenames" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) @@ -56,15 +55,7 @@ func layoutAuth(r *request, content Node) Node { Class("notification"), flashMessages(r), content, - Div( - Class("content is-small has-text-centered"), - hxBoost(), - A(Href(r.path(routenames.Login)), Text("Login")), - Raw(" ◌ "), - A(Href(r.path("register")), Text("Create an account")), - Raw(" ◌ "), - A(Href(r.path("forgot_password")), Text("Forgot password")), - ), + authNavBar(r), ), ), ), From 8f42bd7b517c572115b88924f3b44ee320855cf9 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Tue, 25 Feb 2025 08:53:56 -0500 Subject: [PATCH 15/30] Added separate packages within ui. --- .air.toml | 2 +- pkg/handlers/auth.go | 22 +- pkg/handlers/cache.go | 9 +- pkg/handlers/contact.go | 7 +- pkg/handlers/error.go | 4 +- pkg/handlers/files.go | 9 +- pkg/handlers/pages.go | 13 +- pkg/handlers/search.go | 9 +- pkg/handlers/task.go | 7 +- pkg/ui/components.go | 309 --------------------------- pkg/ui/components/alerts.go | 64 ++++++ pkg/ui/components/form.go | 203 ++++++++++++++++++ pkg/ui/components/head.go | 69 ++++++ pkg/ui/components/htmx.go | 9 + pkg/ui/components/nav.go | 19 ++ pkg/ui/components/tabs.go | 56 +++++ pkg/ui/{emails.go => emails/auth.go} | 4 +- pkg/ui/form.go | 187 ---------------- pkg/ui/forms.go | 289 ------------------------- pkg/ui/forms/cache.go | 53 +++++ pkg/ui/forms/contact.go | 58 +++++ pkg/ui/forms/file.go | 27 +++ pkg/ui/forms/forgot_password.go | 39 ++++ pkg/ui/forms/login.go | 49 +++++ pkg/ui/forms/register.go | 66 ++++++ pkg/ui/forms/reset_password.go | 46 ++++ pkg/ui/forms/task.go | 49 +++++ pkg/ui/layouts.go | 69 ------ pkg/ui/layouts/auth.go | 61 ++++++ pkg/ui/layouts/primary.go | 153 +++++++++++++ pkg/ui/models/file.go | 22 ++ pkg/ui/{models.go => models/post.go} | 38 +--- pkg/ui/models/search_result.go | 19 ++ pkg/ui/pages.go | 291 ------------------------- pkg/ui/pages/about.go | 52 +++++ pkg/ui/pages/auth.go | 46 ++++ pkg/ui/pages/cache.go | 15 ++ pkg/ui/pages/contact.go | 41 ++++ pkg/ui/pages/error.go | 38 ++++ pkg/ui/pages/file.go | 53 +++++ pkg/ui/pages/home.go | 69 ++++++ pkg/ui/pages/search.go | 20 ++ pkg/ui/pages/task.go | 33 +++ pkg/ui/request.go | 24 +-- pkg/ui/utils.go | 4 +- 45 files changed, 1496 insertions(+), 1230 deletions(-) delete mode 100644 pkg/ui/components.go create mode 100644 pkg/ui/components/alerts.go create mode 100644 pkg/ui/components/form.go create mode 100644 pkg/ui/components/head.go create mode 100644 pkg/ui/components/htmx.go create mode 100644 pkg/ui/components/nav.go create mode 100644 pkg/ui/components/tabs.go rename pkg/ui/{emails.go => emails/auth.go} (78%) delete mode 100644 pkg/ui/form.go delete mode 100644 pkg/ui/forms.go create mode 100644 pkg/ui/forms/cache.go create mode 100644 pkg/ui/forms/contact.go create mode 100644 pkg/ui/forms/file.go create mode 100644 pkg/ui/forms/forgot_password.go create mode 100644 pkg/ui/forms/login.go create mode 100644 pkg/ui/forms/register.go create mode 100644 pkg/ui/forms/reset_password.go create mode 100644 pkg/ui/forms/task.go delete mode 100644 pkg/ui/layouts.go create mode 100644 pkg/ui/layouts/auth.go create mode 100644 pkg/ui/layouts/primary.go create mode 100644 pkg/ui/models/file.go rename pkg/ui/{models.go => models/post.go} (71%) create mode 100644 pkg/ui/models/search_result.go delete mode 100644 pkg/ui/pages.go create mode 100644 pkg/ui/pages/about.go create mode 100644 pkg/ui/pages/auth.go create mode 100644 pkg/ui/pages/cache.go create mode 100644 pkg/ui/pages/contact.go create mode 100644 pkg/ui/pages/error.go create mode 100644 pkg/ui/pages/file.go create mode 100644 pkg/ui/pages/home.go create mode 100644 pkg/ui/pages/search.go create mode 100644 pkg/ui/pages/task.go diff --git a/.air.toml b/.air.toml index 88298899..aa66c5e9 100644 --- a/.air.toml +++ b/.air.toml @@ -25,7 +25,7 @@ tmp_dir = "tmp" rerun = false rerun_delay = 500 send_interrupt = false - stop_on_error = false + stop_on_error = true [color] app = "" diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 3b54fad2..59c99cd1 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -16,7 +16,9 @@ import ( "github.com/mikestefanello/pagoda/pkg/redirect" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/emails" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Auth struct { @@ -57,11 +59,11 @@ func (h *Auth) Routes(g *echo.Group) { } func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { - return ui.ForgotPassword(ctx, form.Get[ui.ForgotPasswordForm](ctx)) + return pages.ForgotPassword(ctx, form.Get[forms.ForgotPassword](ctx)) } func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { - var input ui.ForgotPasswordForm + var input forms.ForgotPassword succeed := func() error { form.Clear(ctx) @@ -120,11 +122,11 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { } func (h *Auth) LoginPage(ctx echo.Context) error { - return ui.Login(ctx, form.Get[ui.LoginForm](ctx)) + return pages.Login(ctx, form.Get[forms.Login](ctx)) } func (h *Auth) LoginSubmit(ctx echo.Context) error { - var input ui.LoginForm + var input forms.Login authFailed := func() error { input.SetFieldError("Email", "") @@ -188,11 +190,11 @@ func (h *Auth) Logout(ctx echo.Context) error { } func (h *Auth) RegisterPage(ctx echo.Context) error { - return ui.Register(ctx, form.Get[ui.RegisterForm](ctx)) + return pages.Register(ctx, form.Get[forms.Register](ctx)) } func (h *Auth) RegisterSubmit(ctx echo.Context) error { - var input ui.RegisterForm + var input forms.Register err := form.Submit(ctx, &input) @@ -273,7 +275,7 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { Compose(). To(usr.Email). Subject("Confirm your email address"). - Component(ui.ConfirmEmailAddressEmail(usr.Name, url)). + Component(emails.ConfirmEmailAddress(usr.Name, url)). Send(ctx) if err != nil { @@ -288,11 +290,11 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { } func (h *Auth) ResetPasswordPage(ctx echo.Context) error { - return ui.ResetPassword(ctx, form.Get[ui.ResetPasswordForm](ctx)) + return pages.ResetPassword(ctx, form.Get[forms.ResetPassword](ctx)) } func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { - var input ui.ResetPasswordForm + var input forms.ResetPassword err := form.Submit(ctx, &input) diff --git a/pkg/handlers/cache.go b/pkg/handlers/cache.go index b6cce9d4..2b6d3b23 100644 --- a/pkg/handlers/cache.go +++ b/pkg/handlers/cache.go @@ -8,7 +8,8 @@ import ( "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Cache struct { @@ -30,7 +31,7 @@ func (h *Cache) Routes(g *echo.Group) { } func (h *Cache) Page(ctx echo.Context) error { - f := form.Get[ui.CacheForm](ctx) + f := form.Get[forms.Cache](ctx) // Fetch the value from the cache. value, err := h.cache. @@ -47,11 +48,11 @@ func (h *Cache) Page(ctx echo.Context) error { return fail(err, "failed to fetch from cache") } - return ui.UpdateCache(ctx, f) + return pages.UpdateCache(ctx, f) } func (h *Cache) Submit(ctx echo.Context) error { - var input ui.CacheForm + var input forms.Cache if err := form.Submit(ctx, &input); err != nil { return err diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 4708b4fe..28fa0ae2 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -8,7 +8,8 @@ import ( "github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Contact struct { @@ -30,11 +31,11 @@ func (h *Contact) Routes(g *echo.Group) { } func (h *Contact) Page(ctx echo.Context) error { - return ui.ContactUs(ctx, form.Get[ui.ContactForm](ctx)) + return pages.ContactUs(ctx, form.Get[forms.Contact](ctx)) } func (h *Contact) Submit(ctx echo.Context) error { - var input ui.ContactForm + var input forms.Contact err := form.Submit(ctx, &input) diff --git a/pkg/handlers/error.go b/pkg/handlers/error.go index c4237714..a35ab09f 100644 --- a/pkg/handlers/error.go +++ b/pkg/handlers/error.go @@ -6,7 +6,7 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/log" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Error struct{} @@ -35,7 +35,7 @@ func (e *Error) Page(err error, ctx echo.Context) { ctx.Response().Status = code // Render the error page. - if err = ui.Error(ctx, code); err != nil { + if err = pages.Error(ctx, code); err != nil { log.Ctx(ctx).Error("failed to render error page", "error", err, ) diff --git a/pkg/handlers/files.go b/pkg/handlers/files.go index d7afc01b..5c77e848 100644 --- a/pkg/handlers/files.go +++ b/pkg/handlers/files.go @@ -9,7 +9,8 @@ import ( "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/models" + "github.com/mikestefanello/pagoda/pkg/ui/pages" "github.com/spf13/afero" ) @@ -38,16 +39,16 @@ func (h *Files) Page(ctx echo.Context) error { return err } - files := make([]*ui.File, 0) + files := make([]*models.File, 0) for _, file := range info { - files = append(files, &ui.File{ + files = append(files, &models.File{ Name: file.Name(), Size: file.Size(), Modified: file.ModTime().Format(time.DateTime), }) } - return ui.UploadFile(ctx, files) + return pages.UploadFile(ctx, files) } func (h *Files) Submit(ctx echo.Context) error { diff --git a/pkg/handlers/pages.go b/pkg/handlers/pages.go index dd1002d5..b5937df0 100644 --- a/pkg/handlers/pages.go +++ b/pkg/handlers/pages.go @@ -7,7 +7,8 @@ import ( "github.com/mikestefanello/pagoda/pkg/pager" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/models" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Pages struct{} @@ -28,19 +29,19 @@ func (h *Pages) Routes(g *echo.Group) { func (h *Pages) Home(ctx echo.Context) error { pgr := pager.NewPager(ctx, 4) - return ui.Home(ctx, ui.Posts{ + return pages.Home(ctx, &models.Posts{ Posts: h.fetchPosts(&pgr), Pager: pgr, }) } // fetchPosts is a mock example of fetching posts to illustrate how paging works. -func (h *Pages) fetchPosts(pager *pager.Pager) []ui.Post { +func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post { pager.SetItems(20) - posts := make([]ui.Post, 20) + posts := make([]models.Post, 20) for k := range posts { - posts[k] = ui.Post{ + posts[k] = models.Post{ Title: fmt.Sprintf("Post example #%d", k+1), Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1), } @@ -49,5 +50,5 @@ func (h *Pages) fetchPosts(pager *pager.Pager) []ui.Post { } func (h *Pages) About(ctx echo.Context) error { - return ui.About(ctx) + return pages.About(ctx) } diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 86c18847..f0e92caf 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -7,7 +7,8 @@ import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/services" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/models" + "github.com/mikestefanello/pagoda/pkg/ui/pages" ) type Search struct{} @@ -26,18 +27,18 @@ func (h *Search) Routes(g *echo.Group) { func (h *Search) Page(ctx echo.Context) error { // Fake search results. - results := make([]*ui.SearchResult, 0, 5) + results := make([]*models.SearchResult, 0, 5) if search := ctx.QueryParam("query"); search != "" { for i := 0; i < 5; i++ { title := "Lorem ipsum example ddolor sit amet" index := rand.Intn(len(title)) title = title[:index] + search + title[index:] - results = append(results, &ui.SearchResult{ + results = append(results, &models.SearchResult{ Title: title, URL: fmt.Sprintf("https://www.%s.com", search), }) } } - return ui.SearchResults(ctx, results) + return pages.SearchResults(ctx, results) } diff --git a/pkg/handlers/task.go b/pkg/handlers/task.go index 6f4d8a8e..93139274 100644 --- a/pkg/handlers/task.go +++ b/pkg/handlers/task.go @@ -7,7 +7,8 @@ import ( "github.com/mikestefanello/backlite" "github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/routenames" - "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/pages" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" @@ -35,11 +36,11 @@ func (h *Task) Routes(g *echo.Group) { } func (h *Task) Page(ctx echo.Context) error { - return ui.AddTask(ctx, form.Get[ui.TaskForm](ctx)) + return pages.AddTask(ctx, form.Get[forms.Task](ctx)) } func (h *Task) Submit(ctx echo.Context) error { - var input ui.TaskForm + var input forms.Task err := form.Submit(ctx, &input) diff --git a/pkg/ui/components.go b/pkg/ui/components.go deleted file mode 100644 index 0ffba1c1..00000000 --- a/pkg/ui/components.go +++ /dev/null @@ -1,309 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/mikestefanello/pagoda/pkg/msg" - "github.com/mikestefanello/pagoda/pkg/routenames" - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -type tab struct { - title, body string -} - -func head(r *request) Node { - return Head( - Meta(Charset("utf-8")), - Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), - Link(Rel("icon"), Href(file("favicon.png"))), - TitleEl(Text(appName), If(r.Title != "", Text(" | "+r.Title))), - If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))), - If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))), - Link(Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"), Rel("stylesheet")), - Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")), - Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()), - ) -} - -func flashMessages(r *request) Node { - var g Group - for _, typ := range []msg.Type{ - msg.TypeSuccess, - msg.TypeInfo, - msg.TypeWarning, - msg.TypeDanger, - } { - for _, str := range msg.Get(r.Context, typ) { - g = append(g, notification(typ, str)) - } - } - - return g -} - -func notification(typ msg.Type, text string) Node { - var class string - - switch typ { - case msg.TypeSuccess: - class = "success" - case msg.TypeInfo: - class = "info" - case msg.TypeWarning: - class = "warning" - case msg.TypeDanger: - class = "danger" - } - - return Div( - Class("notification is-"+class), - Attr("x-data", "{show: true}"), - Attr("x-show", "show"), - Button( - Class("delete"), - Attr("@click", "show = false"), - ), - Text(text), - ) -} - -func message(class, header string, body Node) Node { - return Article( - Class("message "+class), - If(header != "", Div( - Class("message-header"), - P(Text(header)), - )), - Div( - Class("message-body"), - body, - ), - ) -} - -func sidebarMenu(r *request) Node { - return Aside( - Class("menu"), - hxBoost(), - P( - Class("menu-label"), - Text("General"), - ), - Ul( - Class("menu-list"), - menuLink(r, "Dashboard", routenames.Home), - menuLink(r, "About", routenames.About), - menuLink(r, "Contact", routenames.Contact), - menuLink(r, "Cache", routenames.Cache), - menuLink(r, "Task", routenames.Task), - menuLink(r, "Files", routenames.Files), - ), - P( - Class("menu-label"), - Text("Account"), - ), - Ul( - Class("menu-list"), - If(r.IsAuth, menuLink(r, "Logout", routenames.Logout)), - If(!r.IsAuth, menuLink(r, "Login", routenames.Login)), - If(!r.IsAuth, menuLink(r, "Register", routenames.Register)), - If(!r.IsAuth, menuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)), - ), - ) -} - -func menuLink(r *request, title, routeName string, routeParams ...string) Node { - href := r.path(routeName, routeParams...) - - return Li( - A( - Href(href), - Text(title), - If(href == r.Path, Class("is-active")), - ), - ) -} - -func authNavBar(r *request) Node { - return Nav( - Class("navbar"), - Div( - Class("navbar-menu"), - Div( - Class("navbar-start"), - A(Class("navbar-item"), Href(r.path(routenames.Login)), Text("Login")), - A(Class("navbar-item"), Href(r.path(routenames.Register)), Text("Create an account")), - A(Class("navbar-item"), Href(r.path(routenames.ForgotPassword)), Text("Forgot password")), - ), - ), - ) -} - -func navBar(r *request) Node { - return Nav( - Class("navbar is-dark"), - Div( - Class("container"), - Div( - Class("navbar-brand"), - hxBoost(), - A( - Href(r.path(routenames.Home)), - Class("navbar-item"), - Text("Pagoda"), - ), - ), - Div( - ID("navbarMenu"), - Class("navbar-menu"), - Div( - Class("navbar-end"), - search(r), - ), - ), - ), - ) -} - -func search(r *request) Node { - return Div( - Class("search mr-2 mt-1"), - Attr("x-data", "{modal:false}"), - Input( - Class("input"), - Type("search"), - Placeholder("Search..."), - Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"), - ), - Div( - Class("modal"), - Attr(":class", "modal ? 'is-active' : ''"), - Attr("x-show", "modal == true"), - Div( - Class("modal-background"), - ), - Div( - Class("modal-content"), - Attr("@click.outside", "modal = false;"), - Div( - Class("box"), - H2( - Class("subtitle"), - Text("Search"), - ), - P( - Class("control"), - Input( - Attr("hx-get", r.path(routenames.Search)), - Attr("hx-trigger", "keyup changed delay:500ms"), - Attr("hx-target", "#results"), - Name("query"), - Class("input"), - Type("search"), - Placeholder("Search..."), - Attr("x-ref", "input"), - ), - ), - Div( - Class("block"), - ), - Div( - ID("results"), - ), - ), - ), - Button( - Class("modal-close is-large"), - Aria("label", "close"), - ), - ), - ) -} - -func footer(r *request) Node { - return Group{ - If(len(r.CSRF) > 0, Script( - Raw(` - document.body.addEventListener('htmx:configRequest', function(evt) { - if (evt.detail.verb !== "get") { - evt.detail.parameters['csrf'] = '`+r.CSRF+`'; - } - }) - `), - )), - Script(Raw(` - document.body.addEventListener('htmx:beforeSwap', function(evt) { - if (evt.detail.xhr.status >= 400){ - evt.detail.shouldSwap = true; - evt.detail.target = htmx.find("body"); - } - }); - `)), - } -} - -func button(class, label string) Node { - return Button( - Class("button "+class), - Text(label), - ) -} - -func buttonLink(href, class, label string) Node { - return A( - Href(href), - Class("button "+class), - Text(label), - ) -} - -func hxBoost() Node { - return Attr("hx-boost", "true") -} - -func tabs(heading, description string, items []tab) Node { - renderTitles := func() Node { - g := make(Group, 0, len(items)) - for i, item := range items { - g = append(g, Li( - Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)), - Attr("@click", fmt.Sprintf("tab = %d", i)), - A(Text(item.title)), - )) - } - return g - } - - renderBodies := func() Node { - g := make(Group, 0, len(items)) - for i, item := range items { - g = append(g, Div( - Attr("x-show", fmt.Sprintf("tab == %d", i)), - P(Raw(" "+item.body)), - )) - } - return g - } - - return Div( - P( - Class("subtitle mt-5"), - Text(heading), - ), - P( - Class("mb-4"), - Text(description), - ), - Div( - Attr("x-data", "{tab: 0}"), - Div( - Class("tabs"), - Ul(renderTitles()), - ), - renderBodies(), - ), - ) -} diff --git a/pkg/ui/components/alerts.go b/pkg/ui/components/alerts.go new file mode 100644 index 00000000..3e2f8210 --- /dev/null +++ b/pkg/ui/components/alerts.go @@ -0,0 +1,64 @@ +package components + +import ( + "github.com/mikestefanello/pagoda/pkg/msg" + "github.com/mikestefanello/pagoda/pkg/ui" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func FlashMessages(r *ui.Request) Node { + var g Group + for _, typ := range []msg.Type{ + msg.TypeSuccess, + msg.TypeInfo, + msg.TypeWarning, + msg.TypeDanger, + } { + for _, str := range msg.Get(r.Context, typ) { + g = append(g, Notification(typ, str)) + } + } + + return g +} + +func Notification(typ msg.Type, text string) Node { + var class string + + switch typ { + case msg.TypeSuccess: + class = "success" + case msg.TypeInfo: + class = "info" + case msg.TypeWarning: + class = "warning" + case msg.TypeDanger: + class = "danger" + } + + return Div( + Class("notification is-"+class), + Attr("x-data", "{show: true}"), + Attr("x-show", "show"), + Button( + Class("delete"), + Attr("@click", "show = false"), + ), + Text(text), + ) +} + +func Message(class, header string, body Node) Node { + return Article( + Class("message "+class), + If(header != "", Div( + Class("message-header"), + P(Text(header)), + )), + Div( + Class("message-body"), + body, + ), + ) +} diff --git a/pkg/ui/components/form.go b/pkg/ui/components/form.go new file mode 100644 index 00000000..455b237c --- /dev/null +++ b/pkg/ui/components/form.go @@ -0,0 +1,203 @@ +package components + +import ( + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/ui" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type ( + InputFieldParams struct { + Form form.Form + FormField string + Name string + InputType string + Label string + Value string + Placeholder string + Help string + } + + RadiosParams struct { + Form form.Form + FormField string + Name string + Label string + Value string + Options []Radio + } + + Radio struct { + Value string + Label string + } + + TextareaFieldParams struct { + Form form.Form + FormField string + Name string + Label string + Value string + Help string + } +) + +func ControlGroup(controls ...Node) Node { + g := make(Group, 0, len(controls)) + for _, control := range controls { + g = append(g, Div( + Class("control"), + control, + )) + } + + return Div( + Class("field is-grouped"), + g, + ) +} + +func TextareaField(el TextareaFieldParams) Node { + return Div( + Class("field"), + Label( + For("name"), + Class("label"), + Text(el.Label), + ), + Div( + Class("control"), + Textarea( + ID(el.Name), + Name(el.Name), + Class("textarea "+formFieldStatusClass(el.Form, el.FormField)), + Text(el.Value), + ), + ), + If(el.Help != "", P(Class("help"), Text(el.Help))), + formFieldErrors(el.Form, el.FormField), + ) +} + +func Radios(el RadiosParams) Node { + buttons := make(Group, 0, len(el.Options)) + for _, opt := range el.Options { + buttons = append(buttons, Label( + Class("radio"), + Input( + Type("radio"), + Name(el.Name), + Value(opt.Value), + If(el.Value == opt.Value, Checked()), + ), + Text(" "+opt.Label), + )) + } + + return Div( + Class("control field"), + Label(Class("label"), Text(el.Label)), + Div( + Class("radios"), + buttons, + ), + formFieldErrors(el.Form, el.FormField), + ) +} + +func InputField(el InputFieldParams) Node { + return Div( + Class("field"), + Label( + Class("label"), + For(el.Name), + Text(el.Label), + ), + Div( + Class("control"), + Input( + ID(el.Name), + Name(el.Name), + Type(el.InputType), + If(el.Placeholder != "", Placeholder(el.Placeholder)), + Class("input "+formFieldStatusClass(el.Form, el.FormField)), + Value(el.Value), + ), + ), + If(el.Help != "", P(Class("help"), Text(el.Help))), + formFieldErrors(el.Form, el.FormField), + ) +} + +func FileField(name, label string) Node { + return Div( + Class("field file"), + Label( + Class("file-label"), + Input( + Class("file-input"), + Type("file"), + Name(name), + ), + Span( + Class("file-cta"), + Span( + Class("file-label"), + Text(label), + ), + ), + ), + ) +} + +func formFieldStatusClass(fm form.Form, formField string) string { + switch { + case !fm.IsSubmitted(): + return "" + case fm.FieldHasErrors(formField): + return "is-danger" + default: + return "is-success" + } +} + +func formFieldErrors(fm form.Form, field string) Node { + errs := fm.GetFieldErrors(field) + if len(errs) == 0 { + return nil + } + + g := make(Group, 0, len(errs)) + for _, err := range errs { + g = append(g, P( + Class("help is-danger"), + Text(err), + )) + } + + return g +} + +func CSRF(r *ui.Request) Node { + return Input( + Type("hidden"), + Name("csrf"), + Value(r.CSRF), + ) +} + +func FormButton(class, label string) Node { + return Button( + Class("button "+class), + Text(label), + ) +} + +func ButtonLink(href, class, label string) Node { + return A( + Href(href), + Class("button "+class), + Text(label), + ) +} diff --git a/pkg/ui/components/head.go b/pkg/ui/components/head.go new file mode 100644 index 00000000..d6ae941d --- /dev/null +++ b/pkg/ui/components/head.go @@ -0,0 +1,69 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/mikestefanello/pagoda/pkg/ui" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func JS(r *ui.Request) Node { + const htmxErr = ` + document.body.addEventListener('htmx:beforeSwap', function(evt) { + if (evt.detail.xhr.status >= 400){ + evt.detail.shouldSwap = true; + evt.detail.target = htmx.find("body"); + } + }); + ` + + const htmxCSFR = ` + document.body.addEventListener('htmx:configRequest', function(evt) { + if (evt.detail.verb !== "get") { + evt.detail.parameters['csrf'] = '%s'; + } + }) + ` + + var csrf Node + + if len(r.CSRF) > 0 { + csrf = Script(Raw(fmt.Sprintf(htmxCSFR, r.CSRF))) + } + + return Group{ + Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")), + Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()), + If(len(r.CSRF) > 0, Script( + Raw(` + document.body.addEventListener('htmx:configRequest', function(evt) { + if (evt.detail.verb !== "get") { + evt.detail.parameters['csrf'] = '`+r.CSRF+`'; + } + }) + `), + )), + Script(Raw(htmxErr)), + csrf, + } +} + +func CSS() Node { + return Link( + Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"), + Rel("stylesheet"), + ) +} + +func Metatags(r *ui.Request) Node { + return Group{ + Meta(Charset("utf-8")), + Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), + Link(Rel("icon"), Href(ui.File("favicon.png"))), + TitleEl(Text(ui.AppName), If(r.Title != "", Text(" | "+r.Title))), + If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))), + If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))), + } +} diff --git a/pkg/ui/components/htmx.go b/pkg/ui/components/htmx.go new file mode 100644 index 00000000..d62cb587 --- /dev/null +++ b/pkg/ui/components/htmx.go @@ -0,0 +1,9 @@ +package components + +import ( + . "maragu.dev/gomponents" +) + +func HxBoost() Node { + return Attr("hx-boost", "true") +} diff --git a/pkg/ui/components/nav.go b/pkg/ui/components/nav.go new file mode 100644 index 00000000..cf23ebfd --- /dev/null +++ b/pkg/ui/components/nav.go @@ -0,0 +1,19 @@ +package components + +import ( + "github.com/mikestefanello/pagoda/pkg/ui" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func MenuLink(r *ui.Request, title, routeName string, routeParams ...string) Node { + href := r.Path(routeName, routeParams...) + + return Li( + A( + Href(href), + Text(title), + If(href == r.CurrentPath, Class("is-active")), + ), + ) +} diff --git a/pkg/ui/components/tabs.go b/pkg/ui/components/tabs.go new file mode 100644 index 00000000..894f7401 --- /dev/null +++ b/pkg/ui/components/tabs.go @@ -0,0 +1,56 @@ +package components + +import ( + "fmt" + + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Tab struct { + Title, Body string +} + +func Tabs(heading, description string, items []Tab) Node { + renderTitles := func() Node { + g := make(Group, 0, len(items)) + for i, item := range items { + g = append(g, Li( + Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)), + Attr("@click", fmt.Sprintf("tab = %d", i)), + A(Text(item.Title)), + )) + } + return g + } + + renderBodies := func() Node { + g := make(Group, 0, len(items)) + for i, item := range items { + g = append(g, Div( + Attr("x-show", fmt.Sprintf("tab == %d", i)), + P(Raw(" "+item.Body)), + )) + } + return g + } + + return Div( + P( + Class("subtitle mt-5"), + Text(heading), + ), + P( + Class("mb-4"), + Text(description), + ), + Div( + Attr("x-data", "{tab: 0}"), + Div( + Class("tabs"), + Ul(renderTitles()), + ), + renderBodies(), + ), + ) +} diff --git a/pkg/ui/emails.go b/pkg/ui/emails/auth.go similarity index 78% rename from pkg/ui/emails.go rename to pkg/ui/emails/auth.go index 1cbeb17c..4d41cb82 100644 --- a/pkg/ui/emails.go +++ b/pkg/ui/emails/auth.go @@ -1,11 +1,11 @@ -package ui +package emails import ( . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) -func ConfirmEmailAddressEmail(username, url string) Node { +func ConfirmEmailAddress(username, url string) Node { return Group{ Strong(Textf("Hello %s,", username)), Br(), diff --git a/pkg/ui/form.go b/pkg/ui/form.go deleted file mode 100644 index 84b56e66..00000000 --- a/pkg/ui/form.go +++ /dev/null @@ -1,187 +0,0 @@ -package ui - -import ( - "github.com/mikestefanello/pagoda/pkg/form" - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -type ( - input struct { - form form.Form - formField string - name string - inputType string - label string - value string - placeholder string - help string - } - - radios struct { - form form.Form - formField string - name string - label string - value string - options []radio - } - - radio struct { - value string - label string - } - - textarea struct { - form form.Form - formField string - name string - label string - value string - help string - } -) - -func formControlGroup(controls ...Node) Node { - g := make(Group, 0, len(controls)) - for _, control := range controls { - g = append(g, Div( - Class("control"), - control, - )) - } - - return Div( - Class("field is-grouped"), - g, - ) -} - -func formTextarea(el textarea) Node { - return Div( - Class("field"), - Label( - For("name"), - Class("label"), - Text(el.label), - ), - Div( - Class("control"), - Textarea( - ID(el.name), - Name(el.name), - Class("textarea "+formFieldStatusClass(el.form, el.formField)), - Text(el.value), - ), - ), - If(el.help != "", P(Class("help"), Text(el.help))), - formFieldErrors(el.form, el.formField), - ) -} - -func formRadios(el radios) Node { - buttons := make(Group, 0, len(el.options)) - for _, opt := range el.options { - buttons = append(buttons, Label( - Class("radio"), - Input( - Type("radio"), - Name(el.name), - Value(opt.value), - If(el.value == opt.value, Checked()), - ), - Text(" "+opt.label), - )) - } - - return Div( - Class("control field"), - Label(Class("label"), Text(el.label)), - Div( - Class("radios"), - buttons, - ), - formFieldErrors(el.form, el.formField), - ) -} - -func formInput(el input) Node { - return Div( - Class("field"), - Label( - Class("label"), - For(el.name), - Text(el.label), - ), - Div( - Class("control"), - Input( - ID(el.name), - Name(el.name), - Type(el.inputType), - If(el.placeholder != "", Placeholder(el.placeholder)), - Class("input "+formFieldStatusClass(el.form, el.formField)), - Value(el.value), - ), - ), - If(el.help != "", P(Class("help"), Text(el.help))), - formFieldErrors(el.form, el.formField), - ) -} - -func formFile(name, label string) Node { - return Div( - Class("field file"), - Label( - Class("file-label"), - Input( - Class("file-input"), - Type("file"), - Name(name), - ), - Span( - Class("file-cta"), - Span( - Class("file-label"), - Text(label), - ), - ), - ), - ) -} - -func formFieldStatusClass(fm form.Form, formField string) string { - switch { - case !fm.IsSubmitted(): - return "" - case fm.FieldHasErrors(formField): - return "is-danger" - default: - return "is-success" - } -} - -func formFieldErrors(fm form.Form, field string) Node { - errs := fm.GetFieldErrors(field) - if len(errs) == 0 { - return nil - } - - g := make(Group, 0, len(errs)) - for _, err := range errs { - g = append(g, P( - Class("help is-danger"), - Text(err), - )) - } - - return g -} - -func csrf(r *request) Node { - return Input( - Type("hidden"), - Name("csrf"), - Value(r.CSRF), - ) -} diff --git a/pkg/ui/forms.go b/pkg/ui/forms.go deleted file mode 100644 index fa5dc59b..00000000 --- a/pkg/ui/forms.go +++ /dev/null @@ -1,289 +0,0 @@ -package ui - -import ( - "fmt" - "net/http" - - "github.com/mikestefanello/pagoda/pkg/form" - "github.com/mikestefanello/pagoda/pkg/routenames" - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -type ( - ContactForm struct { - Email string `form:"email" validate:"required,email"` - Department string `form:"department" validate:"required,oneof=sales marketing hr"` - Message string `form:"message" validate:"required"` - form.Submission - } - - LoginForm struct { - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - form.Submission - } - - RegisterForm struct { - Name string `form:"name" validate:"required"` - Email string `form:"email" validate:"required,email"` - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - form.Submission - } - - ForgotPasswordForm struct { - Email string `form:"email" validate:"required,email"` - form.Submission - } - - ResetPasswordForm struct { - Password string `form:"password" validate:"required"` - ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` - form.Submission - } - - TaskForm struct { - Delay int `form:"delay" validate:"gte=0"` - Message string `form:"message" validate:"required"` - form.Submission - } - - CacheForm struct { - CurrentValue string - Value string `form:"value"` - form.Submission - } -) - -func (f *ContactForm) render(r *request) Node { - return Form( - ID("contact"), - Method(http.MethodPost), - Attr("hx-post", r.path(routenames.ContactSubmit)), - formInput(input{ - form: f, - formField: "Email", - name: "email", - inputType: "email", - label: "Email address", - value: f.Email, - }), - formRadios(radios{ - form: f, - formField: "Department", - name: "department", - label: "Department", - value: f.Department, - options: []radio{ - {value: "sales", label: "Sales"}, - {value: "marketing", label: "Marketing"}, - {value: "hr", label: "HR"}, - }, - }), - formTextarea(textarea{ - form: f, - formField: "Message", - name: "message", - label: "Message", - value: f.Message, - }), - formControlGroup( - button("is-link", "Submit"), - ), - csrf(r), - ) -} - -func (f *LoginForm) render(r *request) Node { - return Form( - ID("login"), - Method(http.MethodPost), - hxBoost(), - Action(r.path(routenames.LoginSubmit)), - flashMessages(r), - formInput(input{ - form: f, - formField: "Email", - name: "email", - inputType: "email", - label: "Email address", - value: f.Email, - }), - formInput(input{ - form: f, - formField: "Password", - name: "password", - inputType: "password", - label: "Password", - placeholder: "******", - }), - formControlGroup( - button("is-link", "Login"), - buttonLink(r.path(routenames.Home), "is-light", "Cancel"), - ), - csrf(r), - ) -} - -func (f *RegisterForm) render(r *request) Node { - return Form( - ID("register"), - Method(http.MethodPost), - hxBoost(), - Action(r.path(routenames.RegisterSubmit)), - formInput(input{ - form: f, - formField: "Name", - name: "name", - inputType: "text", - label: "Name", - value: f.Name, - }), - formInput(input{ - form: f, - formField: "Email", - name: "email", - inputType: "email", - label: "Email address", - value: f.Email, - }), - formInput(input{ - form: f, - formField: "Password", - name: "password", - inputType: "password", - label: "Password", - placeholder: "******", - }), - formInput(input{ - form: f, - formField: "PasswordConfirm", - name: "password-confirm", - inputType: "password", - label: "Confirm password", - placeholder: "******", - }), - formControlGroup( - button("is-primary", "Register"), - buttonLink(r.path(routenames.Home), "is-light", "Cancel"), - ), - csrf(r), - ) -} - -func (f *ForgotPasswordForm) render(r *request) Node { - return Form( - ID("forgot-password"), - Method(http.MethodPost), - hxBoost(), - Action(r.path(routenames.ForgotPasswordSubmit)), - formInput(input{ - form: f, - formField: "Email", - name: "email", - inputType: "email", - label: "Email address", - value: f.Email, - }), - formControlGroup( - button("is-primary", "Reset password"), - buttonLink(r.path(routenames.Home), "is-light", "Cancel"), - ), - csrf(r), - ) -} - -func (f *ResetPasswordForm) render(r *request) Node { - return Form( - ID("reset-password"), - Method(http.MethodPost), - hxBoost(), - Action(r.Path), - formInput(input{ - form: f, - formField: "Password", - name: "password", - inputType: "password", - label: "Password", - placeholder: "******", - }), - formInput(input{ - form: f, - formField: "PasswordConfirm", - name: "password-confirm", - inputType: "password", - label: "Confirm password", - placeholder: "******", - }), - formControlGroup( - button("is-primary", "Update password"), - ), - csrf(r), - ) -} - -func (f *TaskForm) render(r *request) Node { - return Form( - ID("task"), - Method(http.MethodPost), - Attr("hx-post", r.path(routenames.TaskSubmit)), - flashMessages(r), - formInput(input{ - form: f, - formField: "Delay", - name: "delay", - inputType: "number", - label: "Delay (in seconds)", - help: "How long to wait until the task is executed", - value: fmt.Sprint(f.Delay), - }), - formTextarea(textarea{ - form: f, - formField: "Message", - name: "message", - label: "Message", - value: f.Message, - help: "The message the task will output to the log", - }), - formControlGroup( - button("is-link", "Add task to queue"), - ), - csrf(r), - ) -} - -func (f *CacheForm) render(r *request) Node { - return Form( - ID("cache"), - Method(http.MethodPost), - Attr("hx-post", r.path(routenames.CacheSubmit)), - message( - "is-info", - "Test the cache", - Group{ - P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")), - P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")), - }, - ), - Label( - For("value"), - Class("value"), - Text("Value in cache: "), - ), - If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))), - If(f.CurrentValue == "", I(Text("(empty)"))), - formInput(input{ - form: f, - formField: "Value", - name: "value", - inputType: "text", - label: "Value", - value: f.Value, - }), - formControlGroup( - button("is-link", "Update cache"), - ), - csrf(r), - ) -} diff --git a/pkg/ui/forms/cache.go b/pkg/ui/forms/cache.go new file mode 100644 index 00000000..f8432139 --- /dev/null +++ b/pkg/ui/forms/cache.go @@ -0,0 +1,53 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Cache struct { + CurrentValue string + Value string `form:"value"` + form.Submission +} + +func (f *Cache) Render(r *ui.Request) Node { + return Form( + ID("cache"), + Method(http.MethodPost), + Attr("hx-post", r.Path(routenames.CacheSubmit)), + Message( + "is-info", + "Test the cache", + Group{ + P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")), + P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")), + }, + ), + Label( + For("value"), + Class("value"), + Text("Value in cache: "), + ), + If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))), + If(f.CurrentValue == "", I(Text("(empty)"))), + InputField(InputFieldParams{ + Form: f, + FormField: "Value", + Name: "value", + InputType: "text", + Label: "Value", + Value: f.Value, + }), + ControlGroup( + FormButton("is-link", "Update cache"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/contact.go b/pkg/ui/forms/contact.go new file mode 100644 index 00000000..3d8bd3cf --- /dev/null +++ b/pkg/ui/forms/contact.go @@ -0,0 +1,58 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Contact struct { + Email string `form:"email" validate:"required,email"` + Department string `form:"department" validate:"required,oneof=sales marketing hr"` + Message string `form:"message" validate:"required"` + form.Submission +} + +func (f *Contact) Render(r *ui.Request) Node { + return Form( + ID("contact"), + Method(http.MethodPost), + Attr("hx-post", r.Path(routenames.ContactSubmit)), + InputField(InputFieldParams{ + Form: f, + FormField: "Email", + Name: "email", + InputType: "email", + Label: "Email address", + Value: f.Email, + }), + Radios(RadiosParams{ + Form: f, + FormField: "Department", + Name: "department", + Label: "Department", + Value: f.Department, + Options: []Radio{ + {Value: "sales", Label: "Sales"}, + {Value: "marketing", Label: "Marketing"}, + {Value: "hr", Label: "HR"}, + }, + }), + TextareaField(TextareaFieldParams{ + Form: f, + FormField: "Message", + Name: "message", + Label: "Message", + Value: f.Message, + }), + ControlGroup( + FormButton("is-link", "Submit"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/file.go b/pkg/ui/forms/file.go new file mode 100644 index 00000000..ad447b75 --- /dev/null +++ b/pkg/ui/forms/file.go @@ -0,0 +1,27 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type File struct{} + +func (f File) Render(r *ui.Request) Node { + return Form( + ID("files"), + Method(http.MethodPost), + Action(r.Path(routenames.FilesSubmit)), + EncType("multipart/form-data"), + FileField("file", "Choose a file.. "), + ControlGroup( + FormButton("is-link", "Upload"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/forgot_password.go b/pkg/ui/forms/forgot_password.go new file mode 100644 index 00000000..511e36b1 --- /dev/null +++ b/pkg/ui/forms/forgot_password.go @@ -0,0 +1,39 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type ForgotPassword struct { + Email string `form:"email" validate:"required,email"` + form.Submission +} + +func (f *ForgotPassword) Render(r *ui.Request) Node { + return Form( + ID("forgot-password"), + Method(http.MethodPost), + HxBoost(), + Action(r.Path(routenames.ForgotPasswordSubmit)), + InputField(InputFieldParams{ + Form: f, + FormField: "Email", + Name: "email", + InputType: "email", + Label: "Email address", + Value: f.Email, + }), + ControlGroup( + FormButton("is-primary", "Reset password"), + ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/login.go b/pkg/ui/forms/login.go new file mode 100644 index 00000000..b3024f75 --- /dev/null +++ b/pkg/ui/forms/login.go @@ -0,0 +1,49 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Login struct { + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + form.Submission +} + +func (f *Login) Render(r *ui.Request) Node { + return Form( + ID("login"), + Method(http.MethodPost), + HxBoost(), + Action(r.Path(routenames.LoginSubmit)), + FlashMessages(r), + InputField(InputFieldParams{ + Form: f, + FormField: "Email", + Name: "email", + InputType: "email", + Label: "Email address", + Value: f.Email, + }), + InputField(InputFieldParams{ + Form: f, + FormField: "Password", + Name: "password", + InputType: "password", + Label: "Password", + Placeholder: "******", + }), + ControlGroup( + FormButton("is-link", "Login"), + ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/register.go b/pkg/ui/forms/register.go new file mode 100644 index 00000000..2d583258 --- /dev/null +++ b/pkg/ui/forms/register.go @@ -0,0 +1,66 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Register struct { + Name string `form:"name" validate:"required"` + Email string `form:"email" validate:"required,email"` + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + form.Submission +} + +func (f *Register) Render(r *ui.Request) Node { + return Form( + ID("register"), + Method(http.MethodPost), + HxBoost(), + Action(r.Path(routenames.RegisterSubmit)), + InputField(InputFieldParams{ + Form: f, + FormField: "Name", + Name: "name", + InputType: "text", + Label: "Name", + Value: f.Name, + }), + InputField(InputFieldParams{ + Form: f, + FormField: "Email", + Name: "email", + InputType: "email", + Label: "Email address", + Value: f.Email, + }), + InputField(InputFieldParams{ + Form: f, + FormField: "Password", + Name: "password", + InputType: "password", + Label: "Password", + Placeholder: "******", + }), + InputField(InputFieldParams{ + Form: f, + FormField: "PasswordConfirm", + Name: "password-confirm", + InputType: "password", + Label: "Confirm password", + Placeholder: "******", + }), + ControlGroup( + FormButton("is-primary", "Register"), + ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/reset_password.go b/pkg/ui/forms/reset_password.go new file mode 100644 index 00000000..03abcad9 --- /dev/null +++ b/pkg/ui/forms/reset_password.go @@ -0,0 +1,46 @@ +package forms + +import ( + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type ResetPassword struct { + Password string `form:"password" validate:"required"` + ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"` + form.Submission +} + +func (f *ResetPassword) Render(r *ui.Request) Node { + return Form( + ID("reset-password"), + Method(http.MethodPost), + HxBoost(), + Action(r.CurrentPath), + InputField(InputFieldParams{ + Form: f, + FormField: "Password", + Name: "password", + InputType: "password", + Label: "Password", + Placeholder: "******", + }), + InputField(InputFieldParams{ + Form: f, + FormField: "PasswordConfirm", + Name: "password-confirm", + InputType: "password", + Label: "Confirm password", + Placeholder: "******", + }), + ControlGroup( + FormButton("is-primary", "Update password"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/forms/task.go b/pkg/ui/forms/task.go new file mode 100644 index 00000000..2c4c2b76 --- /dev/null +++ b/pkg/ui/forms/task.go @@ -0,0 +1,49 @@ +package forms + +import ( + "fmt" + "net/http" + + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type Task struct { + Delay int `form:"delay" validate:"gte=0"` + Message string `form:"message" validate:"required"` + form.Submission +} + +func (f *Task) Render(r *ui.Request) Node { + return Form( + ID("task"), + Method(http.MethodPost), + Attr("hx-post", r.Path(routenames.TaskSubmit)), + FlashMessages(r), + InputField(InputFieldParams{ + Form: f, + FormField: "Delay", + Name: "delay", + InputType: "number", + Label: "Delay (in seconds)", + Help: "How long to wait until the task is executed", + Value: fmt.Sprint(f.Delay), + }), + TextareaField(TextareaFieldParams{ + Form: f, + FormField: "Message", + Name: "message", + Label: "Message", + Value: f.Message, + Help: "The message the task will output to the log", + }), + ControlGroup( + FormButton("is-link", "Add task to queue"), + ), + CSRF(r), + ) +} diff --git a/pkg/ui/layouts.go b/pkg/ui/layouts.go deleted file mode 100644 index d2bb86a0..00000000 --- a/pkg/ui/layouts.go +++ /dev/null @@ -1,69 +0,0 @@ -package ui - -import ( - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -func layoutPrimary(r *request, content Node) Node { - return Doctype( - HTML( - Lang("en"), - head(r), - Body( - navBar(r), - Div( - Class("container mt-5"), - Div( - Class("columns"), - Div( - Class("column is-2"), - sidebarMenu(r), - ), - Div( - Class("column is-10"), - If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), - flashMessages(r), - content, - ), - ), - ), - footer(r), - ), - ), - ) -} - -func layoutAuth(r *request, content Node) Node { - return Doctype( - HTML( - Lang("en"), - head(r), - Body( - Section( - Class("hero is-fullheight"), - Div( - Class("hero-body"), - Div( - Class("container"), - Div( - Class("columns is-centered"), - Div( - Class("column is-half"), - If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), - Div( - Class("notification"), - flashMessages(r), - content, - authNavBar(r), - ), - ), - ), - ), - ), - ), - footer(r), - ), - ), - ) -} diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go new file mode 100644 index 00000000..2de55f71 --- /dev/null +++ b/pkg/ui/layouts/auth.go @@ -0,0 +1,61 @@ +package layouts + +import ( + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Auth(r *ui.Request, content Node) Node { + return Doctype( + HTML( + Lang("en"), + Head( + Metatags(r), + CSS(), + JS(r), + ), + Body( + Section( + Class("hero is-fullheight"), + Div( + Class("hero-body"), + Div( + Class("container"), + Div( + Class("columns is-centered"), + Div( + Class("column is-half"), + If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), + Div( + Class("notification"), + FlashMessages(r), + content, + authNavBar(r), + ), + ), + ), + ), + ), + ), + ), + ), + ) +} + +func authNavBar(r *ui.Request) Node { + return Nav( + Class("navbar"), + Div( + Class("navbar-menu"), + Div( + Class("navbar-start"), + A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")), + A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")), + A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")), + ), + ), + ) +} diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go new file mode 100644 index 00000000..0a81b572 --- /dev/null +++ b/pkg/ui/layouts/primary.go @@ -0,0 +1,153 @@ +package layouts + +import ( + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Primary(r *ui.Request, content Node) Node { + return Doctype( + HTML( + Lang("en"), + Head( + Metatags(r), + CSS(), + JS(r), + ), + Body( + headerNavBar(r), + Div( + Class("container mt-5"), + Div( + Class("columns"), + Div( + Class("column is-2"), + sidebarMenu(r), + ), + Div( + Class("column is-10"), + If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))), + FlashMessages(r), + content, + ), + ), + ), + ), + ), + ) +} + +func headerNavBar(r *ui.Request) Node { + return Nav( + Class("navbar is-dark"), + Div( + Class("container"), + Div( + Class("navbar-brand"), + HxBoost(), + A( + Href(r.Path(routenames.Home)), + Class("navbar-item"), + Text("Pagoda"), + ), + ), + Div( + ID("navbarMenu"), + Class("navbar-menu"), + Div( + Class("navbar-end"), + search(r), + ), + ), + ), + ) +} + +func search(r *ui.Request) Node { + return Div( + Class("search mr-2 mt-1"), + Attr("x-data", "{modal:false}"), + Input( + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"), + ), + Div( + Class("modal"), + Attr(":class", "modal ? 'is-active' : ''"), + Attr("x-show", "modal == true"), + Div( + Class("modal-background"), + ), + Div( + Class("modal-content"), + Attr("@click.outside", "modal = false;"), + Div( + Class("box"), + H2( + Class("subtitle"), + Text("Search"), + ), + P( + Class("control"), + Input( + Attr("hx-get", r.Path(routenames.Search)), + Attr("hx-trigger", "keyup changed delay:500ms"), + Attr("hx-target", "#results"), + Name("query"), + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("x-ref", "input"), + ), + ), + Div( + Class("block"), + ), + Div( + ID("results"), + ), + ), + ), + Button( + Class("modal-close is-large"), + Aria("label", "close"), + ), + ), + ) +} + +func sidebarMenu(r *ui.Request) Node { + return Aside( + Class("menu"), + HxBoost(), + P( + Class("menu-label"), + Text("General"), + ), + Ul( + Class("menu-list"), + MenuLink(r, "Dashboard", routenames.Home), + MenuLink(r, "About", routenames.About), + MenuLink(r, "Contact", routenames.Contact), + MenuLink(r, "Cache", routenames.Cache), + MenuLink(r, "Task", routenames.Task), + MenuLink(r, "Files", routenames.Files), + ), + P( + Class("menu-label"), + Text("Account"), + ), + Ul( + Class("menu-list"), + If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)), + If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)), + If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)), + If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)), + ), + ) +} diff --git a/pkg/ui/models/file.go b/pkg/ui/models/file.go new file mode 100644 index 00000000..257efcdb --- /dev/null +++ b/pkg/ui/models/file.go @@ -0,0 +1,22 @@ +package models + +import ( + "fmt" + + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type File struct { + Name string + Size int64 + Modified string +} + +func (f *File) Render() Node { + return Tr( + Td(Text(f.Name)), + Td(Text(fmt.Sprint(f.Size))), + Td(Text(f.Modified)), + ) +} diff --git a/pkg/ui/models.go b/pkg/ui/models/post.go similarity index 71% rename from pkg/ui/models.go rename to pkg/ui/models/post.go index e388722c..a07f0ae8 100644 --- a/pkg/ui/models.go +++ b/pkg/ui/models/post.go @@ -1,9 +1,10 @@ -package ui +package models import ( "fmt" "github.com/mikestefanello/pagoda/pkg/pager" + "github.com/mikestefanello/pagoda/pkg/ui" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) @@ -17,23 +18,12 @@ type ( Post struct { Title, Body string } - - SearchResult struct { - Title string - URL string - } - - File struct { - Name string - Size int64 - Modified string - } ) -func (p *Posts) render(path string) Node { +func (p *Posts) Render(path string) Node { g := make(Group, 0, len(p.Posts)) for _, post := range p.Posts { - g = append(g, post.render()) + g = append(g, post.Render()) } return Div( @@ -65,7 +55,7 @@ func (p *Posts) render(path string) Node { ) } -func (p *Post) render() Node { +func (p *Post) Render() Node { return Article( Class("media"), Figure( @@ -73,7 +63,7 @@ func (p *Post) render() Node { P( Class("image is-64x64"), Img( - Src(file("gopher.png")), + Src(ui.File("gopher.png")), Alt("Gopher"), ), ), @@ -93,19 +83,3 @@ func (p *Post) render() Node { ), ) } - -func (s *SearchResult) render() Node { - return A( - Class("panel-block"), - Href(s.URL), - Text(s.Title), - ) -} - -func (f *File) render() Node { - return Tr( - Td(Text(f.Name)), - Td(Text(fmt.Sprint(f.Size))), - Td(Text(f.Modified)), - ) -} diff --git a/pkg/ui/models/search_result.go b/pkg/ui/models/search_result.go new file mode 100644 index 00000000..f1a07af4 --- /dev/null +++ b/pkg/ui/models/search_result.go @@ -0,0 +1,19 @@ +package models + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +type SearchResult struct { + Title string + URL string +} + +func (s *SearchResult) Render() Node { + return A( + Class("panel-block"), + Href(s.URL), + Text(s.Title), + ) +} diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go deleted file mode 100644 index 9080b41d..00000000 --- a/pkg/ui/pages.go +++ /dev/null @@ -1,291 +0,0 @@ -package ui - -import ( - "fmt" - "net/http" - - "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/routenames" - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -func Home(ctx echo.Context, posts Posts) error { - r := newRequest(ctx) - r.Metatags.Description = "This is the home page." - r.Metatags.Keywords = []string{"Software", "Coding", "Go"} - - g := make(Group, 0) - - if r.Htmx.Target != "posts" { - var hello string - if r.IsAuth { - hello = fmt.Sprintf("Hello, %s", r.AuthUser.Name) - } else { - hello = "Hello" - } - - g = append(g, - Section( - Class("hero is-info welcome is-small mb-5"), - Div( - Class("hero-body"), - Div( - Class("container"), - H1( - Class("title"), - Text(hello), - ), - H2( - Class("subtitle"), - If(!r.IsAuth, Text("Please login in to your account.")), - If(r.IsAuth, Text("Welcome back!")), - ), - ), - ), - ), - H2(Class("title"), Text("Recent posts")), - H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")), - ) - } - - g = append(g, posts.render(r.path(routenames.Home))) - - if r.Htmx.Target != "posts" { - g = append(g, message( - "is-small is-warning mt-5", - "Serving files", - Group{ - Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted."), - Text("Static files also contain cache-control headers which are configured via middleware."), - }, - )) - } - - return r.render(layoutPrimary, g) -} - -func ContactUs(ctx echo.Context, form *ContactForm) error { - r := newRequest(ctx) - r.Title = "Contact us" - r.Metatags.Description = "Get in touch with us." - - g := make(Group, 0) - - if r.Htmx.Target != "contact" { - g = append(g, message( - "is-link", - "", - Group{ - P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")), - P(Text("Only the form below will update async upon submission.")), - })) - } - - if form.IsDone() { - g = append(g, message( - "is-large is-success", - "Thank you!", - Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."), - )) - } else { - g = append(g, form.render(r)) - } - - return r.render(layoutPrimary, g) -} - -func Login(ctx echo.Context, form *LoginForm) error { - r := newRequest(ctx) - r.Title = "Login" - - return r.render(layoutAuth, form.render(r)) -} - -func Register(ctx echo.Context, form *RegisterForm) error { - r := newRequest(ctx) - r.Title = "Register" - - return r.render(layoutAuth, form.render(r)) -} - -func ForgotPassword(ctx echo.Context, form *ForgotPasswordForm) error { - r := newRequest(ctx) - r.Title = "Forgot password" - - g := Group{ - Div( - Class("content"), - P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")), - ), - form.render(r), - } - - return r.render(layoutAuth, g) -} - -func ResetPassword(ctx echo.Context, form *ResetPasswordForm) error { - r := newRequest(ctx) - r.Title = "Reset your password" - - return r.render(layoutAuth, form.render(r)) -} - -func SearchResults(ctx echo.Context, results []*SearchResult) error { - r := newRequest(ctx) - - g := make(Group, 0, len(results)) - for _, result := range results { - g = append(g, result.render()) - } - - return r.render(layoutPrimary, g) -} - -func AddTask(ctx echo.Context, form *TaskForm) error { - r := newRequest(ctx) - r.Title = "Create a task" - r.Metatags.Description = "Test creating a task to see how it works." - - g := make(Group, 0) - - if r.Htmx.Target != "task" { - g = append(g, message( - "is-link", - "", - Group{ - P(Raw("Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.")), - P(Text("See pkg/tasks and the README for more information.")), - })) - } - - g = append(g, form.render(r)) - - return r.render(layoutPrimary, g) -} - -func UpdateCache(ctx echo.Context, form *CacheForm) error { - r := newRequest(ctx) - r.Title = "Set a cache entry" - - return r.render(layoutPrimary, form.render(r)) -} - -func Error(ctx echo.Context, code int) error { - r := newRequest(ctx) - r.Title = http.StatusText(code) - var body Node - - switch code { - case http.StatusInternalServerError: - body = Text("Please try again.") - case http.StatusForbidden, http.StatusUnauthorized: - body = Text("You are not authorized to view the requested page.") - case http.StatusNotFound: - body = Group{ - Text("Click "), - A( - Href(r.path(routenames.Home)), - Text("here"), - ), - Text(" to go return home."), - } - default: - body = Text("Something went wrong.") - } - - return r.render(layoutPrimary, P(body)) -} - -func UploadFile(ctx echo.Context, files []*File) error { - r := newRequest(ctx) - r.Title = "Upload a file" - - fileList := make(Group, 0, len(files)) - for _, file := range files { - fileList = append(fileList, file.render()) - } - - n := Group{ - message( - "is-link", - "", - P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")), - ), - Hr(), - Form( - ID("files"), - Method(http.MethodPost), - Action(r.path(routenames.FilesSubmit)), - EncType("multipart/form-data"), - formFile("file", "Choose a file.. "), - formControlGroup( - button("is-link", "Upload"), - ), - csrf(r), - ), - Hr(), - H3( - Class("title"), - Text("Uploaded files"), - ), - message("is-warning", "", P(Text("Below are all files in the configured upload directory."))), - Table( - Class("table"), - THead( - Tr( - Th(Text("Filename")), - Th(Text("Size")), - Th(Text("Modified on")), - ), - ), - TBody( - fileList, - ), - ), - } - - return r.render(layoutPrimary, n) -} - -func About(ctx echo.Context) error { - r := newRequest(ctx) - r.Title = "About" - r.Metatags.Description = "Learn a little about what's included in Pagoda." - - return r.render(layoutPrimary, Group{ - tabs( - "Frontend", - "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", - []tab{ - { - title: "HTMX", - body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.", - }, - { - title: "Alpine.js", - body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.", - }, - { - title: "Bulma", - body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.", - }, - }, - ), - Div(Class("mb-4")), - tabs( - "Backend", - "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.", - []tab{ - { - title: "Echo", - body: "High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.", - }, - { - title: "Ent", - body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.", - }, - }, - ), - }) -} diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go new file mode 100644 index 00000000..b0651788 --- /dev/null +++ b/pkg/ui/pages/about.go @@ -0,0 +1,52 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func About(ctx echo.Context) error { + r := ui.NewRequest(ctx) + r.Title = "About" + r.Metatags.Description = "Learn a little about what's included in Pagoda." + + return r.Render(layouts.Primary, Group{ + components.Tabs( + "Frontend", + "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", + []components.Tab{ + { + Title: "HTMX", + Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.", + }, + { + Title: "Alpine.js", + Body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.", + }, + { + Title: "Bulma", + Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.", + }, + }, + ), + Div(Class("mb-4")), + components.Tabs( + "Backend", + "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.", + []components.Tab{ + { + Title: "Echo", + Body: "High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.", + }, + { + Title: "Ent", + Body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.", + }, + }, + ), + }) +} diff --git a/pkg/ui/pages/auth.go b/pkg/ui/pages/auth.go new file mode 100644 index 00000000..aa277893 --- /dev/null +++ b/pkg/ui/pages/auth.go @@ -0,0 +1,46 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Login(ctx echo.Context, form *forms.Login) error { + r := ui.NewRequest(ctx) + r.Title = "Login" + + return r.Render(layouts.Auth, form.Render(r)) +} + +func Register(ctx echo.Context, form *forms.Register) error { + r := ui.NewRequest(ctx) + r.Title = "Register" + + return r.Render(layouts.Auth, form.Render(r)) +} + +func ForgotPassword(ctx echo.Context, form *forms.ForgotPassword) error { + r := ui.NewRequest(ctx) + r.Title = "Forgot password" + + g := Group{ + Div( + Class("content"), + P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")), + ), + form.Render(r), + } + + return r.Render(layouts.Auth, g) +} + +func ResetPassword(ctx echo.Context, form *forms.ResetPassword) error { + r := ui.NewRequest(ctx) + r.Title = "Reset your password" + + return r.Render(layouts.Auth, form.Render(r)) +} diff --git a/pkg/ui/pages/cache.go b/pkg/ui/pages/cache.go new file mode 100644 index 00000000..ed572961 --- /dev/null +++ b/pkg/ui/pages/cache.go @@ -0,0 +1,15 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" +) + +func UpdateCache(ctx echo.Context, form *forms.Cache) error { + r := ui.NewRequest(ctx) + r.Title = "Set a cache entry" + + return r.Render(layouts.Primary, form.Render(r)) +} diff --git a/pkg/ui/pages/contact.go b/pkg/ui/pages/contact.go new file mode 100644 index 00000000..cef7f326 --- /dev/null +++ b/pkg/ui/pages/contact.go @@ -0,0 +1,41 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func ContactUs(ctx echo.Context, form *forms.Contact) error { + r := ui.NewRequest(ctx) + r.Title = "Contact us" + r.Metatags.Description = "Get in touch with us." + + g := make(Group, 0) + + if r.Htmx.Target != "contact" { + g = append(g, components.Message( + "is-link", + "", + Group{ + P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")), + P(Text("Only the form below will update async upon submission.")), + })) + } + + if form.IsDone() { + g = append(g, components.Message( + "is-large is-success", + "Thank you!", + Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."), + )) + } else { + g = append(g, form.Render(r)) + } + + return r.Render(layouts.Primary, g) +} diff --git a/pkg/ui/pages/error.go b/pkg/ui/pages/error.go new file mode 100644 index 00000000..21d42cf9 --- /dev/null +++ b/pkg/ui/pages/error.go @@ -0,0 +1,38 @@ +package pages + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Error(ctx echo.Context, code int) error { + r := ui.NewRequest(ctx) + r.Title = http.StatusText(code) + var body Node + + switch code { + case http.StatusInternalServerError: + body = Text("Please try again.") + case http.StatusForbidden, http.StatusUnauthorized: + body = Text("You are not authorized to view the requested page.") + case http.StatusNotFound: + body = Group{ + Text("Click "), + A( + Href(r.Path(routenames.Home)), + Text("here"), + ), + Text(" to go return home."), + } + default: + body = Text("Something went wrong.") + } + + return r.Render(layouts.Primary, P(body)) +} diff --git a/pkg/ui/pages/file.go b/pkg/ui/pages/file.go new file mode 100644 index 00000000..020f1d20 --- /dev/null +++ b/pkg/ui/pages/file.go @@ -0,0 +1,53 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + "github.com/mikestefanello/pagoda/pkg/ui/models" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func UploadFile(ctx echo.Context, files []*models.File) error { + r := ui.NewRequest(ctx) + r.Title = "Upload a file" + + fileList := make(Group, 0, len(files)) + for _, file := range files { + fileList = append(fileList, file.Render()) + } + + n := Group{ + components.Message( + "is-link", + "", + P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")), + ), + Hr(), + forms.File{}.Render(r), + Hr(), + H3( + Class("title"), + Text("Uploaded files"), + ), + components.Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))), + Table( + Class("table"), + THead( + Tr( + Th(Text("Filename")), + Th(Text("Size")), + Th(Text("Modified on")), + ), + ), + TBody( + fileList, + ), + ), + } + + return r.Render(layouts.Primary, n) +} diff --git a/pkg/ui/pages/home.go b/pkg/ui/pages/home.go new file mode 100644 index 00000000..9077210f --- /dev/null +++ b/pkg/ui/pages/home.go @@ -0,0 +1,69 @@ +package pages + +import ( + "fmt" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" + . "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + "github.com/mikestefanello/pagoda/pkg/ui/models" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func Home(ctx echo.Context, posts *models.Posts) error { + r := ui.NewRequest(ctx) + r.Metatags.Description = "This is the home page." + r.Metatags.Keywords = []string{"Software", "Coding", "Go"} + + g := make(Group, 0) + + if r.Htmx.Target != "posts" { + var hello string + if r.IsAuth { + hello = fmt.Sprintf("Hello, %s", r.AuthUser.Name) + } else { + hello = "Hello" + } + + g = append(g, + Section( + Class("hero is-info welcome is-small mb-5"), + Div( + Class("hero-body"), + Div( + Class("container"), + H1( + Class("title"), + Text(hello), + ), + H2( + Class("subtitle"), + If(!r.IsAuth, Text("Please login in to your account.")), + If(r.IsAuth, Text("Welcome back!")), + ), + ), + ), + ), + H2(Class("title"), Text("Recent posts")), + H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")), + ) + } + + g = append(g, posts.Render(r.Path(routenames.Home))) + + if r.Htmx.Target != "posts" { + g = append(g, Message( + "is-small is-warning mt-5", + "Serving files", + Group{ + Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted."), + Text("Static files also contain cache-control headers which are configured via middleware."), + }, + )) + } + + return r.Render(layouts.Primary, g) +} diff --git a/pkg/ui/pages/search.go b/pkg/ui/pages/search.go new file mode 100644 index 00000000..a330aa46 --- /dev/null +++ b/pkg/ui/pages/search.go @@ -0,0 +1,20 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + "github.com/mikestefanello/pagoda/pkg/ui/models" + . "maragu.dev/gomponents" +) + +func SearchResults(ctx echo.Context, results []*models.SearchResult) error { + r := ui.NewRequest(ctx) + + g := make(Group, 0, len(results)) + for _, result := range results { + g = append(g, result.Render()) + } + + return r.Render(layouts.Primary, g) +} diff --git a/pkg/ui/pages/task.go b/pkg/ui/pages/task.go new file mode 100644 index 00000000..aed83ce3 --- /dev/null +++ b/pkg/ui/pages/task.go @@ -0,0 +1,33 @@ +package pages + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/forms" + "github.com/mikestefanello/pagoda/pkg/ui/layouts" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func AddTask(ctx echo.Context, form *forms.Task) error { + r := ui.NewRequest(ctx) + r.Title = "Create a task" + r.Metatags.Description = "Test creating a task to see how it works." + + g := make(Group, 0) + + if r.Htmx.Target != "task" { + g = append(g, components.Message( + "is-link", + "", + Group{ + P(Raw("Submitting this form will create an ExampleTask in the task queue. After the specified delay, the message will be logged by the queue processor.")), + P(Text("See pkg/tasks and the README for more information.")), + })) + } + + g = append(g, form.Render(r)) + + return r.Render(layouts.Primary, g) +} diff --git a/pkg/ui/request.go b/pkg/ui/request.go index 91ca7bdd..7ec5b4ff 100644 --- a/pkg/ui/request.go +++ b/pkg/ui/request.go @@ -9,9 +9,9 @@ import ( "maragu.dev/gomponents" ) -type layoutFunc func(*request, gomponents.Node) gomponents.Node +type LayoutFunc func(*Request, gomponents.Node) gomponents.Node -type request struct { +type Request struct { // AppName stores the name of the application. // If omitted, the configuration value will be used. AppName string @@ -22,8 +22,8 @@ type request struct { // Context stores the request context Context echo.Context - // Path stores the path of the current request - Path string + // CurrentPath stores the path of the current request + CurrentPath string // IsHome stores whether the requested page is the home page or not IsHome bool @@ -51,14 +51,14 @@ type request struct { Htmx *htmx.Request } -func newRequest(ctx echo.Context) *request { - p := &request{ - Context: ctx, - Path: ctx.Request().URL.Path, - Htmx: htmx.GetRequest(ctx), +func NewRequest(ctx echo.Context) *Request { + p := &Request{ + Context: ctx, + CurrentPath: ctx.Request().URL.Path, + Htmx: htmx.GetRequest(ctx), } - p.IsHome = p.Path == "/" + p.IsHome = p.CurrentPath == "/" if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil { p.CSRF = csrf.(string) @@ -72,11 +72,11 @@ func newRequest(ctx echo.Context) *request { return p } -func (r *request) path(routeName string, routeParams ...string) string { +func (r *Request) Path(routeName string, routeParams ...string) string { return r.Context.Echo().Reverse(routeName, routeParams) } -func (r *request) render(layout layoutFunc, node gomponents.Node) error { +func (r *Request) Render(layout LayoutFunc, node gomponents.Node) error { if r.Htmx.Enabled && !r.Htmx.Boosted { return node.Render(r.Context.Response().Writer) } diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go index 01e19346..b707eaaa 100644 --- a/pkg/ui/utils.go +++ b/pkg/ui/utils.go @@ -8,7 +8,7 @@ import ( ) const ( - appName = "Pagoda" + AppName = "Pagoda" ) var ( @@ -16,6 +16,6 @@ var ( cacheBuster = random.String(10) ) -func file(filepath string) string { +func File(filepath string) string { return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster) } From 0e8edf4a069efaef020cafa9cd1d6fee22601d6a Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Tue, 25 Feb 2025 19:14:05 -0500 Subject: [PATCH 16/30] Added a gomponent node cache. --- pkg/ui/cache.go | 1 - pkg/ui/cache/cache.go | 52 ++++++++++++++++++++++++++++++++++++++ pkg/ui/cache/cache_test.go | 32 +++++++++++++++++++++++ pkg/ui/layouts/auth.go | 4 +++ pkg/ui/layouts/primary.go | 7 +++++ 5 files changed, 95 insertions(+), 1 deletion(-) delete mode 100644 pkg/ui/cache.go create mode 100644 pkg/ui/cache/cache.go create mode 100644 pkg/ui/cache/cache_test.go diff --git a/pkg/ui/cache.go b/pkg/ui/cache.go deleted file mode 100644 index 5b1faa29..00000000 --- a/pkg/ui/cache.go +++ /dev/null @@ -1 +0,0 @@ -package ui diff --git a/pkg/ui/cache/cache.go b/pkg/ui/cache/cache.go new file mode 100644 index 00000000..2989e8b0 --- /dev/null +++ b/pkg/ui/cache/cache.go @@ -0,0 +1,52 @@ +package cache + +import ( + "bytes" + "sync" + + "maragu.dev/gomponents" +) + +var ( + // cache stores a cache of assembled components keyed by an ID. + cache = make(map[string]gomponents.Node) + + // mu handles concurrent access to the cache. + mu sync.RWMutex +) + +// Set sets a given renderable node in the cache with a given key. +// You should only cache nodes that are entirely static. +// This will panic if the node fails to render. +// +// To optimize performance, the node will be rendered and converted to a Raw component so the assembly and rendering +// of the entire, nested node only has to execute once. This can eliminate countless function calls and string building. +// It's worth noting that this performance optimization is, in most cases, entirely unnecessary, but it's easy to do +// and realize some performance gains. In my very limited testing, gomponents actually outperformed Go templates in +// many areas; but not all. The results were still very close and my limited testing is in no way definitive. +// +// In most applications, these slight differences in nanoseconds and bytes allocated will almost never matter or even +// be noticeable, but it's good to be aware of them; and it's fun to address them. In looking at the example layouts +// provided, I noticed that a lot of nested function calls and string building was happening on every single page load +// just to re-render static HTML such as the navbar and the search form/modal. Benchmarks quickly revealed that caching +// those high-level nodes made a significant difference in speed and memory allocations. Going further, I thought that +// with the entire node cached, you still have to render the entire nested structure each time it's used, so that is why +// this will render them upfront, then cache. If my few examples have a handful of static nodes, I assume most full +// applications will have many, so maybe this is useful. +func Set(key string, node gomponents.Node) { + buf := bytes.NewBuffer(nil) + if err := node.Render(buf); err != nil { + panic(err) + } + + mu.Lock() + defer mu.Unlock() + cache[key] = gomponents.Raw(buf.String()) +} + +// Get returns the node cached under the provided key, if one exists. +func Get(key string) gomponents.Node { + mu.RLock() + defer mu.RUnlock() + return cache[key] +} diff --git a/pkg/ui/cache/cache_test.go b/pkg/ui/cache/cache_test.go new file mode 100644 index 00000000..88c94014 --- /dev/null +++ b/pkg/ui/cache/cache_test.go @@ -0,0 +1,32 @@ +package cache + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func TestCache(t *testing.T) { + key := "test" + assert.Nil(t, Get(key)) + + node := Div(Text("hello")) + Set(key, node) + + got := Get(key) + require.NotNil(t, got) + + // Check it was converted to a Raw component. + _, ok := got.(NodeFunc) + require.True(t, ok) + + // Both nodes should render the same string. + buf1, buf2 := bytes.NewBuffer(nil), bytes.NewBuffer(nil) + require.NoError(t, node.Render(buf1)) + require.NoError(t, got.Render(buf2)) + assert.Equal(t, buf1.String(), buf2.String()) +} diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go index 2de55f71..06f608e3 100644 --- a/pkg/ui/layouts/auth.go +++ b/pkg/ui/layouts/auth.go @@ -3,6 +3,7 @@ package layouts import ( "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/cache" . "github.com/mikestefanello/pagoda/pkg/ui/components" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" @@ -46,6 +47,9 @@ func Auth(r *ui.Request, content Node) Node { } func authNavBar(r *ui.Request) Node { + if n := cache.Get("layout.authNavBar"); n != nil { + return n + } return Nav( Class("navbar"), Div( diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go index 0a81b572..72f40955 100644 --- a/pkg/ui/layouts/primary.go +++ b/pkg/ui/layouts/primary.go @@ -3,6 +3,7 @@ package layouts import ( "github.com/mikestefanello/pagoda/pkg/routenames" "github.com/mikestefanello/pagoda/pkg/ui" + "github.com/mikestefanello/pagoda/pkg/ui/cache" . "github.com/mikestefanello/pagoda/pkg/ui/components" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" @@ -41,6 +42,9 @@ func Primary(r *ui.Request, content Node) Node { } func headerNavBar(r *ui.Request) Node { + if n := cache.Get("layout.headerNavBar"); n != nil { + return n + } return Nav( Class("navbar is-dark"), Div( @@ -67,6 +71,9 @@ func headerNavBar(r *ui.Request) Node { } func search(r *ui.Request) Node { + if n := cache.Get("layout.search"); n != nil { + return n + } return Div( Class("search mr-2 mt-1"), Attr("x-data", "{modal:false}"), From 40efe614c20157476e8fb2cde5aea112f34103c8 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:53:24 -0500 Subject: [PATCH 17/30] Removed full page caching. --- pkg/context/context.go | 2 +- pkg/handlers/router.go | 3 +- pkg/middleware/cache.go | 25 +----------- pkg/middleware/cache_test.go | 34 ---------------- pkg/msg/msg.go | 3 +- pkg/tasks/example.go | 10 +++-- pkg/tasks/register.go | 2 +- pkg/ui/pages/about.go | 77 ++++++++++++++++++++---------------- pkg/ui/pages/file.go | 6 +-- 9 files changed, 57 insertions(+), 105 deletions(-) diff --git a/pkg/context/context.go b/pkg/context/context.go index fa1a001a..029f2788 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -30,7 +30,7 @@ const ( HTMXRequestKey = "htmx" ) -// IsCanceledError determines if an error is due to a context cancelation +// IsCanceledError determines if an error is due to a context cancelation. func IsCanceledError(err error) bool { return errors.Is(err, context.Canceled) } diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index 0e3077e7..61bd3461 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -13,7 +13,7 @@ import ( // BuildRouter builds the router. func BuildRouter(c *services.Container) error { // Static files with proper cache control. - // ui.file() should be used in ui components to append a cache key to the URL in order to break cache + // ui.File() should be used in ui components to append a cache key to the URL in order to break cache // after each server restart. c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)). Static(config.StaticPrefix, config.StaticDir) @@ -47,7 +47,6 @@ func BuildRouter(c *services.Container) error { }), middleware.Session(cookieStore), middleware.LoadAuthenticatedUser(c.Auth), - middleware.ServeCachedPage, echomw.CSRFWithConfig(echomw.CSRFConfig{ TokenLookup: "form:csrf", CookieHTTPOnly: true, diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go index 2a718a0a..adfb8ebe 100644 --- a/pkg/middleware/cache.go +++ b/pkg/middleware/cache.go @@ -2,35 +2,12 @@ package middleware import ( "fmt" - "net/http" "time" "github.com/labstack/echo/v4" - "github.com/mikestefanello/pagoda/pkg/context" ) -// ServeCachedPage attempts to load a page from the cache by matching on the complete request URL -// If a page is cached for the requested URL, it will be served here and the request terminated. -// Any request made by an authenticated user or that is not a GET will be skipped. -func ServeCachedPage(next echo.HandlerFunc) echo.HandlerFunc { - return func(ctx echo.Context) error { - - // Skip non GET requests - if ctx.Request().Method != http.MethodGet { - return next(ctx) - } - - // Skip if the user is authenticated - if ctx.Get(context.AuthenticatedUserKey) != nil { - return next(ctx) - } - - // TODO keep this functionality? - return next(ctx) - } -} - -// CacheControl sets a Cache-Control header with a given max age +// CacheControl sets a Cache-Control header with a given max age. func CacheControl(maxAge time.Duration) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { diff --git a/pkg/middleware/cache_test.go b/pkg/middleware/cache_test.go index c092d5da..040777de 100644 --- a/pkg/middleware/cache_test.go +++ b/pkg/middleware/cache_test.go @@ -9,40 +9,6 @@ import ( "github.com/stretchr/testify/assert" ) -// TODO keep this? -//func TestServeCachedPage(t *testing.T) { -// // Cache a page -// ctx, rec := tests.NewContext(c.Web, "/cache") -// p := page.New(ctx) -// p.Layout = templates.LayoutHTMX -// p.Name = templates.PageHome -// p.Cache.Enabled = true -// p.Cache.Expiration = time.Minute -// p.StatusCode = http.StatusCreated -// p.Headers["a"] = "b" -// p.Headers["c"] = "d" -// err := c.TemplateRenderer.RenderPage(ctx, p) -// output := rec.Body.Bytes() -// require.NoError(t, err) -// -// // Request the URL of the cached page -// ctx, rec = tests.NewContext(c.Web, "/cache") -// err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) -// assert.NoError(t, err) -// assert.Equal(t, p.StatusCode, ctx.Response().Status) -// assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a")) -// assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c")) -// assert.Equal(t, output, rec.Body.Bytes()) -// -// // Login and try again -// tests.InitSession(ctx) -// err = c.Auth.Login(ctx, usr.ID) -// require.NoError(t, err) -// _ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth)) -// err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer)) -// assert.Nil(t, err) -//} - func TestCacheControl(t *testing.T) { ctx, _ := tests.NewContext(c.Web, "/") _ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5)) diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go index b4fe9a42..724ecdbc 100644 --- a/pkg/msg/msg.go +++ b/pkg/msg/msg.go @@ -7,7 +7,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/session" ) -// Type is a message type.s +// Type is a message type. type Type string const ( @@ -60,7 +60,6 @@ func Set(ctx echo.Context, typ Type, message string) { // Get gets flash messages of a given type from the session storage. // Errors will be logged and not returned. -// TODO return all types with a single call. func Get(ctx echo.Context, typ Type) []string { var msgs []string diff --git a/pkg/tasks/example.go b/pkg/tasks/example.go index 48e1839b..4c7a86db 100644 --- a/pkg/tasks/example.go +++ b/pkg/tasks/example.go @@ -2,14 +2,16 @@ package tasks import ( "context" - "github.com/mikestefanello/backlite" "time" + "github.com/mikestefanello/backlite" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/services" ) -// ExampleTask is an example implementation of backlite.Task +// ExampleTask is an example implementation of backlite.Task. // This represents the task that can be queued for execution via the task client and should contain everything // that your queue processor needs to process the task. type ExampleTask struct { @@ -34,7 +36,7 @@ func (t ExampleTask) Config() backlite.QueueConfig { } } -// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks +// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks. // The service container is provided so the subscriber can have access to the app dependencies. // All queues must be registered in the Register() function. // Whenever an ExampleTask is added to the task client, it will be queued and eventually sent here for execution. @@ -44,7 +46,7 @@ func NewExampleTaskQueue(c *services.Container) backlite.Queue { "message", task.Message, ) log.Default().Info("This can access the container for dependencies", - "echo", c.Web.Reverse("home"), + "echo", c.Web.Reverse(routenames.Home), ) return nil }) diff --git a/pkg/tasks/register.go b/pkg/tasks/register.go index 895b974a..fc934dcd 100644 --- a/pkg/tasks/register.go +++ b/pkg/tasks/register.go @@ -4,7 +4,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/services" ) -// Register registers all task queues with the task client +// Register registers all task queues with the task client. func Register(c *services.Container) { c.Tasks.Register(NewExampleTaskQueue(c)) } diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go index b0651788..9db86885 100644 --- a/pkg/ui/pages/about.go +++ b/pkg/ui/pages/about.go @@ -3,7 +3,8 @@ package pages import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/ui" - "github.com/mikestefanello/pagoda/pkg/ui/components" + "github.com/mikestefanello/pagoda/pkg/ui/cache" + . "github.com/mikestefanello/pagoda/pkg/ui/components" "github.com/mikestefanello/pagoda/pkg/ui/layouts" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" @@ -14,39 +15,47 @@ func About(ctx echo.Context) error { r.Title = "About" r.Metatags.Description = "Learn a little about what's included in Pagoda." - return r.Render(layouts.Primary, Group{ - components.Tabs( - "Frontend", - "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", - []components.Tab{ - { - Title: "HTMX", - Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.", + tabs := func() Node { + // The tabs are static so we can render and cache them. + if n := cache.Get("pages.about.Tabs"); n != nil { + return n + } + return Group{ + Tabs( + "Frontend", + "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", + []Tab{ + { + Title: "HTMX", + Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit htmx.org to learn more.", + }, + { + Title: "Alpine.js", + Body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.", + }, + { + Title: "Bulma", + Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.", + }, }, - { - Title: "Alpine.js", - Body: "Drop-in, Vue-like functionality written directly in your markup. Visit alpinejs.dev to learn more.", + ), + Div(Class("mb-4")), + Tabs( + "Backend", + "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.", + []Tab{ + { + Title: "Echo", + Body: "High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.", + }, + { + Title: "Ent", + Body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.", + }, }, - { - Title: "Bulma", - Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit bulma.io to learn more.", - }, - }, - ), - Div(Class("mb-4")), - components.Tabs( - "Backend", - "The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.", - []components.Tab{ - { - Title: "Echo", - Body: "High performance, extensible, minimalist Go web framework. Visit echo.labstack.com to learn more.", - }, - { - Title: "Ent", - Body: "Simple, yet powerful ORM for modeling and querying data. Visit entgo.io to learn more.", - }, - }, - ), - }) + ), + } + } + + return r.Render(layouts.Primary, tabs()) } diff --git a/pkg/ui/pages/file.go b/pkg/ui/pages/file.go index 020f1d20..c3dec527 100644 --- a/pkg/ui/pages/file.go +++ b/pkg/ui/pages/file.go @@ -3,7 +3,7 @@ package pages import ( "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/ui" - "github.com/mikestefanello/pagoda/pkg/ui/components" + . "github.com/mikestefanello/pagoda/pkg/ui/components" "github.com/mikestefanello/pagoda/pkg/ui/forms" "github.com/mikestefanello/pagoda/pkg/ui/layouts" "github.com/mikestefanello/pagoda/pkg/ui/models" @@ -21,7 +21,7 @@ func UploadFile(ctx echo.Context, files []*models.File) error { } n := Group{ - components.Message( + Message( "is-link", "", P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")), @@ -33,7 +33,7 @@ func UploadFile(ctx echo.Context, files []*models.File) error { Class("title"), Text("Uploaded files"), ), - components.Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))), + Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))), Table( Class("table"), THead( From fb845c6288b8fa228e04ee78a9549ba4b9f37677 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:18:23 -0500 Subject: [PATCH 18/30] Code cleanup. --- config/config.go | 41 +++++++++++----------- pkg/middleware/entity.go | 2 +- pkg/services/container.go | 46 ++++++++++++------------- pkg/ui/components/form.go | 24 ++++++------- pkg/ui/components/head.go | 9 ----- pkg/ui/components/tabs.go | 12 +++---- pkg/ui/models/post.go | 6 ++-- pkg/ui/pages/file.go | 6 ++-- pkg/ui/pages/search.go | 6 ++-- pkg/ui/request.go | 72 +++++++++++++++++++++++---------------- pkg/ui/utils.go | 6 ++-- 11 files changed, 115 insertions(+), 115 deletions(-) diff --git a/config/config.go b/config/config.go index 67597f5d..be3c7161 100644 --- a/config/config.go +++ b/config/config.go @@ -9,35 +9,32 @@ import ( ) const ( - // TemplateExt stores the extension used for the template files - TemplateExt = ".gohtml" - - // StaticDir stores the name of the directory that will serve static files + // StaticDir stores the name of the directory that will serve static files. StaticDir = "static" - // StaticPrefix stores the URL prefix used when serving static files + // StaticPrefix stores the URL prefix used when serving static files. StaticPrefix = "files" ) type environment string const ( - // EnvLocal represents the local environment + // EnvLocal represents the local environment. EnvLocal environment = "local" - // EnvTest represents the test environment + // EnvTest represents the test environment. EnvTest environment = "test" - // EnvDevelop represents the development environment + // EnvDevelop represents the development environment. EnvDevelop environment = "dev" - // EnvStaging represents the staging environment + // EnvStaging represents the staging environment. EnvStaging environment = "staging" - // EnvQA represents the qa environment + // EnvQA represents the qa environment. EnvQA environment = "qa" - // EnvProduction represents the production environment + // EnvProduction represents the production environment. EnvProduction environment = "prod" ) @@ -51,7 +48,7 @@ func SwitchEnvironment(env environment) { } type ( - // Config stores complete configuration + // Config stores complete configuration. Config struct { HTTP HTTPConfig App AppConfig @@ -62,7 +59,7 @@ type ( Mail MailConfig } - // HTTPConfig stores HTTP configuration + // HTTPConfig stores HTTP configuration. HTTPConfig struct { Hostname string Port uint16 @@ -77,7 +74,7 @@ type ( } } - // AppConfig stores application configuration + // AppConfig stores application configuration. AppConfig struct { Environment environment EncryptionKey string @@ -89,7 +86,7 @@ type ( EmailVerificationTokenExpiration time.Duration } - // CacheConfig stores the cache configuration + // CacheConfig stores the cache configuration. CacheConfig struct { Capacity int Expiration struct { @@ -98,19 +95,19 @@ type ( } } - // DatabaseConfig stores the database configuration + // DatabaseConfig stores the database configuration. DatabaseConfig struct { Driver string Connection string TestConnection string } - // FilesConfig stores the file system configuration + // FilesConfig stores the file system configuration. FilesConfig struct { Directory string } - // TasksConfig stores the tasks configuration + // TasksConfig stores the tasks configuration. TasksConfig struct { Goroutines int ReleaseAfter time.Duration @@ -118,7 +115,7 @@ type ( ShutdownTimeout time.Duration } - // MailConfig stores the mail configuration + // MailConfig stores the mail configuration. MailConfig struct { Hostname string Port uint16 @@ -128,11 +125,11 @@ type ( } ) -// GetConfig loads and returns configuration +// GetConfig loads and returns configuration. func GetConfig() (Config, error) { var c Config - // Load the config file + // Load the config file. viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath(".") @@ -140,7 +137,7 @@ func GetConfig() (Config, error) { viper.AddConfigPath("../config") viper.AddConfigPath("../../config") - // Load env variables + // Load env variables. viper.SetEnvPrefix("pagoda") viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) diff --git a/pkg/middleware/entity.go b/pkg/middleware/entity.go index c13bee74..d6a4c012 100644 --- a/pkg/middleware/entity.go +++ b/pkg/middleware/entity.go @@ -12,7 +12,7 @@ import ( "github.com/labstack/echo/v4" ) -// LoadUser loads the user based on the ID provided as a path parameter +// LoadUser loads the user based on the ID provided as a path parameter. func LoadUser(orm *ent.Client) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/pkg/services/container.go b/pkg/services/container.go index 9bd074dc..01c090b7 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -23,40 +23,40 @@ import ( ) // Container contains all services used by the application and provides an easy way to handle dependency -// injection including within tests +// injection including within tests. type Container struct { // Validator stores a validator Validator *Validator - // Web stores the web framework + // Web stores the web framework. Web *echo.Echo - // Config stores the application configuration + // Config stores the application configuration. Config *config.Config - // Cache contains the cache client + // Cache contains the cache client. Cache *CacheClient - // Database stores the connection to the database + // Database stores the connection to the database. Database *sql.DB // Files stores the file system. Files afero.Fs - // ORM stores a client to the ORM + // ORM stores a client to the ORM. ORM *ent.Client - // Mail stores an email sending client + // Mail stores an email sending client. Mail *MailClient - // Auth stores an authentication client + // Auth stores an authentication client. Auth *AuthClient - // Tasks stores the task client + // Tasks stores the task client. Tasks *backlite.Client } -// NewContainer creates and initializes a new Container +// NewContainer creates and initializes a new Container. func NewContainer() *Container { c := new(Container) c.initConfig() @@ -102,7 +102,7 @@ func (c *Container) Shutdown() error { return nil } -// initConfig initializes configuration +// initConfig initializes configuration. func (c *Container) initConfig() { cfg, err := config.GetConfig() if err != nil { @@ -110,7 +110,7 @@ func (c *Container) initConfig() { } c.Config = &cfg - // Configure logging + // Configure logging. switch cfg.App.Environment { case config.EnvProduction: slog.SetLogLoggerLevel(slog.LevelInfo) @@ -119,19 +119,19 @@ func (c *Container) initConfig() { } } -// initValidator initializes the validator +// initValidator initializes the validator. func (c *Container) initValidator() { c.Validator = NewValidator() } -// initWeb initializes the web framework +// initWeb initializes the web framework. func (c *Container) initWeb() { c.Web = echo.New() c.Web.HideBanner = true c.Web.Validator = c.Validator } -// initCache initializes the cache +// initCache initializes the cache. func (c *Container) initCache() { store, err := newInMemoryCache(c.Config.Cache.Capacity) if err != nil { @@ -141,7 +141,7 @@ func (c *Container) initCache() { c.Cache = NewCacheClient(store) } -// initDatabase initializes the database +// initDatabase initializes the database. func (c *Container) initDatabase() { var err error var connection string @@ -175,7 +175,7 @@ func (c *Container) initFiles() { c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory) } -// initORM initializes the ORM +// initORM initializes the ORM. func (c *Container) initORM() { drv := entsql.OpenDB(c.Config.Database.Driver, c.Database) c.ORM = ent.NewClient(ent.Driver(drv)) @@ -186,12 +186,12 @@ func (c *Container) initORM() { } } -// initAuth initializes the authentication client +// initAuth initializes the authentication client. func (c *Container) initAuth() { c.Auth = NewAuthClient(c.Config, c.ORM) } -// initMail initialize the mail client +// initMail initialize the mail client. func (c *Container) initMail() { var err error c.Mail, err = NewMailClient(c.Config) @@ -200,11 +200,11 @@ func (c *Container) initMail() { } } -// initTasks initializes the task client +// initTasks initializes the task client. func (c *Container) initTasks() { var err error // You could use a separate database for tasks, if you'd like. but using one - // makes transaction support easier + // makes transaction support easier. c.Tasks, err = backlite.NewClient(backlite.ClientConfig{ DB: c.Database, Logger: log.Default(), @@ -222,10 +222,10 @@ func (c *Container) initTasks() { } } -// openDB opens a database connection +// openDB opens a database connection. func openDB(driver, connection string) (*sql.DB, error) { // Helper to automatically create the directories that the specified sqlite file - // should reside in, if one + // should reside in, if one. if driver == "sqlite3" { d := strings.Split(connection, "/") diff --git a/pkg/ui/components/form.go b/pkg/ui/components/form.go index 455b237c..7dee6cfe 100644 --- a/pkg/ui/components/form.go +++ b/pkg/ui/components/form.go @@ -44,12 +44,12 @@ type ( ) func ControlGroup(controls ...Node) Node { - g := make(Group, 0, len(controls)) - for _, control := range controls { - g = append(g, Div( + g := make(Group, len(controls)) + for i, control := range controls { + g[i] = Div( Class("control"), control, - )) + ) } return Div( @@ -81,9 +81,9 @@ func TextareaField(el TextareaFieldParams) Node { } func Radios(el RadiosParams) Node { - buttons := make(Group, 0, len(el.Options)) - for _, opt := range el.Options { - buttons = append(buttons, Label( + buttons := make(Group, len(el.Options)) + for i, opt := range el.Options { + buttons[i] = Label( Class("radio"), Input( Type("radio"), @@ -92,7 +92,7 @@ func Radios(el RadiosParams) Node { If(el.Value == opt.Value, Checked()), ), Text(" "+opt.Label), - )) + ) } return Div( @@ -168,12 +168,12 @@ func formFieldErrors(fm form.Form, field string) Node { return nil } - g := make(Group, 0, len(errs)) - for _, err := range errs { - g = append(g, P( + g := make(Group, len(errs)) + for i, err := range errs { + g[i] = P( Class("help is-danger"), Text(err), - )) + ) } return g diff --git a/pkg/ui/components/head.go b/pkg/ui/components/head.go index d6ae941d..a86b7ae6 100644 --- a/pkg/ui/components/head.go +++ b/pkg/ui/components/head.go @@ -36,15 +36,6 @@ func JS(r *ui.Request) Node { return Group{ Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")), Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()), - If(len(r.CSRF) > 0, Script( - Raw(` - document.body.addEventListener('htmx:configRequest', function(evt) { - if (evt.detail.verb !== "get") { - evt.detail.parameters['csrf'] = '`+r.CSRF+`'; - } - }) - `), - )), Script(Raw(htmxErr)), csrf, } diff --git a/pkg/ui/components/tabs.go b/pkg/ui/components/tabs.go index 894f7401..5c8857bc 100644 --- a/pkg/ui/components/tabs.go +++ b/pkg/ui/components/tabs.go @@ -13,24 +13,24 @@ type Tab struct { func Tabs(heading, description string, items []Tab) Node { renderTitles := func() Node { - g := make(Group, 0, len(items)) + g := make(Group, len(items)) for i, item := range items { - g = append(g, Li( + g[i] = Li( Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)), Attr("@click", fmt.Sprintf("tab = %d", i)), A(Text(item.Title)), - )) + ) } return g } renderBodies := func() Node { - g := make(Group, 0, len(items)) + g := make(Group, len(items)) for i, item := range items { - g = append(g, Div( + g[i] = Div( Attr("x-show", fmt.Sprintf("tab == %d", i)), P(Raw(" "+item.Body)), - )) + ) } return g } diff --git a/pkg/ui/models/post.go b/pkg/ui/models/post.go index a07f0ae8..b66ef672 100644 --- a/pkg/ui/models/post.go +++ b/pkg/ui/models/post.go @@ -21,9 +21,9 @@ type ( ) func (p *Posts) Render(path string) Node { - g := make(Group, 0, len(p.Posts)) - for _, post := range p.Posts { - g = append(g, post.Render()) + g := make(Group, len(p.Posts)) + for i, post := range p.Posts { + g[i] = post.Render() } return Div( diff --git a/pkg/ui/pages/file.go b/pkg/ui/pages/file.go index c3dec527..506946d0 100644 --- a/pkg/ui/pages/file.go +++ b/pkg/ui/pages/file.go @@ -15,9 +15,9 @@ func UploadFile(ctx echo.Context, files []*models.File) error { r := ui.NewRequest(ctx) r.Title = "Upload a file" - fileList := make(Group, 0, len(files)) - for _, file := range files { - fileList = append(fileList, file.Render()) + fileList := make(Group, len(files)) + for i, file := range files { + fileList[i] = file.Render() } n := Group{ diff --git a/pkg/ui/pages/search.go b/pkg/ui/pages/search.go index a330aa46..0af5cee7 100644 --- a/pkg/ui/pages/search.go +++ b/pkg/ui/pages/search.go @@ -11,9 +11,9 @@ import ( func SearchResults(ctx echo.Context, results []*models.SearchResult) error { r := ui.NewRequest(ctx) - g := make(Group, 0, len(results)) - for _, result := range results { - g = append(g, result.Render()) + g := make(Group, len(results)) + for i, result := range results { + g[i] = result.Render() } return r.Render(layouts.Primary, g) diff --git a/pkg/ui/request.go b/pkg/ui/request.go index 7ec5b4ff..3bea2c28 100644 --- a/pkg/ui/request.go +++ b/pkg/ui/request.go @@ -9,48 +9,54 @@ import ( "maragu.dev/gomponents" ) -type LayoutFunc func(*Request, gomponents.Node) gomponents.Node +type ( + // Request encapsulates information about the incoming request in order to provide your ui with important and + // useful information needed for rendering. + Request struct { + // Title stores the title of the page. + Title string -type Request struct { - // AppName stores the name of the application. - // If omitted, the configuration value will be used. - AppName string + // Context stores the request context. + Context echo.Context - // Title stores the title of the page - Title string + // CurrentPath stores the path of the current request. + CurrentPath string - // Context stores the request context - Context echo.Context + // IsHome stores whether the requested page is the home page. + IsHome bool - // CurrentPath stores the path of the current request - CurrentPath string + // IsAuth stores whether the user is authenticated. + IsAuth bool - // IsHome stores whether the requested page is the home page or not - IsHome bool + // AuthUser stores the authenticated user. + AuthUser *ent.User - // IsAuth stores whether the user is authenticated - IsAuth bool + // Metatags stores metatag values. + Metatags struct { + // Description stores the description metatag value. + Description string - // AuthUser stores the authenticated user - AuthUser *ent.User + // Keywords stores the keywords metatag values. + Keywords []string + } - // Metatags stores metatag values - Metatags struct { - // Description stores the description metatag value - Description string + // CSRF stores the CSRF token for the given request. + // This will only be populated if the CSRF middleware is in effect for the given request. + // If this is populated, all forms must include this value otherwise the requests will be rejected. + CSRF string - // Keywords stores the keywords metatag values - Keywords []string + // Htmx stores information provided by HTMX about this request. + Htmx *htmx.Request } - // CSRF stores the CSRF token for the given request. - // This will only be populated if the CSRF middleware is in effect for the given request. - // If this is populated, all forms must include this value otherwise the requests will be rejected. - CSRF string - - Htmx *htmx.Request -} + // LayoutFunc is a callback function intended to render your page node within a given layout. + // This is handled as a callback in order to automatically support HTMX requests so that you can respond + // with only the page content and not the entire layout. + // See Request.Render(). + LayoutFunc func(*Request, gomponents.Node) gomponents.Node +) +// NewRequest generates a new Request using the Echo context of a given HTTP request. func NewRequest(ctx echo.Context) *Request { p := &Request{ Context: ctx, @@ -72,10 +78,16 @@ func NewRequest(ctx echo.Context) *Request { return p } +// Path generates a URL path for a given route name and optional route parameters. +// This will only work if you've supplied names for each of your routes. It's optional to use and helps avoids +// having duplicate, hard-coded paths and parameters all over your application. func (r *Request) Path(routeName string, routeParams ...string) string { return r.Context.Echo().Reverse(routeName, routeParams) } +// Render renders a given node, optionally within a given layout based on the HTMX request headers. +// If the request is being made by HTMX and is not boosted, this will automatically only render the node without +// the layout, to support partial rendering. func (r *Request) Render(layout LayoutFunc, node gomponents.Node) error { if r.Htmx.Enabled && !r.Htmx.Boosted { return node.Render(r.Context.Response().Writer) diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go index b707eaaa..480fd682 100644 --- a/pkg/ui/utils.go +++ b/pkg/ui/utils.go @@ -7,15 +7,15 @@ import ( "github.com/mikestefanello/pagoda/config" ) -const ( - AppName = "Pagoda" -) +// AppName is the name of the application. +const AppName = "Pagoda" var ( // cacheBuster stores a random string used as a cache buster for static files. cacheBuster = random.String(10) ) +// File generates a relative URL to a static file including a cache-buster query parameter. func File(filepath string) string { return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster) } From 6de3c865a7ea9fa4f186f2656895c6feea07457e Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:23:34 -0500 Subject: [PATCH 19/30] Run tests in parallel with cache. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2563f831..a40014c6 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ watch: ## Run the application and watch for changes with air to automatically re .PHONY: test test: ## Run all tests - go test -count=1 -p 1 ./... + go test ./... .PHONY: check-updates check-updates: ## Check for direct dependency updates From a62046151c68dd54be4dcf0d9e59caf5bed9dd87 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:56:10 -0500 Subject: [PATCH 20/30] Removed sprig and gommon. --- README.md | 3 ++- go.mod | 9 +-------- go.sum | 14 -------------- pkg/ui/utils.go | 6 +++--- 4 files changed, 6 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c2f0813d..6f95fc8d 100644 --- a/README.md +++ b/README.md @@ -1162,6 +1162,7 @@ Future work includes but is not limited to: Thank you to all of the following amazing projects for making this possible. - [afero](https://github.com/spf13/afero) +- [air](https://github.com/air-verse/air) - [alpinejs](https://github.com/alpinejs/alpine) - [backlite](https://github.com/mikestefanello/backlite) - [bulma](https://github.com/jgthms/bulma) @@ -1169,12 +1170,12 @@ Thank you to all of the following amazing projects for making this possible. - [ent](https://github.com/ent/ent) - [go](https://go.dev/) - [go-sqlite3](https://github.com/mattn/go-sqlite3) +- [gomponents](https://github.com/maragudk/gomponents) - [goquery](https://github.com/PuerkitoBio/goquery) - [htmx](https://github.com/bigskysoftware/htmx) - [jwt](https://github.com/golang-jwt/jwt) - [otter](https://github.com/maypok86/otter) - [sessions](https://github.com/gorilla/sessions) -- [sprig](https://github.com/Masterminds/sprig) - [sqlite](https://sqlite.org/) - [testify](https://github.com/stretchr/testify) - [validator](https://github.com/go-playground/validator) diff --git a/go.mod b/go.mod index 71aaf91a..e68c05b0 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,12 @@ go 1.23.0 require ( entgo.io/ent v0.14.2 - github.com/Masterminds/sprig v2.22.0+incompatible github.com/PuerkitoBio/goquery v1.10.1 github.com/go-playground/validator/v10 v10.24.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gorilla/context v1.1.2 github.com/gorilla/sessions v1.4.0 github.com/labstack/echo/v4 v4.13.3 - github.com/labstack/gommon v0.4.2 github.com/mattn/go-sqlite3 v1.14.24 github.com/maypok86/otter v1.2.4 github.com/mikestefanello/backlite v0.2.0 @@ -24,8 +22,6 @@ require ( require ( ariga.io/atlas v0.31.1-0.20250212144724-069be8033e83 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver v1.5.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -43,16 +39,13 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.20.1 // indirect - github.com/huandu/xstrings v1.4.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect diff --git a/go.sum b/go.sum index 4c9d8d8a..9d4cc82a 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,6 @@ entgo.io/ent v0.14.2 h1:ywld/j2Rx4EmnIKs8eZ29cbFA1zpB+DA9TLL5l3rlq0= entgo.io/ent v0.14.2/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= -github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -64,10 +58,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= -github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= -github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -94,14 +84,10 @@ github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/mikestefanello/backlite v0.2.0 h1:nX+QUy/z5FEV+ApMqvZ1jGqyRPGbuBI2mJR0BvirT9s= github.com/mikestefanello/backlite v0.2.0/go.mod h1:/vj8LPZWG/xqK/3uHaqOtu5JRLDEWqeyJKWTAlADTV0= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/pkg/ui/utils.go b/pkg/ui/utils.go index 480fd682..7fe0339d 100644 --- a/pkg/ui/utils.go +++ b/pkg/ui/utils.go @@ -2,8 +2,8 @@ package ui import ( "fmt" + "time" - "github.com/labstack/gommon/random" "github.com/mikestefanello/pagoda/config" ) @@ -11,8 +11,8 @@ import ( const AppName = "Pagoda" var ( - // cacheBuster stores a random string used as a cache buster for static files. - cacheBuster = random.String(10) + // cacheBuster stores the current time as a cache buster for static files. + cacheBuster = fmt.Sprint(time.Now().Unix()) ) // File generates a relative URL to a static file including a cache-buster query parameter. From 64e112de6c0ef957e413990ff62f9f63c02b9cff Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 2 Mar 2025 09:14:40 -0500 Subject: [PATCH 21/30] Use our const for the CSRF context key. --- pkg/context/context.go | 3 +++ pkg/handlers/router.go | 2 ++ pkg/ui/request.go | 3 +-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/context/context.go b/pkg/context/context.go index 029f2788..836bf484 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -28,6 +28,9 @@ const ( // HTMXRequestKey is the key used to store the HTMX request data in context. HTMXRequestKey = "htmx" + + // CSRFKey is the key used to store the CSRF token in context. + CSRFKey = "csrf" ) // IsCanceledError determines if an error is due to a context cancelation. diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index 61bd3461..ee5d8cc4 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/sessions" echomw "github.com/labstack/echo/v4/middleware" "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/services" ) @@ -52,6 +53,7 @@ func BuildRouter(c *services.Container) error { CookieHTTPOnly: true, CookieSecure: true, CookieSameSite: http.SameSiteStrictMode, + ContextKey: context.CSRFKey, }), ) diff --git a/pkg/ui/request.go b/pkg/ui/request.go index 3bea2c28..cddf8941 100644 --- a/pkg/ui/request.go +++ b/pkg/ui/request.go @@ -2,7 +2,6 @@ package ui import ( "github.com/labstack/echo/v4" - echomw "github.com/labstack/echo/v4/middleware" "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/htmx" @@ -66,7 +65,7 @@ func NewRequest(ctx echo.Context) *Request { p.IsHome = p.CurrentPath == "/" - if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil { + if csrf := ctx.Get(context.CSRFKey); csrf != nil { p.CSRF = csrf.(string) } From bfbe88a2bc10c5221655ccaabbf34f6f0b0e9bf6 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 2 Mar 2025 10:29:52 -0500 Subject: [PATCH 22/30] Pass config to the ui via middleware and context. --- config/config.go | 2 + config/config.yaml | 6 ++- pkg/context/context.go | 5 +- pkg/handlers/auth.go | 3 +- pkg/handlers/router.go | 1 + pkg/middleware/config.go | 17 +++++++ pkg/middleware/config_test.go | 22 +++++++++ pkg/services/container.go | 2 +- pkg/ui/cache/cache.go | 2 +- pkg/ui/components/head.go | 6 +-- pkg/ui/components/nav.go | 2 +- pkg/ui/emails/auth.go | 8 ++- pkg/ui/request.go | 18 ++++++- pkg/ui/request_test.go | 93 +++++++++++++++++++++++++++++++++++ pkg/ui/{utils.go => ui.go} | 3 -- pkg/ui/ui_test.go | 16 ++++++ 16 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 pkg/middleware/config.go create mode 100644 pkg/middleware/config_test.go create mode 100644 pkg/ui/request_test.go rename pkg/ui/{utils.go => ui.go} (85%) create mode 100644 pkg/ui/ui_test.go diff --git a/config/config.go b/config/config.go index be3c7161..7c8853e4 100644 --- a/config/config.go +++ b/config/config.go @@ -76,6 +76,8 @@ type ( // AppConfig stores application configuration. AppConfig struct { + Name string + Host string Environment environment EncryptionKey string Timeout time.Duration diff --git a/config/config.yaml b/config/config.yaml index 70fedbb6..963ecbc6 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -11,8 +11,12 @@ http: key: "" app: + name: "Pagoda" + # We manually set this rather than using the HTTP settings in order to build absolute URLs for users + # since it's likely your app's HTTP settings are not identical to what is exposed by your server. + host: "http://localhost:8000" environment: "local" - # Change this on any live environments + # Change this on any live environments. encryptionKey: "?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf" timeout: "20s" passwordToken: diff --git a/pkg/context/context.go b/pkg/context/context.go index 836bf484..d433949b 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -31,9 +31,12 @@ const ( // CSRFKey is the key used to store the CSRF token in context. CSRFKey = "csrf" + + // ConfigKey is the key used to store the configuration in context. + ConfigKey = "config" ) -// IsCanceledError determines if an error is due to a context cancelation. +// IsCanceledError determines if an error is due to a context cancellation. func IsCanceledError(err error) bool { return errors.Is(err, context.Canceled) } diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 59c99cd1..4638dca0 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -270,12 +270,11 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { } // Send the email. - url := ctx.Echo().Reverse(routenames.VerifyEmail, token) err = h.mail. Compose(). To(usr.Email). Subject("Confirm your email address"). - Component(emails.ConfirmEmailAddress(usr.Name, url)). + Component(emails.ConfirmEmailAddress(ctx, usr.Name, token)). Send(ctx) if err != nil { diff --git a/pkg/handlers/router.go b/pkg/handlers/router.go index ee5d8cc4..57d31def 100644 --- a/pkg/handlers/router.go +++ b/pkg/handlers/router.go @@ -46,6 +46,7 @@ func BuildRouter(c *services.Container) error { echomw.TimeoutWithConfig(echomw.TimeoutConfig{ Timeout: c.Config.App.Timeout, }), + middleware.Config(c.Config), middleware.Session(cookieStore), middleware.LoadAuthenticatedUser(c.Auth), echomw.CSRFWithConfig(echomw.CSRFConfig{ diff --git a/pkg/middleware/config.go b/pkg/middleware/config.go new file mode 100644 index 00000000..e3c89c18 --- /dev/null +++ b/pkg/middleware/config.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/context" +) + +// Config stores the configuration in the request so it can be accessed by the ui. +func Config(cfg *config.Config) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + ctx.Set(context.ConfigKey, cfg) + return next(ctx) + } + } +} diff --git a/pkg/middleware/config_test.go b/pkg/middleware/config_test.go new file mode 100644 index 00000000..22c0283b --- /dev/null +++ b/pkg/middleware/config_test.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "testing" + + "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfig(t *testing.T) { + ctx, _ := tests.NewContext(c.Web, "/") + cfg := &config.Config{} + err := tests.ExecuteMiddleware(ctx, Config(cfg)) + require.NoError(t, err) + + got, ok := ctx.Get(context.ConfigKey).(*config.Config) + require.True(t, ok) + assert.Same(t, got, cfg) +} diff --git a/pkg/services/container.go b/pkg/services/container.go index 01c090b7..a3f3ce80 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -18,7 +18,7 @@ import ( "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/log" - // Require by ent + // Required by ent. _ "github.com/mikestefanello/pagoda/ent/runtime" ) diff --git a/pkg/ui/cache/cache.go b/pkg/ui/cache/cache.go index 2989e8b0..94095e8b 100644 --- a/pkg/ui/cache/cache.go +++ b/pkg/ui/cache/cache.go @@ -8,7 +8,7 @@ import ( ) var ( - // cache stores a cache of assembled components keyed by an ID. + // cache stores a cache of assembled components by key. cache = make(map[string]gomponents.Node) // mu handles concurrent access to the cache. diff --git a/pkg/ui/components/head.go b/pkg/ui/components/head.go index a86b7ae6..56536b43 100644 --- a/pkg/ui/components/head.go +++ b/pkg/ui/components/head.go @@ -19,7 +19,7 @@ func JS(r *ui.Request) Node { }); ` - const htmxCSFR = ` + const htmxCSRF = ` document.body.addEventListener('htmx:configRequest', function(evt) { if (evt.detail.verb !== "get") { evt.detail.parameters['csrf'] = '%s'; @@ -30,7 +30,7 @@ func JS(r *ui.Request) Node { var csrf Node if len(r.CSRF) > 0 { - csrf = Script(Raw(fmt.Sprintf(htmxCSFR, r.CSRF))) + csrf = Script(Raw(fmt.Sprintf(htmxCSRF, r.CSRF))) } return Group{ @@ -53,7 +53,7 @@ func Metatags(r *ui.Request) Node { Meta(Charset("utf-8")), Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), Link(Rel("icon"), Href(ui.File("favicon.png"))), - TitleEl(Text(ui.AppName), If(r.Title != "", Text(" | "+r.Title))), + TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))), If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))), If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))), } diff --git a/pkg/ui/components/nav.go b/pkg/ui/components/nav.go index cf23ebfd..385069fc 100644 --- a/pkg/ui/components/nav.go +++ b/pkg/ui/components/nav.go @@ -6,7 +6,7 @@ import ( . "maragu.dev/gomponents/html" ) -func MenuLink(r *ui.Request, title, routeName string, routeParams ...string) Node { +func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node { href := r.Path(routeName, routeParams...) return Li( diff --git a/pkg/ui/emails/auth.go b/pkg/ui/emails/auth.go index 4d41cb82..771f8543 100644 --- a/pkg/ui/emails/auth.go +++ b/pkg/ui/emails/auth.go @@ -1,11 +1,17 @@ package emails import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/routenames" + "github.com/mikestefanello/pagoda/pkg/ui" . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) -func ConfirmEmailAddress(username, url string) Node { +func ConfirmEmailAddress(ctx echo.Context, username, token string) Node { + url := ui.NewRequest(ctx). + Url(routenames.VerifyEmail, token) + return Group{ Strong(Textf("Hello %s,", username)), Br(), diff --git a/pkg/ui/request.go b/pkg/ui/request.go index cddf8941..c2c6ab9a 100644 --- a/pkg/ui/request.go +++ b/pkg/ui/request.go @@ -2,6 +2,7 @@ package ui import ( "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/htmx" @@ -46,6 +47,10 @@ type ( // Htmx stores information provided by HTMX about this request. Htmx *htmx.Request + + // Config stores the application configuration. + // This will only be populated if the Config middleware is installed in the router. + Config *config.Config } // LayoutFunc is a callback function intended to render your page node within a given layout. @@ -74,14 +79,23 @@ func NewRequest(ctx echo.Context) *Request { p.AuthUser = u.(*ent.User) } + if cfg := ctx.Get(context.ConfigKey); cfg != nil { + p.Config = cfg.(*config.Config) + } + return p } // Path generates a URL path for a given route name and optional route parameters. // This will only work if you've supplied names for each of your routes. It's optional to use and helps avoids // having duplicate, hard-coded paths and parameters all over your application. -func (r *Request) Path(routeName string, routeParams ...string) string { - return r.Context.Echo().Reverse(routeName, routeParams) +func (r *Request) Path(routeName string, routeParams ...any) string { + return r.Context.Echo().Reverse(routeName, routeParams...) +} + +// Url generates an absolute URL for a given route name and optional route parameters. +func (r *Request) Url(routeName string, routeParams ...any) string { + return r.Config.App.Host + r.Path(routeName, routeParams...) } // Render renders a given node, optionally within a given layout based on the HTMX request headers. diff --git a/pkg/ui/request_test.go b/pkg/ui/request_test.go new file mode 100644 index 00000000..e8a49875 --- /dev/null +++ b/pkg/ui/request_test.go @@ -0,0 +1,93 @@ +package ui + +import ( + "testing" + + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/ent" + "github.com/mikestefanello/pagoda/pkg/context" + "github.com/mikestefanello/pagoda/pkg/htmx" + "github.com/mikestefanello/pagoda/pkg/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "maragu.dev/gomponents" + "maragu.dev/gomponents/html" +) + +func TestNewRequest(t *testing.T) { + e := echo.New() + ctx, _ := tests.NewContext(e, "/") + r := NewRequest(ctx) + assert.Same(t, ctx, r.Context) + assert.Equal(t, "/", r.CurrentPath) + assert.True(t, r.IsHome) + assert.False(t, r.IsAuth) + assert.Nil(t, r.AuthUser) + assert.Empty(t, r.CSRF) + assert.Nil(t, r.Config) + assert.Same(t, htmx.GetRequest(ctx), r.Htmx) + + ctx, _ = tests.NewContext(e, "/abc") + usr := &ent.User{ + ID: 1, + } + ctx.Set(context.AuthenticatedUserKey, usr) + ctx.Set(context.CSRFKey, "12345") + ctx.Set(context.ConfigKey, &config.Config{ + App: config.AppConfig{ + Name: "testing", + }, + }) + r = NewRequest(ctx) + assert.Equal(t, "/abc", r.CurrentPath) + assert.False(t, r.IsHome) + assert.True(t, r.IsAuth) + assert.Equal(t, usr, r.AuthUser) + assert.Equal(t, "12345", r.CSRF) + assert.Equal(t, "testing", r.Config.App.Name) +} + +func TestRequest_UrlPath(t *testing.T) { + e := echo.New() + e.GET("/abc/:id", func(c echo.Context) error { return nil }).Name = "test" + ctx, _ := tests.NewContext(e, "/") + r := NewRequest(ctx) + r.Config = &config.Config{ + App: config.AppConfig{ + Host: "http://localhost", + }, + } + + assert.Equal(t, "http://localhost/abc/123", r.Url("test", 123)) + assert.Equal(t, "/abc/123", r.Path("test", 123)) +} + +func TestRequest_Render(t *testing.T) { + e := echo.New() + layout := func(r *Request, n gomponents.Node) gomponents.Node { + return html.Div(html.Class("test"), n) + } + node := html.P(gomponents.Text("hello")) + + t.Run("no htmx", func(t *testing.T) { + ctx, rec := tests.NewContext(e, "/") + r := NewRequest(ctx) + r.Htmx = &htmx.Request{} + err := r.Render(layout, node) + require.NoError(t, err) + assert.Equal(t, `

hello

`, rec.Body.String()) + }) + + t.Run("htmx", func(t *testing.T) { + ctx, rec := tests.NewContext(e, "/") + r := NewRequest(ctx) + r.Htmx = &htmx.Request{ + Enabled: true, + Boosted: false, + } + err := r.Render(layout, node) + require.NoError(t, err) + assert.Equal(t, `

hello

`, rec.Body.String()) + }) +} diff --git a/pkg/ui/utils.go b/pkg/ui/ui.go similarity index 85% rename from pkg/ui/utils.go rename to pkg/ui/ui.go index 7fe0339d..e982a9b2 100644 --- a/pkg/ui/utils.go +++ b/pkg/ui/ui.go @@ -7,9 +7,6 @@ import ( "github.com/mikestefanello/pagoda/config" ) -// AppName is the name of the application. -const AppName = "Pagoda" - var ( // cacheBuster stores the current time as a cache buster for static files. cacheBuster = fmt.Sprint(time.Now().Unix()) diff --git a/pkg/ui/ui_test.go b/pkg/ui/ui_test.go new file mode 100644 index 00000000..bffeead7 --- /dev/null +++ b/pkg/ui/ui_test.go @@ -0,0 +1,16 @@ +package ui + +import ( + "fmt" + "testing" + + "github.com/mikestefanello/pagoda/config" + "github.com/stretchr/testify/assert" +) + +func TestFile(t *testing.T) { + path := "abc.txt" + got := File(path) + expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster) + assert.Equal(t, expected, got) +} From 3fb59481798c18e134c415653a3edd68a93eb353 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Sun, 2 Mar 2025 20:57:36 -0500 Subject: [PATCH 23/30] Updated the readme. --- README.md | 503 ++++++++++++++++++++---------------------------------- 1 file changed, 188 insertions(+), 315 deletions(-) diff --git a/README.md b/README.md index 6f95fc8d..e7e6fa64 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ * [Getting started](#getting-started) * [Dependencies](#dependencies) * [Start the application](#start-the-application) - * [Running tests](#running-tests) + * [Live reloading](#live-reloading) * [Service container](#service-container) * [Dependency injection](#dependency-injection) * [Test dependencies](#test-dependencies) @@ -46,34 +46,30 @@ * [Custom middleware](#custom-middleware) * [Handlers](#handlers) * [Errors](#errors) + * [Redirects](#redirects) * [Testing](#testing) * [HTTP server](#http-server) * [Request / Request helpers](#request--response-helpers) * [Goquery](#goquery) -* [Pages](#pages) - * [Flash messaging](#flash-messaging) - * [Pager](#pager) - * [CSRF](#csrf) - * [Automatic template parsing](#automatic-template-parsing) - * [Cached responses](#cached-responses) - * [Cache tags](#cache-tags) - * [Cache middleware](#cache-middleware) - * [Data](#data) +* [User interface](#user-interface) + * [Why Gomponents?](#why-gomponents) + * [Request](#request) + * [Title and metatags](#title-and-metatags) + * [URL generation](#url-generation) + * [HTMX support and rendering](#htmx-support-and-rendering) + * [Components](#components) + * [Layouts](#layouts) + * [Pages](#pages) + * [Rendering the page](#rendering-the-page) * [Forms](#forms) * [Submission processing](#submission-processing) * [Inline validation](#inline-validation) - * [Headers](#headers) - * [Status code](#status-code) - * [Metatags](#metatags) - * [URL and link generation](#url-and-link-generation) + * [CSRF](#csrf) + * [Models](#models) * [HTMX support](#htmx-support) - * [Rendering the page](#rendering-the-page) -* [Template renderer](#template-renderer) - * [Custom functions](#custom-functions) - * [Caching](#caching) - * [Hot-reload for development](#hot-reload-for-development) - * [File configuration](#file-configuration) -* [Funcmap](#funcmap) + * [Node caching](#node-caching) + * [Flash messaging](#flash-messaging) +* [Pager](#pager) * [Cache](#cache) * [Set data](#set-data) * [Get data](#get-data) @@ -101,7 +97,7 @@ _Pagoda_ is not a framework but rather a base starter-kit for rapid, easy full-s Built on a solid [foundation](#foundation) of well-established frameworks and modules, _Pagoda_ aims to be a starting point for any web application with the benefit over a mega-framework in that you have full control over all of the code, the ability to easily swap any frameworks or modules in or out, no strict patterns or interfaces to follow, and no fear of lock-in. -While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ templates can be powerful and easy to work with, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS at all. +While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ alone can be powerful and easy to work with as a full-stack solution, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS or CSS at all. In fact, you can even avoid writing HTML as well. ### Foundation @@ -111,6 +107,7 @@ While many great projects were used to build this, all of which are listed in th - [Echo](https://echo.labstack.com/): High performance, extensible, minimalist Go web framework. - [Ent](https://entgo.io/): Simple, yet powerful ORM for modeling and querying data. +- [Gomponents](https://github.com/maragudk/gomponents): HTML components written in pure Go. They render to HTML 5, and make it easy for you to build reusable components. #### Frontend @@ -162,27 +159,26 @@ By default, you should be able to access the application in your browser at `loc By default, your data will be stored within the `dbs` directory. If you ever want to quickly delete all data just remove this directory. -### Running tests +### Live reloading -To run all tests in the application, execute `make test`. This ensures that the tests from each package are not run in parallel. This is required since many packages contain tests that connect to the test database which is stored in memory and reset automatically for each package. +Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and run whenever you make changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading. ## Service container The container is located at `pkg/services/container.go` and is meant to house all of your application's services and/or dependencies. It is easily extensible and can be created and initialized in a single call. The services currently included in the container are: -- Configuration +- Authentication - Cache +- Configuration - Database -- ORM -- Web -- Validator -- Authentication +- Files - Mail -- Template renderer +- ORM - Tasks -- Files +- Validator +- Web -A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`. +A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`, which will attempt to gracefully shutdown all services. ### Dependency injection @@ -196,7 +192,7 @@ It is common that your tests will require access to dependencies, like the datab The `config` package provides a flexible, extensible way to store all configuration for the application. Configuration is added to the `Container` as a _Service_, making it accessible across most of the application. -Be sure to review and adjust all of the default configuration values provided in `config/config.yaml`. +Be sure to review and adjust all the default configuration values provided in `config/config.yaml`. ### Environment overrides @@ -394,7 +390,6 @@ For this example, we'll create a new handler which includes a GET and POST route ```go type Example struct { orm *ent.Client - *services.TemplateRenderer } ``` @@ -410,7 +405,6 @@ func init() { ```go func (e *Example) Init(c *services.Container) error { - e.TemplateRenderer = c.TemplateRenderer e.orm = c.ORM return nil } @@ -418,12 +412,12 @@ func (e *Example) Init(c *services.Container) error { 4) Declare the routes -**It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. +**It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. All route names are currently stored as consts in the `routenames` package so they are accessible from within the `ui` layer. ```go func (e *Example) Routes(g *echo.Group) { - g.GET("/example", e.Page).Name = "example" - g.POST("/example", c.PageSubmit).Name = "example.submit" + g.GET("/example", e.Page).Name = routenames.Example + g.POST("/example", c.PageSubmit).Name = routenames.ExampleSubmit } ``` @@ -441,9 +435,9 @@ func (e *Example) PageSubmit(ctx echo.Context) error { ### Errors -Routes can return errors to indicate that something wrong happened. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code. You can use `return echo.NewHTTPError(http.StatusInternalServerError)`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed. +Routes can return errors to indicate that something wrong happened and an error page should be rendered for the request. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code, and optionally a message that will be logged. You can use `return echo.NewHTTPError(http.StatusInternalServerError, "optional message")`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed. -The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.gohtml`. The status code is passed to the template so you can easily alter the markup depending on the error type. +The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route passes the status code to the `page.Error` UI component, allowing you to easily adjust the markup depending on the error type. ### Redirects @@ -469,7 +463,7 @@ Only a brief example of route tests were provided in order to highlight what is #### HTTP server -When the route tests initialize, a new `Container` is created which provides full access to all of the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses. +When the route tests initialize, a new `Container` is created which provides full access to all the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses. #### Request / Response helpers @@ -501,160 +495,165 @@ assert.Len(t, h1.Nodes, 1) assert.Equal(t, "About", h1.Text()) ``` -## Pages +## User interface -The `Page` is the major building block of your `Handler` responses. It is a _struct_ type located at `pkg/page/page.go`. The concept of the `Page` is that it provides a consistent structure for building responses and transmitting data and functionality to the templates. Pages are rendered with the `TemplateRenderer`. +todo -All example routes provided construct and _render_ a `Page`. It's recommended that you review both the `Page` and the example routes as they try to illustrate all included functionality. +### Why Gomponents? -As you develop your application, the `Page` can be easily extended to include whatever data or functions you want to provide to your templates. +Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_ which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough easily provide re-usable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more. -Initializing a new page is simple: +While I was extremely hesitant to adopt a rendering option outside the standard library, if an option exists that I personally feel is far superior, that is what I'm going to go with. [Templ](https://github.com/a-h/templ) was also a consideration as that project has made massive progress, seen an explosion in adoption, and aims to solve all the problems previously mentioned. I did not feel that it was a good fit for this project though as it requires you to know and understand their templating language, to install a CLI and an IDE plugin (which does not work with all IDEs; especially GoLand), and separately compile template code. -```go -func (c *home) Get(ctx echo.Context) error { - p := page.New(ctx) -} -``` +[Gomponents](https://github.com/maragudk/gomponents) allows you to build HTML using nothing except pure, type-safe Go; whether that's entire documents or dynamic, reusable components. [Here](https://www.gomponents.com/) are some basic examples to give you an idea of how it works and [this tool](https://gomponents.morehart.dev/) is incredibly useful for quickly converting HTML to _gomponent_ Go code. When I first came across this library, I was very much against it, and couldn't imagine writing tons of nested function calls just to produce some HTML; especially for complex markup. But after actually spending some time using it to replicate the UI of this project, and feeling the downsides of Go templates, I quickly became a big fan and supporter of this approach. Between this and the chosen JS/CSS libraries, you can literally write your entire frontend without leaving Go. + +Before making any quick judgements of your own, I ask that you deeply consider what you've used in the past, review what previously existed in this project, and compare to the current solution and code presented here. I believe I've laid out the `ui` package in a way that makes building your frontend with _gomponents_ very easy and enjoyable. + +### Request -Using the `echo.Context`, the `Page` will be initialized with the following fields populated: +The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires. -- `Context`: The passed in _context_ -- `Path`: The requested URL path -- `URL`: The requested URL -- `StatusCode`: Defaults to 200 -- `Pager`: Initialized `Pager` (see below) -- `RequestID`: The request ID, if the middleware is being used +`NewRequest()` will automatically populate the following fields using the `echo.Context` from the current request: + +- `Context`: The provided _echo.Context_ +- `CurrentPath`: The requested URL path - `IsHome`: If the request was for the homepage - `IsAuth`: If the user is authenticated -- `AuthUser`: The logged in user entity, if one +- `AuthUser`: The logged-in user entity, if one - `CSRF`: The CSRF token, if the middleware is being used -- `HTMX.Request`: Data from the HTMX headers, if HTMX made the request (see below) - -### Flash messaging +- `Htmx`: Data from the HTMX headers, if HTMX made the request +- `Config`: The application configuration, if the middleware is being used -Flash messaging functionality is provided within the `msg` package. It is used to provide one-time status messages to users. +#### Title and metatags -Flash messaging requires that [sessions](#sessions) and the session middleware are in place since that is where the messages are stored. +The `Request` type has additional fields to make it easy to set static values within components being rendered on a given page. While the _title_ is always important, the others are provided as an example: -#### Creating messages +* `Title`: The page title +* `Metatags`: + * `Description`: The description of the page + * `Tags`: A slice of keyword tags -There are four types of messages, and each can be created as follows: -- Success: `msg.Success(ctx echo.Context, message string)` -- Info: `msg.Info(ctx echo.Context, message string)` -- Warning: `msg.Warning(ctx echo.Context, message string)` -- Danger: `msg.Danger(ctx echo.Context, message string)` +#### URL generation -The _message_ string can contain HTML. +As mentioned in the [Routes](#routes) section, it is recommended, though not required, to provide names for each of your routes. These are currently defined as consts in the `routenames` package. If you use names for your routes, you can leverage the URL generation methods on the `Request`. This allows you to prevent hard-coding your route paths and parameters in multiple places. -#### Rendering messages +The methods both take a route name and optional variadic route parameters: -When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again. +* `Path`: Generates a relative path for a given route. +* `Url`: Generates an absolute URL for a given route. This uses the `App.Host` field in your [configuration](#configuration) to determine the host of the URL. -The `Page` has a method that can be used to fetch messages for a given type from within the template: `Page.GetMessages(typ msg.Type)`. This is used rather than the _funcmap_ because the `Page` contains the request context which is required in order to access the session data. Since the `Page` is the data destined for the templates, you can use: `{{.GetMessages "success"}}` for example. +#### HTMX support and rendering -To make things easier, a template _component_ is already provided, located at `templates/components/messages.gohtml`. This will render all messages of all types simply by using `{{template "messages" .}}` either within your page or layout template. +While using HTMX is completely optional, tools are provided to make working with it as easy as possible and there are examples of it all throughout this project. -### Pager +As mentioned, the `Request` automatically contains an `htmx.Request` object which contains values from all the HTMX headers. This allows your frontend code, especially [pages](#pages), to dynamically react to which partial section of the page is being requested, for example. -A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. +The `Request` type also contains a `Render()` method which automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. -During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations. - -Methods include: +See the [pages](#pages) section for more details or view the code in the `pages` package. -- `SetItems(items int)`: Set the total amount of items in the entire result-set -- `IsBeginning()`: Determine if the pager is at the beginning of the pages -- `IsEnd()`: Determine if the pager is at the end of the pages -- `GetOffset()`: Get the offset which can be useful is constructing a paged database query - -There is currently no template (yet) to easily render a pager. - -### CSRF - -By default, all non GET requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router. - -The `Page` will contain the CSRF token for the given request. There is a CSRF helper component template which can be used to easily render a hidden form element in your form which will contain the CSRF token and the proper element name. Simply include `{{template "csrf" .}}` within your form. - -### Automatic template parsing - -Dealing with templates can be quite tedious and annoying so the `Page` aims to make it as simple as possible with the help of the [template renderer](#template-renderer). To start, templates for _pages_ are grouped in the following directories within the `templates` directory: - -- `layouts`: Base templates that provide the entire HTML wrapper/layout. This template should include a call to `{{template "content" .}}` to render the content of the `Page`. -- `pages`: Templates that are specific for a given route/page. These must contain `{{define "content"}}{{end}}` which will be injected in to the _layout_ template. -- `components`: A shared library of common components that the layout and base template can leverage. - -Specifying which templates to render for a given `Page` is as easy as: +**Example:** ```go -page.Name = "home" -page.Layout = "main" +g.GET("/user/:uid", profilePage).Name = routenames.Profile ``` -That alone will result in the following templates being parsed and executed when the `Page` is rendered: - -1) `layouts/main.gohtml` as the base template -2) `pages/home.gohtml` to provide the `content` template for the layout -3) All template files located within the `components` directory -4) The entire [funcmap](#funcmap) - -The [template renderer](#template-renderer) also provides caching and local hot-reloading. +```go +func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node { + return A( + Class("profile"), + Href(r.Path(routenames.Profile, userID)), + Text(userName), + ) +} +``` -### Cached responses +### Components -A `Page` can have cached enabled just by setting `Page.Cache.Enabled` to `true`. The `TemplateRenderer` will automatically handle caching the HTML output, headers and status code. Cached pages are stored using a key that matches the full request URL and [middleware](#cache-middleware) is used to serve it on matching requests. +The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation). -By default, the cache expiration time will be set according to the configuration value located at `Config.Cache.Expiration.Page` but it can be set per-page at `Page.Cache.Expiration`. +### Layouts -#### Cache tags +Layouts are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating to the login, register, and forgot password pages. -You can optionally specify cache tags for the `Page` by setting a slice of strings on `Page.Cache.Tags`. This provides the ability to build in cache invalidation logic in your application driven by events such as entity operations, for example. +### Pages -You can use the [cache client](#cache) on the `Container` to easily [flush cache tags](#flush-tags), if needed. +todo -#### Cache middleware + * [Rendering the page](#rendering-the-page) -Cached pages are served via the middleware `ServeCachedPage()` in the `middleware` package. +### Forms -The cache is bypassed if the requests meet any of the following criteria: -1) Is not a GET request -2) Is made by an authenticated user +Building, rendering, validating and processing forms is made extremely easy with [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator), [form.Submission](https://github.com/mikestefanello/pagoda/blob/templates/pkg/form/submission.go), and the provided _gomponent_ [components](#components). -Cached pages are looked up for a key that matches the exact, full URL of the given request. +Start by declaring the form within the [forms](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/forms) package: -### Data +```go +type Guestbook struct { + Message string `form:"message" validate:"required"` + form.Submission +} +``` -The `Data` field on the `Page` is of type `any` and is what allows your route to pass whatever it requires to the templates, alongside the `Page` itself. +Embedding `form.Submission` satisfies the `form.Form` interface and handles submissions and validation for you. -### Forms +Next, provide a method that renders the form: -The `Form` field on the `Page` is similar to the `Data` field, but it's meant to store a struct that represents a form being rendered on the page. +```go +func (f *Guestbook) Render(r *ui.Request) Node { + return Form( + ID("contact"), + Method(http.MethodPost), + Attr("hx-post", r.Path(routenames.GuestbookSubmit)), + TextareaField(TextareaFieldParams{ + Form: f, + FormField: "Message", + Name: "message", + Label: "Message", + Value: f.Message, + }), + ControlGroup( + FormButton("is-link", "Submit"), + ), + CSRF(r), + ) +} +``` -An example of this pattern is: +Then, create a _page_ that includes your form: ```go -type ContactForm struct { - Email string `form:"email" validate:"required,email"` - Message string `form:"message" validate:"required"` - form.Submission +func UserGuestbook(ctx echo.Context, form *forms.Guestbook) error { + r := ui.NewRequest(ctx) + + content := Div( + Class("guestbook"), + H1(Text("My guestbook")), + P(Text("Hi, please sign my guestbook!")), + form.Render(r) + ) + + return r.Render(layouts.Primary, content) } ``` -Embedding `form.Submission` satisfies the `form.Form` interface and makes dealing with submissions and validation extremely easy. - -Then in your page: +And last, have your handler render the _page_ in a route, and provide a route for the submission. ```go -p := page.New(ctx) -p.Form = form.Get[ContactForm](ctx) +func (e *Example) Routes(g *echo.Group) { + g.GET("/guestbook", e.Page).Name = routenames.Guestbook + g.POST("/guestbook", c.PageSubmit).Name = routenames.GuestbookSubmit +} + +func (e *Example) Page(ctx echo.Context) error { + return pages.UserGuestbook(ctx, form.Get[forms.Guestbook](ctx)) +} ``` -This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section. +`form.Get` will either initialize a new form, or load one previously stored in the context (ie, if it was already submitted). #### Submission processing -Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `Submission` struct located in `pkg/form/submission.go`. - Using the example form above, this is all you would have to do within the _POST_ callback for your route: Start by submitting the form along with the request context. This will: @@ -663,116 +662,84 @@ Start by submitting the form along with the request context. This will: 3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags. ```go -var input ContactForm - -err := form.Submit(ctx, &input) -``` - -Check the error returned, and act accordingly. For example: -```go -switch err.(type) { -case nil: - // All good! -case validator.ValidationErrors: - // The form input was not valid, so re-render the form - return c.Page(ctx) -default: - // Request failed, show the error page - return err +func (e *Example) Submit(ctx echo.Context) error { + var input forms.Guestbook + + // Submit the form. + err := form.Submit(ctx, &input) + + // Check the error returned, and act accordingly. + switch err.(type) { + case nil: + // All good! + case validator.ValidationErrors: + // The form input was not valid, so re-render the form. + return e.Page(ctx) + default: + // Request failed, show the error page. + return err + } } ``` -And finally, your template: -```html -
- - -
-``` +By default, all non `GET` requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router. -Second, render the error messages, if there are any for a given field: -```go -{{template "field-errors" (.Form.GetFieldErrors "Email")}} -``` +The `Request` automatically extracts the CSRF token from the context, but you must include it in your forms by using the provided `CSRF()` [component](#components) as shown in the example above. -### Headers +### Models -HTTP headers can be set either via the `Page` or the _context_: +todo -```go -p := page.New(ctx) -p.Headers["HeaderName"] = "header-value" -``` +### HTMX support -```go -ctx.Response().Header().Set("HeaderName", "header-value") -``` +todo -### Status code +### Node caching -The HTTP response status code can be set either via the `Page` or the _context_: +todo -```go -p := page.New(ctx) -p.StatusCode = http.StatusTooManyRequests -``` +### Flash messaging -```go -ctx.Response().Status = http.StatusTooManyRequests -``` +Flash messaging functionality is provided within the `msg` package. It is used to provide one-time status messages to users. -### Metatags +Flash messaging requires that [sessions](#sessions) and the session middleware are in place since that is where the messages are stored. -The `Page` provides the ability to set basic HTML metatags which can be especially useful if your web application is publicly accessible. Only fields for the _description_ and _keywords_ are provided but adding additional fields is very easy. +#### Creating messages -```go -p := page.New(ctx) -p.Metatags.Description = "The page description." -p.Metatags.Keywords = []string{"Go", "Software"} -``` +There are four types of messages, and each can be created as follows: +- Success: `msg.Success(ctx echo.Context, message string)` +- Info: `msg.Info(ctx echo.Context, message string)` +- Warning: `msg.Warning(ctx echo.Context, message string)` +- Danger: `msg.Danger(ctx echo.Context, message string)` -A _component_ template is included to render metatags in `core.gohtml` which can be used by adding `{{template "metatags" .}}` to your _layout_. +#### Rendering messages -### URL and link generation +When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again. -Generating URLs in the templates is made easy if you follow the [routing patterns](#patterns) and provide names for your routes. Echo provides a `Reverse` function to generate a route URL with a given route name and optional parameters. This function is made accessible to the templates via _funcmap_ function `url`. +A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI. -As an example, if you have route such as: -```go -e.GET("/user/profile/:user", handler.Get).Name = "user_profile" -``` +### Pager -And you want to generate a URL in the template, you can: -```go -{{url "user_profile" 1} -``` +A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. -Which will generate: `/user/profile/1` +During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations. -There is also a helper function provided in the [funcmap](#funcmap) to generate links which has the benefit of adding an _active_ class if the link URL matches the current path. This is especially useful for navigation menus. +Methods include: -```go -{{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}} -``` +- `SetItems(items int)`: Set the total amount of items in the entire result-set +- `IsBeginning()`: Determine if the pager is at the beginning of the pages +- `IsEnd()`: Determine if the pager is at the end of the pages +- `GetOffset()`: Get the offset which can be useful is constructing a paged database query -Will generate: -```html -Profile -``` -Assuming the current _path_ is `/user/profile/1`; otherwise the `is-active` class will be excluded. +There is currently no template (yet) to easily render a pager. ### HTMX support @@ -820,100 +787,6 @@ if page.HTMX.Request.Target == "search" { If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `footer` template by leveraging HTMX [events](https://htmx.org/reference/#events). -### Rendering the page - -Once your `Page` is fully built, rendering it via the embedded `TemplateRenderer` in your _handler_ can be done simply by calling `RenderPage()`: - -```go -func (c *home) Get(ctx echo.Context) error { - p := page.New(ctx) - p.Layout = templates.LayoutMain - p.Name = templates.PageHome - return c.RenderPage(ctx, p) -} -``` - -## Template renderer - -The _template renderer_ is a _Service_ on the `Container` that aims to make template parsing and rendering easy and flexible. It is the mechanism that allows the `Page` to do [automatic template parsing](#automatic-template-parsing). The standard `html/template` is still the engine used behind the scenes. The code can be found in `pkg/services/template_renderer.go`. - -Here is an example of a complex rendering that uses multiple template files as well as an entire directory of template files: - -```go -buf, err = c.TemplateRenderer. - Parse(). - Group("page"). - Key("home"). - Base("main"). - Files("layouts/main", "pages/home"). - Directories("components"). - Execute(data) -``` - -This will do the following: -- [Cache](#caching) the parsed template with a _group_ of `page` and _key_ of `home` so this parse only happens once -- Set the _base template file_ as `main` -- Include the templates `templates/layout/main.gohtml` and `templates/pages/home.gohtml` -- Include all templates located within the directory `templates/components` -- Include the [funcmap](#funcmap) -- Execute the parsed template with `data` being passed in to the templates - -Using the example from the [page rendering](#rendering-the-page), this is will execute: - -```go -buf, err = c.TemplateRenderer. - Parse(). - Group("page"). - Key(page.Name). - Base(page.Layout). - Files( - fmt.Sprintf("layouts/%s", page.Layout), - fmt.Sprintf("pages/%s", page.Name), - ). - Directories("components"). - Execute(page) -``` - -If you have a need to _separately_ parse and cache the templates then later execute, you can separate the operations: - -```go -_, err := c.TemplateRenderer. - Parse(). - Group("my-group"). - Key("my-key"). - Base("auth"). - Files("layouts/auth", "pages/login"). - Directories("components"). - Store() -``` - -```go -tpl, err := c.TemplateRenderer.Load("my-group", "my-key") -buf, err := tpl.Execute(data) -``` - -### Custom functions - -All templates will be parsed with the [funcmap](#funcmap) so all of your custom functions as well as the functions provided by [sprig](https://github.com/Masterminds/sprig) will be available. - -### Caching - -Parsed templates will be cached within a `sync.Map` so the operation will only happen once per cache _group_ and _ID_. Be careful with your cache _group_ and _ID_ parameters to avoid collisions. - -### Hot-reload for development - -If the current [environment](#environments) is set to `config.EnvLocal`, which is the default, the cache will be bypassed and templates will be parsed every time they are requested. This allows you to have hot-reloading without having to restart the application so you can see your HTML changes in the browser immediately. - -### File configuration - -To make things easier and less repetitive, parameters given to the _template renderer_ must not include the `templates` directory or the template file extensions. The file extension is stored as a constant (`TemplateExt`) within the `config` package. - -## Funcmap - -The `funcmap` package provides a _function map_ (`template.FuncMap`) which will be included for all templates rendered with the [template renderer](#template-renderer). Aside from a few custom functions, [sprig](https://github.com/Masterminds/sprig) is included which provides over 100 commonly used template functions. The full list is available [here](http://masterminds.github.io/sprig/). - -To include additional custom functions, add to the map in `NewFuncMap()` and define the function in the package. It will then become automatically available in all templates. - ## Cache As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. From 7c911cd53d6adf8ffd5335dfca43f6e406155e6f Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Mon, 3 Mar 2025 21:04:02 -0500 Subject: [PATCH 24/30] Updated readme. Fixed component cache examples. --- README.md | 196 +++++++++++++++++++++----------------- pkg/pager/pager.go | 9 +- pkg/ui/layouts/auth.go | 9 +- pkg/ui/layouts/primary.go | 14 ++- 4 files changed, 129 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index e7e6fa64..7d37a6c4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ * [Goquery](#goquery) * [User interface](#user-interface) * [Why Gomponents?](#why-gomponents) + * [HTMX support](#htmx-support) + * [Header management](#header-management) + * [Conditional and partial rendering](#conditional-and-partial-rendering) + * [CSRF token](#csrf-token) * [Request](#request) * [Title and metatags](#title-and-metatags) * [URL generation](#url-generation) @@ -66,7 +70,6 @@ * [Inline validation](#inline-validation) * [CSRF](#csrf) * [Models](#models) - * [HTMX support](#htmx-support) * [Node caching](#node-caching) * [Flash messaging](#flash-messaging) * [Pager](#pager) @@ -501,7 +504,7 @@ todo ### Why Gomponents? -Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_ which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough easily provide re-usable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more. +Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_ which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough to easily provide reusable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more. While I was extremely hesitant to adopt a rendering option outside the standard library, if an option exists that I personally feel is far superior, that is what I'm going to go with. [Templ](https://github.com/a-h/templ) was also a consideration as that project has made massive progress, seen an explosion in adoption, and aims to solve all the problems previously mentioned. I did not feel that it was a good fit for this project though as it requires you to know and understand their templating language, to install a CLI and an IDE plugin (which does not work with all IDEs; especially GoLand), and separately compile template code. @@ -509,6 +512,46 @@ While I was extremely hesitant to adopt a rendering option outside the standard Before making any quick judgements of your own, I ask that you deeply consider what you've used in the past, review what previously existed in this project, and compare to the current solution and code presented here. I believe I've laid out the `ui` package in a way that makes building your frontend with _gomponents_ very easy and enjoyable. +### HTMX support + +[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext. + +Many examples of its usage are available in the included examples: +- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience. +- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX. +- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI. +- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX. + +All of this can be easily accomplished without writing any JavaScript at all. + +Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend, though many things are provided here to make it simple. + +#### Header management + +Included is an [htmx package](https://github.com/mikestefanello/pagoda/blob/main/pkg/htmx/htmx.go) to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands for both the request and response. This allows you, for example, to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ and your _ui_. + +From within your _route_, you can fetch HTMX request details by calling `htmx.GetRequest(ctx)`, and you can send commands back to HTMX by calling `htmx.Response{...}.Apply(ctx)`, and populating any fields on the `htmx.Response` struct. + +From within your _ui_, the [Request](#request) object will automatically contain the request details on the `Htmx` field. + +#### Conditional and partial rendering + +Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _ui_, if needed. + +The most important case to support is _partial_ rendering. If HTMX is making a request, unless it is [boosted](https://htmx.org/docs/#boosting), you only want to render the _content_ of your _route_, and not the entire [layout](#layouts). This is automatically handled by the `Render()` method on the [Request](#request) type. More can be read about that [here](#rendering). + +If your routes aren't doing multiple things, you may not need _conditional_ rendering, but it's worth knowing how flexible you can be. A simple example of this: + +```go +if htmx.GetRequest(ctx).Target == "search" { + // This request is HTMX fetching content just for the #search element +} +``` + +#### CSRF token + +If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events). + ### Request The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires. @@ -539,18 +582,8 @@ As mentioned in the [Routes](#routes) section, it is recommended, though not req The methods both take a route name and optional variadic route parameters: -* `Path`: Generates a relative path for a given route. -* `Url`: Generates an absolute URL for a given route. This uses the `App.Host` field in your [configuration](#configuration) to determine the host of the URL. - -#### HTMX support and rendering - -While using HTMX is completely optional, tools are provided to make working with it as easy as possible and there are examples of it all throughout this project. - -As mentioned, the `Request` automatically contains an `htmx.Request` object which contains values from all the HTMX headers. This allows your frontend code, especially [pages](#pages), to dynamically react to which partial section of the page is being requested, for example. - -The `Request` type also contains a `Render()` method which automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. - -See the [pages](#pages) section for more details or view the code in the `pages` package. +* `Path()`: Generates a relative path for a given route. +* `Url()`: Generates an absolute URL for a given route. This uses the `App.Host` field in your [configuration](#configuration) to determine the host of the URL. **Example:** @@ -570,7 +603,9 @@ func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node { ### Components -The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation). +The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation). + +Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements. ### Layouts @@ -580,7 +615,24 @@ Layouts are full HTML templates that are used by [pages](#pages) to inject thems todo - * [Rendering the page](#rendering-the-page) +#### Rendering + +While using HTMX is completely optional, tools are provided to make working with it as easy as possible and there are examples of it all throughout this project. + +As mentioned, the `Request` automatically contains an `htmx.Request` object which contains values from all the HTMX headers. This allows your frontend code, especially [pages](#pages), to dynamically react to which partial section of the page is being requested, for example. + +The `Request` type also contains a `Render()` method which automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. This is accomplished by passing in your layout and _page_ separately, for example: + +```go +func MyPage(ctx echo.Context) error { + r := ui.NewRequest(ctx) + r.Title = "My page" + node := Div(Text("Hello world!")) + return r.Render(layouts.Primary, node) +} +``` + +Using `Render()`, in this example, only `node` will render if HTMX made the request in a non-boosted fashion, otherwise `node` will render within `layouts.Primary`. ### Forms @@ -625,10 +677,11 @@ Then, create a _page_ that includes your form: ```go func UserGuestbook(ctx echo.Context, form *forms.Guestbook) error { r := ui.NewRequest(ctx) + r.Title = "User page" content := Div( Class("guestbook"), - H1(Text("My guestbook")), + H2(Text("My guestbook")), P(Text("Hi, please sign my guestbook!")), form.Render(r) ) @@ -696,15 +749,31 @@ The `Request` automatically extracts the CSRF token from the context, but you mu ### Models -todo +Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example. -### HTMX support +### Node caching -todo +While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponent.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change. -### Node caching +A good example of this, and one included, is the entire upper navigation bar, search form, and search modal in the _Primary_ layout. It contains a large amount of nested _gomponent_ function calls and a lot of rendering is required. There is no reason to do this more than once. -todo +The cache functions are available in `pkg/ui/cache` and can be used like this: + +```go +func SearchModal() gomponent.Node { + const cacheKey = "searchModal" + if n := cache.Get(cacheKey); n != nil { + return n + } + + n := Div(...your entire nested node...) + cache.Set(cacheKey, n) + return n +} +``` +`cache.Get()` does more than just cache the `Node` in-memory. It renders the entire `Node` into a `bytes.Buffer`, then stores a `Raw()` `Node` using the rendered content. This means that everytime the `Node` is taken from the cache and rendered, the pre-rendered `string` is used rather than having to iterate through the nested component, executing all of the element functions and rendering and building the entire HTML output. + +It's worth noting that my benchmarking was very limited and cannot be considered anything definitive. In my tests, gomponents was faster, allocated less overall, but had more allocations in total. If you're able to cache static nodes, gomponents can perform significantly better. Reiterating, for most applications, these differences in nanoseconds and bytes will most likely be completely insignificant and unnoticed; but it's worth being aware of. ### Flash messaging @@ -726,11 +795,9 @@ When a flash message is retrieved from storage in order to be rendered, it is de A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI. -### Pager +## Pager -A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. - -During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations. +A very basic mechanism is provided to handle and facilitate paging located in `pkg/pager` and can be initialized via `pager.NewPager()`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. This query can be controlled via the `QueryKey` constant. Methods include: @@ -739,59 +806,13 @@ Methods include: - `IsEnd()`: Determine if the pager is at the end of the pages - `GetOffset()`: Get the offset which can be useful is constructing a paged database query -There is currently no template (yet) to easily render a pager. - -### HTMX support - -[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext. - -Many examples of its usage are available in the included examples: -- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience. -- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX. -- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI. -- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX. - -All of this can be easily accomplished without writing any JavaScript at all. - -Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend. But to make things easier, included is a small package to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands. - -The `htmx` package contains the headers for the _request_ and _response_. When a `Page` is initialized, `Page.HTMX.Request` will also be initialized and populated with the headers that HTMX provides, if HTMX made the request. This allows you to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ as well as your _templates_. - -If you need to set any HTMX headers in your `Page` response, this can be done by altering `Page.HTMX.Response`. - -#### Layout template override - -To facilitate easy partial rendering for HTMX requests, the `Page` will automatically change your _Layout_ template to use `htmx.gohtml`, which currently only renders `{{template "content" .}}`. This allows you to use an HTMX request to only update the content portion of the page, rather than the entire HTML. - -This override only happens if the HTMX request being made is **not a boost** request because **boost** requests replace the entire `body` element so there is no need to do a partial render. - -#### Conditional processing / rendering - -Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _template_, if needed. If your routes aren't doing multiple things, you may not need this, but it's worth knowing how flexible you can be. - -A simple example of this: - -```go -if page.HTMX.Request.Target == "search" { - // You know this request HTMX is fetching content just for the #search element -} -``` - -```go -{{if eq .HTMX.Request.Target "search"}} - // Render content for the #search element -{{end}} -``` - -#### CSRF token - -If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `footer` template by leveraging HTMX [events](https://htmx.org/reference/#events). +There is currently no generic component to easily render a pager, but the homepage does have an example. ## Cache As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. -The built-in usage of the cache is currently only for optional [page caching](#cached-responses) and a simple example route located at `/cache` where you can set and view the value of a given cache entry. +The built-in usage of the cache is currently only for used for a simple example route located at `/cache` where you can set and view the value of a given cache entry. Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in. @@ -917,25 +938,27 @@ The cache max-life is controlled by the configuration at `Config.Cache.Expiratio ### Cache-buster -While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function is provided in the [funcmap](#funcmap) to generate a static file URL for a given file that appends a cache-buster query. This query string is randomly generated and persisted until the application restarts. +While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string generated using the timestamp of when the app started and persists until the application restarts. For example, to render a file located in `static/picture.png`, you would use: -```html - +```go +return Img(Src(ui.File("picture.png"))) ``` Which would result in: ```html - + ``` -Where `9fhe73kaf3` is the randomly-generated cache-buster. +Where `1741053493` is the cache-buster. ## Email -An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is because there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications. +An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications. + +The structure in the client (`MailClient`) makes composing emails very easy, and you have the option to construct the body using either a simple string or with a renderable _gomponent_, as explained in the [user interface](#user-interface), in order to produce HTML emails. A simple example is provided in `pkg/ui/emails`. -The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`. +The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`. The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address. @@ -952,19 +975,18 @@ err = c.Mail. Send(ctx) ``` -**Sending with a template body**: +**Sending an HTML body using a gomponent**: ```go err = c.Mail. Compose(). To("hello@example.com"). - Subject("Welcome!"). - Template("welcome"). - TemplateData(templateData). + Subject("Confirm your email address"). + Component(emails.ConfirmEmailAddress(ctx, username, token)). Send(ctx) ``` -This will use the template located at `templates/emails/welcome.gohtml` and pass `templateData` to it. +This will use the HTML provided when rendering the _gomponent_ as the email body. ## HTTPS @@ -1032,7 +1054,7 @@ Future work includes but is not limited to: ## Credits -Thank you to all of the following amazing projects for making this possible. +Thank you to all the following amazing projects for making this possible. - [afero](https://github.com/spf13/afero) - [air](https://github.com/air-verse/air) diff --git a/pkg/pager/pager.go b/pkg/pager/pager.go index f0d939bf..ae4672e0 100644 --- a/pkg/pager/pager.go +++ b/pkg/pager/pager.go @@ -7,13 +7,8 @@ import ( "github.com/labstack/echo/v4" ) -const ( - // DefaultItemsPerPage stores the default amount of items per page. - DefaultItemsPerPage = 20 - - // QueryKey stores the query key used to indicate the current page. - QueryKey = "page" -) +// QueryKey stores the query key used to indicate the current page. +const QueryKey = "page" // Pager provides a mechanism to allow a user to page results via a query parameter. type Pager struct { diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go index 06f608e3..c5f6ee92 100644 --- a/pkg/ui/layouts/auth.go +++ b/pkg/ui/layouts/auth.go @@ -47,10 +47,12 @@ func Auth(r *ui.Request, content Node) Node { } func authNavBar(r *ui.Request) Node { - if n := cache.Get("layout.authNavBar"); n != nil { + const cacheKey = "authNavBar" + if n := cache.Get(cacheKey); n != nil { return n } - return Nav( + + n := Nav( Class("navbar"), Div( Class("navbar-menu"), @@ -62,4 +64,7 @@ func authNavBar(r *ui.Request) Node { ), ), ) + + cache.Set(cacheKey, n) + return n } diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go index 72f40955..4813d0c3 100644 --- a/pkg/ui/layouts/primary.go +++ b/pkg/ui/layouts/primary.go @@ -42,10 +42,12 @@ func Primary(r *ui.Request, content Node) Node { } func headerNavBar(r *ui.Request) Node { - if n := cache.Get("layout.headerNavBar"); n != nil { + const cacheKey = "layout.headerNavBar" + if n := cache.Get(cacheKey); n != nil { return n } - return Nav( + + n := Nav( Class("navbar is-dark"), Div( Class("container"), @@ -68,13 +70,17 @@ func headerNavBar(r *ui.Request) Node { ), ), ) + cache.Set(cacheKey, n) + return n } func search(r *ui.Request) Node { + const cacheKey = "layout.search" if n := cache.Get("layout.search"); n != nil { return n } - return Div( + + n := Div( Class("search mr-2 mt-1"), Attr("x-data", "{modal:false}"), Input( @@ -126,6 +132,8 @@ func search(r *ui.Request) Node { ), ), ) + cache.Set(cacheKey, n) + return n } func sidebarMenu(r *ui.Request) Node { From cd4d9d7af43b707037ed9ac55d5f03a6899b471b Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Tue, 4 Mar 2025 19:46:32 -0500 Subject: [PATCH 25/30] Updated readme. --- README.md | 51 +++++++++++++++++++++++--------------------- pkg/ui/pages/home.go | 2 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7d37a6c4..3e4d4b77 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,10 @@ * [Request](#request) * [Title and metatags](#title-and-metatags) * [URL generation](#url-generation) - * [HTMX support and rendering](#htmx-support-and-rendering) * [Components](#components) * [Layouts](#layouts) * [Pages](#pages) - * [Rendering the page](#rendering-the-page) + * [Rendering](#rendering) * [Forms](#forms) * [Submission processing](#submission-processing) * [Inline validation](#inline-validation) @@ -104,7 +103,7 @@ While separate JavaScript frontends have surged in popularity, many prefer the r ### Foundation -While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **not required to use any of these**. Swapping any of them out will be relatively easy. +While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **not required to use any of these**. Swapping any of them out will be relatively easy. #### Backend @@ -158,13 +157,13 @@ make run Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`. -By default, you should be able to access the application in your browser at `localhost:8000`. This can be changed via the [configuration](#configuration). +By default, you should be able to access the application in your browser at `localhost:8000`. Your data will be stored within the `dbs` directory. If you ever want to quickly delete all data, just remove this directory. -By default, your data will be stored within the `dbs` directory. If you ever want to quickly delete all data just remove this directory. +These settings, and many others, can be changed via the [configuration](#configuration). ### Live reloading -Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and run whenever you make changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading. +Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading. ## Service container @@ -500,11 +499,9 @@ assert.Equal(t, "About", h1.Text()) ## User interface -todo - ### Why Gomponents? -Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_ which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough to easily provide reusable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more. +Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_, which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough to easily provide reusable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more. While I was extremely hesitant to adopt a rendering option outside the standard library, if an option exists that I personally feel is far superior, that is what I'm going to go with. [Templ](https://github.com/a-h/templ) was also a consideration as that project has made massive progress, seen an explosion in adoption, and aims to solve all the problems previously mentioned. I did not feel that it was a good fit for this project though as it requires you to know and understand their templating language, to install a CLI and an IDE plugin (which does not work with all IDEs; especially GoLand), and separately compile template code. @@ -609,31 +606,35 @@ Your components can also make using utility-based CSS libraries, such as [Tailwi ### Layouts -Layouts are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating to the login, register, and forgot password pages. +_Layouts_ are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating to the login, register, and forgot password pages. ### Pages -todo +_Pages_ are what [route handlers](#handlers) ultimately assemble and render. They may accept primitives, [models](#models), [forms](#forms), or nothing at all, and they embed themselves in a [layout](#layouts) of their choice. Each _page_ represents a different page of your web application and many [examples](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/pages) are provided for reference. See below for a minimal example. #### Rendering -While using HTMX is completely optional, tools are provided to make working with it as easy as possible and there are examples of it all throughout this project. - -As mentioned, the `Request` automatically contains an `htmx.Request` object which contains values from all the HTMX headers. This allows your frontend code, especially [pages](#pages), to dynamically react to which partial section of the page is being requested, for example. - -The `Request` type also contains a `Render()` method which automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. This is accomplished by passing in your layout and _page_ separately, for example: +The `Request` type contains a `Render()` method which makes rendering your page within a given layout simple. It automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. Using HTMX is completely optional. This is accomplished by passing in your layout and _page_ separately, for example: ```go -func MyPage(ctx echo.Context) error { +func MyPage(ctx echo.Context, username string) error { r := ui.NewRequest(ctx) r.Title = "My page" - node := Div(Text("Hello world!")) + node := Div(Textf("Hello, %s!", username)) return r.Render(layouts.Primary, node) } ``` Using `Render()`, in this example, only `node` will render if HTMX made the request in a non-boosted fashion, otherwise `node` will render within `layouts.Primary`. +And from within your [route handler](#handlers), simply: + +```go +func (e *ExampleHandler) Page(ctx echo.Context) error { + return pages.MyPage(ctx, "abcd") +} +``` + ### Forms Building, rendering, validating and processing forms is made extremely easy with [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator), [form.Submission](https://github.com/mikestefanello/pagoda/blob/templates/pkg/form/submission.go), and the provided _gomponent_ [components](#components). @@ -732,6 +733,8 @@ func (e *Example) Submit(ctx echo.Context) error { // Request failed, show the error page. return err } + + msg.Success(fmt.Sprintf("Your message was: %s", input.Message)) } ``` @@ -797,22 +800,22 @@ A [component](#components), `FlashMessages()`, is provided to render flash messa ## Pager -A very basic mechanism is provided to handle and facilitate paging located in `pkg/pager` and can be initialized via `pager.NewPager()`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. This query can be controlled via the `QueryKey` constant. +A very basic mechanism is provided to handle and facilitate paging located in `pkg/pager` and can be initialized via `pager.NewPager()`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. This query key can be controlled via the `QueryKey` constant. Methods include: - `SetItems(items int)`: Set the total amount of items in the entire result-set - `IsBeginning()`: Determine if the pager is at the beginning of the pages - `IsEnd()`: Determine if the pager is at the end of the pages -- `GetOffset()`: Get the offset which can be useful is constructing a paged database query +- `GetOffset()`: Get the offset which can be useful in constructing a paged database query There is currently no generic component to easily render a pager, but the homepage does have an example. ## Cache -As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. +As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. -The built-in usage of the cache is currently only for used for a simple example route located at `/cache` where you can set and view the value of a given cache entry. +The built-in usage of the cache is currently only used for a simple example route located at `/cache` where you can set and view the value of a given cache entry. Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in. @@ -890,7 +893,7 @@ As shown in the previous examples, cache tags were provided because they can be ## Tasks -Tasks are queued operations to be executed in the background, either immediately, at a specfic time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc. +Tasks are queued operations to be executed in the background, either immediately, at a specific time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc. Since we're already using [SQLite](https://sqlite.org/) for our database, it's available to act as a persistent store for queued tasks so that tasks are never lost, can be retried until successful, and their concurrent execution can be managed. [Backlite](https://github.com/mikestefanello/backlite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously. I wrote that specifically to address the requirements I wanted to satisfy for this project. @@ -938,7 +941,7 @@ The cache max-life is controlled by the configuration at `Config.Cache.Expiratio ### Cache-buster -While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string generated using the timestamp of when the app started and persists until the application restarts. +While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts. For example, to render a file located in `static/picture.png`, you would use: ```go diff --git a/pkg/ui/pages/home.go b/pkg/ui/pages/home.go index 9077210f..51bab861 100644 --- a/pkg/ui/pages/home.go +++ b/pkg/ui/pages/home.go @@ -59,7 +59,7 @@ func Home(ctx echo.Context, posts *models.Posts) error { "is-small is-warning mt-5", "Serving files", Group{ - Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted."), + Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "), Text("Static files also contain cache-control headers which are configured via middleware."), }, )) From 68f4454cea18905054ccc93499842a42b1bed104 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Tue, 4 Mar 2025 20:14:50 -0500 Subject: [PATCH 26/30] Fixed about page component caching. --- pkg/ui/pages/about.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go index 9db86885..5a48d644 100644 --- a/pkg/ui/pages/about.go +++ b/pkg/ui/pages/about.go @@ -17,10 +17,12 @@ func About(ctx echo.Context) error { tabs := func() Node { // The tabs are static so we can render and cache them. - if n := cache.Get("pages.about.Tabs"); n != nil { + const cacheKey = "pages.about.Tabs" + if n := cache.Get(cacheKey); n != nil { return n } - return Group{ + + n := Group{ Tabs( "Frontend", "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", @@ -55,6 +57,9 @@ func About(ctx echo.Context) error { }, ), } + + cache.Set(cacheKey, n) + return n } return r.Render(layouts.Primary, tabs()) From 4950b69010efe7b1ee37c8200f66f6fc2207c6f6 Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:25:37 -0500 Subject: [PATCH 27/30] Updated gomponents. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e68c05b0..37b190f5 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.33.0 - maragu.dev/gomponents v1.0.0 + maragu.dev/gomponents v1.1.0 ) require ( diff --git a/go.sum b/go.sum index 9d4cc82a..09565b03 100644 --- a/go.sum +++ b/go.sum @@ -225,5 +225,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4= -maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= +maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U= +maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= From fda2cfd07d9c3c35e17fdfcdb2e349f6190c801d Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:38:21 -0500 Subject: [PATCH 28/30] Easier component caching. --- pkg/ui/cache/cache.go | 12 ++++ pkg/ui/cache/cache_test.go | 27 ++++++- pkg/ui/layouts/auth.go | 30 ++++---- pkg/ui/layouts/primary.go | 144 +++++++++++++++++-------------------- pkg/ui/pages/about.go | 18 ++--- 5 files changed, 122 insertions(+), 109 deletions(-) diff --git a/pkg/ui/cache/cache.go b/pkg/ui/cache/cache.go index 94095e8b..79badc61 100644 --- a/pkg/ui/cache/cache.go +++ b/pkg/ui/cache/cache.go @@ -50,3 +50,15 @@ func Get(key string) gomponents.Node { defer mu.RUnlock() return cache[key] } + +// SetIfNotExists will return the cached Node for the key, if it exists, otherwise it will use the provided callback +// function to generate the node and cache it. +func SetIfNotExists(key string, gen func() gomponents.Node) gomponents.Node { + if n := Get(key); n != nil { + return n + } + + n := gen() + Set(key, n) + return n +} diff --git a/pkg/ui/cache/cache_test.go b/pkg/ui/cache/cache_test.go index 88c94014..f4b8498c 100644 --- a/pkg/ui/cache/cache_test.go +++ b/pkg/ui/cache/cache_test.go @@ -10,7 +10,7 @@ import ( . "maragu.dev/gomponents/html" ) -func TestCache(t *testing.T) { +func TestCache_GetSet(t *testing.T) { key := "test" assert.Nil(t, Get(key)) @@ -30,3 +30,28 @@ func TestCache(t *testing.T) { require.NoError(t, got.Render(buf2)) assert.Equal(t, buf1.String(), buf2.String()) } + +func TestCache_SetIfNotExists(t *testing.T) { + key := "test" + called := 0 + callback := func() Node { + called++ + return Div(Text("hello")) + } + + assertRender := func(n Node) { + buf := bytes.NewBuffer(nil) + require.NoError(t, n.Render(buf)) + assert.Equal(t, `
hello
`, buf.String()) + } + + got := SetIfNotExists(key, callback) + assert.Equal(t, 1, called) + require.NotNil(t, got) + assertRender(got) + + got = SetIfNotExists(key, callback) + assert.Equal(t, 1, called) + require.NotNil(t, got) + assertRender(got) +} diff --git a/pkg/ui/layouts/auth.go b/pkg/ui/layouts/auth.go index c5f6ee92..719d3ff9 100644 --- a/pkg/ui/layouts/auth.go +++ b/pkg/ui/layouts/auth.go @@ -47,24 +47,18 @@ func Auth(r *ui.Request, content Node) Node { } func authNavBar(r *ui.Request) Node { - const cacheKey = "authNavBar" - if n := cache.Get(cacheKey); n != nil { - return n - } - - n := Nav( - Class("navbar"), - Div( - Class("navbar-menu"), + return cache.SetIfNotExists("authNavBar", func() Node { + return Nav( + Class("navbar"), Div( - Class("navbar-start"), - A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")), - A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")), - A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")), + Class("navbar-menu"), + Div( + Class("navbar-start"), + A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")), + A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")), + A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")), + ), ), - ), - ) - - cache.Set(cacheKey, n) - return n + ) + }) } diff --git a/pkg/ui/layouts/primary.go b/pkg/ui/layouts/primary.go index 4813d0c3..e92e562a 100644 --- a/pkg/ui/layouts/primary.go +++ b/pkg/ui/layouts/primary.go @@ -42,98 +42,88 @@ func Primary(r *ui.Request, content Node) Node { } func headerNavBar(r *ui.Request) Node { - const cacheKey = "layout.headerNavBar" - if n := cache.Get(cacheKey); n != nil { - return n - } - - n := Nav( - Class("navbar is-dark"), - Div( - Class("container"), + return cache.SetIfNotExists("layout.headerNavBar", func() Node { + return Nav( + Class("navbar is-dark"), Div( - Class("navbar-brand"), - HxBoost(), - A( - Href(r.Path(routenames.Home)), - Class("navbar-item"), - Text("Pagoda"), + Class("container"), + Div( + Class("navbar-brand"), + HxBoost(), + A( + Href(r.Path(routenames.Home)), + Class("navbar-item"), + Text("Pagoda"), + ), ), - ), - Div( - ID("navbarMenu"), - Class("navbar-menu"), Div( - Class("navbar-end"), - search(r), + ID("navbarMenu"), + Class("navbar-menu"), + Div( + Class("navbar-end"), + search(r), + ), ), ), - ), - ) - cache.Set(cacheKey, n) - return n + ) + }) } func search(r *ui.Request) Node { - const cacheKey = "layout.search" - if n := cache.Get("layout.search"); n != nil { - return n - } - - n := Div( - Class("search mr-2 mt-1"), - Attr("x-data", "{modal:false}"), - Input( - Class("input"), - Type("search"), - Placeholder("Search..."), - Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"), - ), - Div( - Class("modal"), - Attr(":class", "modal ? 'is-active' : ''"), - Attr("x-show", "modal == true"), - Div( - Class("modal-background"), + return cache.SetIfNotExists("layout.search", func() Node { + return Div( + Class("search mr-2 mt-1"), + Attr("x-data", "{modal:false}"), + Input( + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"), ), Div( - Class("modal-content"), - Attr("@click.outside", "modal = false;"), + Class("modal"), + Attr(":class", "modal ? 'is-active' : ''"), + Attr("x-show", "modal == true"), Div( - Class("box"), - H2( - Class("subtitle"), - Text("Search"), - ), - P( - Class("control"), - Input( - Attr("hx-get", r.Path(routenames.Search)), - Attr("hx-trigger", "keyup changed delay:500ms"), - Attr("hx-target", "#results"), - Name("query"), - Class("input"), - Type("search"), - Placeholder("Search..."), - Attr("x-ref", "input"), - ), - ), - Div( - Class("block"), - ), + Class("modal-background"), + ), + Div( + Class("modal-content"), + Attr("@click.outside", "modal = false;"), Div( - ID("results"), + Class("box"), + H2( + Class("subtitle"), + Text("Search"), + ), + P( + Class("control"), + Input( + Attr("hx-get", r.Path(routenames.Search)), + Attr("hx-trigger", "keyup changed delay:500ms"), + Attr("hx-target", "#results"), + Name("query"), + Class("input"), + Type("search"), + Placeholder("Search..."), + Attr("x-ref", "input"), + ), + ), + Div( + Class("block"), + ), + Div( + ID("results"), + ), ), ), + Button( + Class("modal-close is-large"), + Aria("label", "close"), + ), ), - Button( - Class("modal-close is-large"), - Aria("label", "close"), - ), - ), - ) - cache.Set(cacheKey, n) - return n + ) + }) } func sidebarMenu(r *ui.Request) Node { diff --git a/pkg/ui/pages/about.go b/pkg/ui/pages/about.go index 5a48d644..5c68be8b 100644 --- a/pkg/ui/pages/about.go +++ b/pkg/ui/pages/about.go @@ -15,14 +15,9 @@ func About(ctx echo.Context) error { r.Title = "About" r.Metatags.Description = "Learn a little about what's included in Pagoda." - tabs := func() Node { - // The tabs are static so we can render and cache them. - const cacheKey = "pages.about.Tabs" - if n := cache.Get(cacheKey); n != nil { - return n - } - - n := Group{ + // The tabs are static so we can render and cache them. + tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node { + return Group{ Tabs( "Frontend", "The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.", @@ -57,10 +52,7 @@ func About(ctx echo.Context) error { }, ), } + }) - cache.Set(cacheKey, n) - return n - } - - return r.Render(layouts.Primary, tabs()) + return r.Render(layouts.Primary, tabs) } From fd546e0e9797bc5fa7a1951e7a40fca3e51f899a Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:43:35 -0500 Subject: [PATCH 29/30] Updated readme. --- README.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3e4d4b77..94362e4f 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ func (e *Example) PageSubmit(ctx echo.Context) error { Routes can return errors to indicate that something wrong happened and an error page should be rendered for the request. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code, and optionally a message that will be logged. You can use `return echo.NewHTTPError(http.StatusInternalServerError, "optional message")`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed. -The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route passes the status code to the `page.Error` UI component, allowing you to easily adjust the markup depending on the error type. +The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route passes the status code to the `pages.Error` UI component page, allowing you to easily adjust the markup depending on the error type. ### Redirects @@ -606,7 +606,7 @@ Your components can also make using utility-based CSS libraries, such as [Tailwi ### Layouts -_Layouts_ are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating to the login, register, and forgot password pages. +_Layouts_ are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating between the links on the _General_ and _Account_ sidebar menus. ### Pages @@ -655,7 +655,7 @@ Next, provide a method that renders the form: ```go func (f *Guestbook) Render(r *ui.Request) Node { return Form( - ID("contact"), + ID("guestbook"), Method(http.MethodPost), Attr("hx-post", r.Path(routenames.GuestbookSubmit)), TextareaField(TextareaFieldParams{ @@ -710,11 +710,13 @@ func (e *Example) Page(ctx echo.Context) error { Using the example form above, this is all you would have to do within the _POST_ callback for your route: -Start by submitting the form along with the request context. This will: -1. Store a pointer to the form so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided. +Start by submitting the form via `form.Submit()`, along with the request context. This will: +1. Store a pointer to the form in the _context_ so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided. 2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields. 3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags. +Then, evaluate the error returned, if one, and process the form values however you need to: + ```go func (e *Example) Submit(ctx echo.Context) error { var input forms.Guestbook @@ -727,7 +729,7 @@ func (e *Example) Submit(ctx echo.Context) error { case nil: // All good! case validator.ValidationErrors: - // The form input was not valid, so re-render the form. + // The form input was not valid, so re-render the form with the errors included. return e.Page(ctx) default: // Request failed, show the error page. @@ -735,12 +737,16 @@ func (e *Example) Submit(ctx echo.Context) error { } msg.Success(fmt.Sprintf("Your message was: %s", input.Message)) + + return redirect.New(ctx). + Route(routenames.Home). + Go() } ``` #### Inline validation -The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that the provided form [components](#components), such as `TextareaField` shown in the example above, use to automatically provide classes and error messages. The example form above will have inline validation without doing anything other than what is shown. +The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that the provided form [components](#components), such as `TextareaField` shown in the example above, use to automatically provide classes and error messages. The example form above will have inline validation without requiring anything other than what is shown above. While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed. @@ -756,25 +762,23 @@ Models are objects built and provided by your _routes_ that can be rendered by y ### Node caching -While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponent.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change. +While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change. A good example of this, and one included, is the entire upper navigation bar, search form, and search modal in the _Primary_ layout. It contains a large amount of nested _gomponent_ function calls and a lot of rendering is required. There is no reason to do this more than once. -The cache functions are available in `pkg/ui/cache` and can be used like this: +The cache functions are available in `pkg/ui/cache` and can most easily used like this: ```go -func SearchModal() gomponent.Node { - const cacheKey = "searchModal" - if n := cache.Get(cacheKey); n != nil { - return n - } - - n := Div(...your entire nested node...) - cache.Set(cacheKey, n) - return n +func SearchModal() gomponents.Node { + return cache.SetIfNotExists("searchModal", func() gomponents.Node { + return Div(...your entire nested node...) + }) } ``` -`cache.Get()` does more than just cache the `Node` in-memory. It renders the entire `Node` into a `bytes.Buffer`, then stores a `Raw()` `Node` using the rendered content. This means that everytime the `Node` is taken from the cache and rendered, the pre-rendered `string` is used rather than having to iterate through the nested component, executing all of the element functions and rendering and building the entire HTML output. + +`cache.SetIfNotExists()`is a helper function that uses `cache.Get()` to check if the `Node` is already cached under the provided _key_, and if not, executes the _func_ to generate the `Node`, and caches that via `cache.Set()`. + +`cache.Set()` does more than just cache the `Node` in-memory. It renders the entire `Node` into a `bytes.Buffer`, then stores a `Raw()` `Node` using the rendered content. This means that everytime the `Node` is taken from the cache and rendered, the pre-rendered `string` is used rather than having to iterate through the nested component, executing all of the element functions and rendering and building the entire HTML output. It's worth noting that my benchmarking was very limited and cannot be considered anything definitive. In my tests, gomponents was faster, allocated less overall, but had more allocations in total. If you're able to cache static nodes, gomponents can perform significantly better. Reiterating, for most applications, these differences in nanoseconds and bytes will most likely be completely insignificant and unnoticed; but it's worth being aware of. From 32e785322331315b1ef7af88703a9f2e858a34af Mon Sep 17 00:00:00 2001 From: mikestefanello <552328+mikestefanello@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:46:01 -0500 Subject: [PATCH 30/30] Fixed component cache test. --- pkg/ui/cache/cache_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ui/cache/cache_test.go b/pkg/ui/cache/cache_test.go index f4b8498c..06b0c940 100644 --- a/pkg/ui/cache/cache_test.go +++ b/pkg/ui/cache/cache_test.go @@ -32,7 +32,7 @@ func TestCache_GetSet(t *testing.T) { } func TestCache_SetIfNotExists(t *testing.T) { - key := "test" + key := "test2" called := 0 callback := func() Node { called++