-
- Warning
-
- This route has caching enabled so hot-reloading in the local environment will not work.
-
- ?<;t?GyfSOaQ%go<&yi%s&}W;d)J%Zcm$To)B)Wni
z;Gf4W{N{ZLW2W@^TTS%dt0I$te7W9O?5s1s1TH9xPT2PtGU
z`v?7G j+)z{H&p=D!QiPYH!8mXv6Jj!`0p<+8b*%
zM? *3l2eq {{.}} 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. Backend The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects. → {{.Body}} Please try again. You are not authorized to view the requested page. Click {{link (url "home") "here" .Path}} to return home Something went wrong
-
- {{.Title}}
-
-
-
-
- hello hello_#H55pCpZgDo(GB(+eHR!5JN3(aSn89l5Gri?(G*z9zWDacw{yy0Ibj6Zr@q^0(gy?uf
z2>p(ptP#Z+h++)z>6qpKGwC;FU=8ZIT9{(&+XxrBPDx1_3E|#~Y(|5^bajY($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%mC0
UWzi8;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(?K3uBx
-
- {{.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" .}}
-
-
-
-{{define "search"}}
-
-
-
-
-
-
-
-
-
- {{- if .Title}}
-
- {{.Title}}
- {{- end}}
-
- {{template "messages" .}}
- {{template "content" .}}
-
- $refs.input.focus());"/>
-
-{{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}}
-
-
-
-
-
-
-
-{{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"}}
-
-{{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}}
-
- {{- else}}
-
- {{- 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}}
-
-
- {{- range $index, $tab := .}}
-
- {{- range $index, $tab := .}}
-
-
- {{- end}}
-
- Uploaded files
-
-
-
-
-{{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"}}
-
-{{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"}}
-
-
-
-
- {{- range .Data}}
- Filename
- Size
- Modified on
-
-
- {{- end}}
-
- {{.Name}}
- {{.Size}}
- {{.Modified}}
-
-
-
-
-
- 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
-
-
- {{- range .Data}}
-
-{{end}}
-
-{{define "file-msg"}}
-
-
-{{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"}}
-
-{{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"}}
-
-{{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"}}
-
-{{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"}}
-
-{{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, `
-
-
-
-
-
- {{.Body}}
-
- {{- if not $.Pager.IsBeginning}}
-
- `, 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, `
+```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++