Migrate from templates to Gomponents (#103)
This commit is contained in:
parent
0bf9ab7189
commit
051d032038
104 changed files with 2768 additions and 2824 deletions
|
|
@ -3,29 +3,54 @@ package context
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthenticatedUserKey is the key value used to store the authenticated user in context
|
||||
// AuthenticatedUserKey is the key used to store the authenticated user in context.
|
||||
AuthenticatedUserKey = "auth_user"
|
||||
|
||||
// UserKey is the key value used to store a user in context
|
||||
// UserKey is the key used to store a user in context.
|
||||
UserKey = "user"
|
||||
|
||||
// FormKey is the key value used to store a form in context
|
||||
// FormKey is the key used to store a form in context.
|
||||
FormKey = "form"
|
||||
|
||||
// PasswordTokenKey is the key value used to store a password token in context
|
||||
// PasswordTokenKey is the key used to store a password token in context.
|
||||
PasswordTokenKey = "password_token"
|
||||
|
||||
// LoggerKey is the key value used to store a structured logger in context
|
||||
// LoggerKey is the key used to store a structured logger in context.
|
||||
LoggerKey = "logger"
|
||||
|
||||
// SessionKey is the key value used to store the session data in context
|
||||
// SessionKey is the key used to store the session data in context.
|
||||
SessionKey = "session"
|
||||
|
||||
// HTMXRequestKey is the key used to store the HTMX request data in context.
|
||||
HTMXRequestKey = "htmx"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Cache checks if a value of a given type exists in the Echo context for a given key and returns that, otherwise
|
||||
// it will use a callback to generate a value, which is stored in the context then returned. This allows you to
|
||||
// only generate items only once for a given request.
|
||||
func Cache[T any](ctx echo.Context, key string, gen func(echo.Context) T) T {
|
||||
if val := ctx.Get(key); val != nil {
|
||||
if v, ok := val.(T); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
val := gen(ctx)
|
||||
ctx.Set(key, val)
|
||||
return val
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -22,3 +23,25 @@ func TestIsCanceled(t *testing.T) {
|
|||
|
||||
assert.False(t, IsCanceledError(errors.New("test error")))
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
ctx := echo.New().NewContext(nil, nil)
|
||||
|
||||
key := "testing"
|
||||
value := "hello"
|
||||
called := 0
|
||||
callback := func(ctx echo.Context) string {
|
||||
called++
|
||||
return value
|
||||
}
|
||||
|
||||
assert.Nil(t, ctx.Get(key))
|
||||
|
||||
got := Cache(ctx, key, callback)
|
||||
assert.Equal(t, value, got)
|
||||
assert.Equal(t, 1, called)
|
||||
|
||||
got = Cache(ctx, key, callback)
|
||||
assert.Equal(t, value, got)
|
||||
assert.Equal(t, 1, called)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
package funcmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/sprig"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/gommon/random"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// CacheBuster stores a random string used as a cache buster for static files.
|
||||
CacheBuster = random.String(10)
|
||||
)
|
||||
|
||||
type funcMap struct {
|
||||
web *echo.Echo
|
||||
}
|
||||
|
||||
// NewFuncMap provides a template function map
|
||||
func NewFuncMap(web *echo.Echo) template.FuncMap {
|
||||
fm := &funcMap{web: web}
|
||||
|
||||
// See http://masterminds.github.io/sprig/ for all provided funcs
|
||||
funcs := sprig.FuncMap()
|
||||
|
||||
// Include all the custom functions
|
||||
funcs["file"] = fm.file
|
||||
funcs["link"] = fm.link
|
||||
funcs["url"] = fm.url
|
||||
|
||||
return funcs
|
||||
}
|
||||
|
||||
// file appends a cache buster to a given filepath so it can remain cached until the app is restarted
|
||||
func (fm *funcMap) file(filepath string) string {
|
||||
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, CacheBuster)
|
||||
}
|
||||
|
||||
// link outputs HTML for a link element, providing the ability to dynamically set the active class
|
||||
func (fm *funcMap) link(url, text, currentPath string, classes ...string) template.HTML {
|
||||
if currentPath == url {
|
||||
classes = append(classes, "is-active")
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`<a class="%s" href="%s">%s</a>`, strings.Join(classes, " "), url, text)
|
||||
return template.HTML(html)
|
||||
}
|
||||
|
||||
// url generates a URL from a given route name and optional parameters
|
||||
func (fm *funcMap) url(routeName string, params ...any) string {
|
||||
return fm.web.Reverse(routeName, params...)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package funcmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewFuncMap(t *testing.T) {
|
||||
f := NewFuncMap(echo.New())
|
||||
assert.NotNil(t, f["link"])
|
||||
assert.NotNil(t, f["file"])
|
||||
assert.NotNil(t, f["url"])
|
||||
}
|
||||
|
||||
func TestLink(t *testing.T) {
|
||||
f := new(funcMap)
|
||||
|
||||
link := string(f.link("/abc", "Text", "/abc"))
|
||||
expected := `<a class="is-active" href="/abc">Text</a>`
|
||||
assert.Equal(t, expected, link)
|
||||
|
||||
link = string(f.link("/abc", "Text", "/abc", "first", "second"))
|
||||
expected = `<a class="first second is-active" href="/abc">Text</a>`
|
||||
assert.Equal(t, expected, link)
|
||||
|
||||
link = string(f.link("/abc", "Text", "/def"))
|
||||
expected = `<a class="" href="/abc">Text</a>`
|
||||
assert.Equal(t, expected, link)
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
f := new(funcMap)
|
||||
|
||||
file := f.file("test.png")
|
||||
expected := fmt.Sprintf("/%s/test.png?v=%s", config.StaticPrefix, CacheBuster)
|
||||
assert.Equal(t, expected, file)
|
||||
}
|
||||
|
||||
func TestUrl(t *testing.T) {
|
||||
f := new(funcMap)
|
||||
f.web = echo.New()
|
||||
f.web.GET("/mypath/:id", func(c echo.Context) error {
|
||||
return nil
|
||||
}).Name = "test"
|
||||
out := f.url("test", 5)
|
||||
assert.Equal(t, "/mypath/5", out)
|
||||
}
|
||||
|
|
@ -13,65 +13,25 @@ import (
|
|||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
"github.com/mikestefanello/pagoda/pkg/middleware"
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/redirect"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/emails"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameForgotPassword = "forgot_password"
|
||||
routeNameForgotPasswordSubmit = "forgot_password.submit"
|
||||
routeNameLogin = "login"
|
||||
routeNameLoginSubmit = "login.submit"
|
||||
routeNameLogout = "logout"
|
||||
routeNameRegister = "register"
|
||||
routeNameRegisterSubmit = "register.submit"
|
||||
routeNameResetPassword = "reset_password"
|
||||
routeNameResetPasswordSubmit = "reset_password.submit"
|
||||
routeNameVerifyEmail = "verify_email"
|
||||
)
|
||||
|
||||
type (
|
||||
Auth struct {
|
||||
auth *services.AuthClient
|
||||
mail *services.MailClient
|
||||
orm *ent.Client
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
forgotPasswordForm struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
loginForm struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
registerForm struct {
|
||||
Name string `form:"name" validate:"required"`
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
resetPasswordForm struct {
|
||||
Password string `form:"password" validate:"required"`
|
||||
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
|
||||
form.Submission
|
||||
}
|
||||
)
|
||||
type Auth struct {
|
||||
auth *services.AuthClient
|
||||
mail *services.MailClient
|
||||
orm *ent.Client
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(new(Auth))
|
||||
}
|
||||
|
||||
func (h *Auth) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
h.orm = c.ORM
|
||||
h.auth = c.Auth
|
||||
h.mail = c.Mail
|
||||
|
|
@ -79,37 +39,31 @@ func (h *Auth) Init(c *services.Container) error {
|
|||
}
|
||||
|
||||
func (h *Auth) Routes(g *echo.Group) {
|
||||
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout
|
||||
g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail
|
||||
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routenames.Logout
|
||||
g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail
|
||||
|
||||
noAuth := g.Group("/user", middleware.RequireNoAuthentication())
|
||||
noAuth.GET("/login", h.LoginPage).Name = routeNameLogin
|
||||
noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit
|
||||
noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister
|
||||
noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit
|
||||
noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword
|
||||
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit
|
||||
noAuth.GET("/login", h.LoginPage).Name = routenames.Login
|
||||
noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit
|
||||
noAuth.GET("/register", h.RegisterPage).Name = routenames.Register
|
||||
noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit
|
||||
noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword
|
||||
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit
|
||||
|
||||
resetGroup := noAuth.Group("/password/reset",
|
||||
middleware.LoadUser(h.orm),
|
||||
middleware.LoadValidPasswordToken(h.auth),
|
||||
)
|
||||
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword
|
||||
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit
|
||||
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword
|
||||
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit
|
||||
}
|
||||
|
||||
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutAuth
|
||||
p.Name = templates.PageForgotPassword
|
||||
p.Title = "Forgot password"
|
||||
p.Form = form.Get[forgotPasswordForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.ForgotPassword(ctx, form.Get[forms.ForgotPassword](ctx))
|
||||
}
|
||||
|
||||
func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
|
||||
var input forgotPasswordForm
|
||||
var input forms.ForgotPassword
|
||||
|
||||
succeed := func() error {
|
||||
form.Clear(ctx)
|
||||
|
|
@ -127,7 +81,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Attempt to load the user
|
||||
// Attempt to load the user.
|
||||
u, err := h.orm.User.
|
||||
Query().
|
||||
Where(user.Email(strings.ToLower(input.Email))).
|
||||
|
|
@ -141,7 +95,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
|
|||
return fail(err, "error querying user during forgot password")
|
||||
}
|
||||
|
||||
// Generate the token
|
||||
// Generate the token.
|
||||
token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID)
|
||||
if err != nil {
|
||||
return fail(err, "error generating password reset token")
|
||||
|
|
@ -151,8 +105,8 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
|
|||
"user_id", u.ID,
|
||||
)
|
||||
|
||||
// Email the user
|
||||
url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token)
|
||||
// Email the user.
|
||||
url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token)
|
||||
err = h.mail.
|
||||
Compose().
|
||||
To(u.Email).
|
||||
|
|
@ -168,17 +122,11 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
|
|||
}
|
||||
|
||||
func (h *Auth) LoginPage(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutAuth
|
||||
p.Name = templates.PageLogin
|
||||
p.Title = "Log in"
|
||||
p.Form = form.Get[loginForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.Login(ctx, form.Get[forms.Login](ctx))
|
||||
}
|
||||
|
||||
func (h *Auth) LoginSubmit(ctx echo.Context) error {
|
||||
var input loginForm
|
||||
var input forms.Login
|
||||
|
||||
authFailed := func() error {
|
||||
input.SetFieldError("Email", "")
|
||||
|
|
@ -197,7 +145,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Attempt to load the user
|
||||
// Attempt to load the user.
|
||||
u, err := h.orm.User.
|
||||
Query().
|
||||
Where(user.Email(strings.ToLower(input.Email))).
|
||||
|
|
@ -211,22 +159,22 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
|
|||
return fail(err, "error querying user during login")
|
||||
}
|
||||
|
||||
// Check if the password is correct
|
||||
// Check if the password is correct.
|
||||
err = h.auth.CheckPassword(input.Password, u.Password)
|
||||
if err != nil {
|
||||
return authFailed()
|
||||
}
|
||||
|
||||
// Log the user in
|
||||
// Log the user in.
|
||||
err = h.auth.Login(ctx, u.ID)
|
||||
if err != nil {
|
||||
return fail(err, "unable to log in user")
|
||||
}
|
||||
|
||||
msg.Success(ctx, fmt.Sprintf("Welcome back, <strong>%s</strong>. You are now logged in.", u.Name))
|
||||
msg.Success(ctx, fmt.Sprintf("Welcome back, %s. You are now logged in.", u.Name))
|
||||
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameHome).
|
||||
Route(routenames.Home).
|
||||
Go()
|
||||
}
|
||||
|
||||
|
|
@ -237,22 +185,16 @@ func (h *Auth) Logout(ctx echo.Context) error {
|
|||
msg.Danger(ctx, "An error occurred. Please try again.")
|
||||
}
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameHome).
|
||||
Route(routenames.Home).
|
||||
Go()
|
||||
}
|
||||
|
||||
func (h *Auth) RegisterPage(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutAuth
|
||||
p.Name = templates.PageRegister
|
||||
p.Title = "Register"
|
||||
p.Form = form.Get[registerForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.Register(ctx, form.Get[forms.Register](ctx))
|
||||
}
|
||||
|
||||
func (h *Auth) RegisterSubmit(ctx echo.Context) error {
|
||||
var input registerForm
|
||||
var input forms.Register
|
||||
|
||||
err := form.Submit(ctx, &input)
|
||||
|
||||
|
|
@ -264,13 +206,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
// Hash the password.
|
||||
pwHash, err := h.auth.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return fail(err, "unable to hash password")
|
||||
}
|
||||
|
||||
// Attempt creating the user
|
||||
// Attempt creating the user.
|
||||
u, err := h.orm.User.
|
||||
Create().
|
||||
SetName(input.Name).
|
||||
|
|
@ -287,13 +229,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
|
|||
case *ent.ConstraintError:
|
||||
msg.Warning(ctx, "A user with this email address already exists. Please log in.")
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameLogin).
|
||||
Route(routenames.Login).
|
||||
Go()
|
||||
default:
|
||||
return fail(err, "unable to create user")
|
||||
}
|
||||
|
||||
// Log the user in
|
||||
// Log the user in.
|
||||
err = h.auth.Login(ctx, u.ID)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Error("unable to log user in",
|
||||
|
|
@ -302,22 +244,22 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
|
|||
)
|
||||
msg.Info(ctx, "Your account has been created.")
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameLogin).
|
||||
Route(routenames.Login).
|
||||
Go()
|
||||
}
|
||||
|
||||
msg.Success(ctx, "Your account has been created. You are now logged in.")
|
||||
|
||||
// Send the verification email
|
||||
// Send the verification email.
|
||||
h.sendVerificationEmail(ctx, u)
|
||||
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameHome).
|
||||
Route(routenames.Home).
|
||||
Go()
|
||||
}
|
||||
|
||||
func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
|
||||
// Generate a token
|
||||
// Generate a token.
|
||||
token, err := h.auth.GenerateEmailVerificationToken(usr.Email)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Error("unable to generate email verification token",
|
||||
|
|
@ -327,13 +269,12 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
|
|||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
url := ctx.Echo().Reverse(routeNameVerifyEmail, token)
|
||||
// Send the email.
|
||||
err = h.mail.
|
||||
Compose().
|
||||
To(usr.Email).
|
||||
Subject("Confirm your email address").
|
||||
Body(fmt.Sprintf("Click here to confirm your email address: %s", url)).
|
||||
Component(emails.ConfirmEmailAddress(ctx, usr.Name, token)).
|
||||
Send(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -348,17 +289,11 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
|
|||
}
|
||||
|
||||
func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutAuth
|
||||
p.Name = templates.PageResetPassword
|
||||
p.Title = "Reset password"
|
||||
p.Form = form.Get[resetPasswordForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.ResetPassword(ctx, form.Get[forms.ResetPassword](ctx))
|
||||
}
|
||||
|
||||
func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
|
||||
var input resetPasswordForm
|
||||
var input forms.ResetPassword
|
||||
|
||||
err := form.Submit(ctx, &input)
|
||||
|
||||
|
|
@ -370,16 +305,16 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
// Hash the new password.
|
||||
hash, err := h.auth.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return fail(err, "unable to hash password")
|
||||
}
|
||||
|
||||
// Get the requesting user
|
||||
// Get the requesting user.
|
||||
usr := ctx.Get(context.UserKey).(*ent.User)
|
||||
|
||||
// Update the user
|
||||
// Update the user.
|
||||
_, err = usr.
|
||||
Update().
|
||||
SetPassword(hash).
|
||||
|
|
@ -389,7 +324,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
|
|||
return fail(err, "unable to update password")
|
||||
}
|
||||
|
||||
// Delete all password tokens for this user
|
||||
// Delete all password tokens for this user.
|
||||
err = h.auth.DeletePasswordTokens(ctx, usr.ID)
|
||||
if err != nil {
|
||||
return fail(err, "unable to delete password tokens")
|
||||
|
|
@ -397,24 +332,24 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
|
|||
|
||||
msg.Success(ctx, "Your password has been updated.")
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameLogin).
|
||||
Route(routenames.Login).
|
||||
Go()
|
||||
}
|
||||
|
||||
func (h *Auth) VerifyEmail(ctx echo.Context) error {
|
||||
var usr *ent.User
|
||||
|
||||
// Validate the token
|
||||
// Validate the token.
|
||||
token := ctx.Param("token")
|
||||
email, err := h.auth.ValidateEmailVerificationToken(token)
|
||||
if err != nil {
|
||||
msg.Warning(ctx, "The link is either invalid or has expired.")
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameHome).
|
||||
Route(routenames.Home).
|
||||
Go()
|
||||
}
|
||||
|
||||
// Check if it matches the authenticated user
|
||||
// Check if it matches the authenticated user.
|
||||
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
|
||||
authUser := u.(*ent.User)
|
||||
|
||||
|
|
@ -423,7 +358,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Query to find a matching user, if needed
|
||||
// Query to find a matching user, if needed.
|
||||
if usr == nil {
|
||||
usr, err = h.orm.User.
|
||||
Query().
|
||||
|
|
@ -435,7 +370,7 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Verify the user, if needed
|
||||
// Verify the user, if needed.
|
||||
if !usr.Verified {
|
||||
usr, err = usr.
|
||||
Update().
|
||||
|
|
@ -449,6 +384,6 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
|
|||
|
||||
msg.Success(ctx, "Your email has been successfully verified.")
|
||||
return redirect.New(ctx).
|
||||
Route(routeNameHome).
|
||||
Route(routenames.Home).
|
||||
Go()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,79 +2,63 @@ package handlers
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"time"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameCache = "cache"
|
||||
routeNameCacheSubmit = "cache.submit"
|
||||
)
|
||||
|
||||
type (
|
||||
Cache struct {
|
||||
cache *services.CacheClient
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
cacheForm struct {
|
||||
Value string `form:"value"`
|
||||
form.Submission
|
||||
}
|
||||
)
|
||||
type Cache struct {
|
||||
cache *services.CacheClient
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(new(Cache))
|
||||
}
|
||||
|
||||
func (h *Cache) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
h.cache = c.Cache
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Cache) Routes(g *echo.Group) {
|
||||
g.GET("/cache", h.Page).Name = routeNameCache
|
||||
g.POST("/cache", h.Submit).Name = routeNameCacheSubmit
|
||||
g.GET("/cache", h.Page).Name = routenames.Cache
|
||||
g.POST("/cache", h.Submit).Name = routenames.CacheSubmit
|
||||
}
|
||||
|
||||
func (h *Cache) Page(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageCache
|
||||
p.Title = "Set a cache entry"
|
||||
p.Form = form.Get[cacheForm](ctx)
|
||||
f := form.Get[forms.Cache](ctx)
|
||||
|
||||
// Fetch the value from the cache
|
||||
// Fetch the value from the cache.
|
||||
value, err := h.cache.
|
||||
Get().
|
||||
Key("page_cache_example").
|
||||
Fetch(ctx.Request().Context())
|
||||
|
||||
// Store the value in the page, so it can be rendered, if found
|
||||
// Store the value in the form, so it can be rendered, if found.
|
||||
switch {
|
||||
case err == nil:
|
||||
p.Data = value.(string)
|
||||
f.CurrentValue = value.(string)
|
||||
case errors.Is(err, services.ErrCacheMiss):
|
||||
default:
|
||||
return fail(err, "failed to fetch from cache")
|
||||
}
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.UpdateCache(ctx, f)
|
||||
}
|
||||
|
||||
func (h *Cache) Submit(ctx echo.Context) error {
|
||||
var input cacheForm
|
||||
var input forms.Cache
|
||||
|
||||
if err := form.Submit(ctx, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the cache
|
||||
// Set the cache.
|
||||
err := h.cache.
|
||||
Set().
|
||||
Key("page_cache_example").
|
||||
|
|
|
|||
|
|
@ -2,60 +2,40 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameContact = "contact"
|
||||
routeNameContactSubmit = "contact.submit"
|
||||
)
|
||||
|
||||
type (
|
||||
Contact struct {
|
||||
mail *services.MailClient
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
contactForm struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Department string `form:"department" validate:"required,oneof=sales marketing hr"`
|
||||
Message string `form:"message" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
)
|
||||
type Contact struct {
|
||||
mail *services.MailClient
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(new(Contact))
|
||||
}
|
||||
|
||||
func (h *Contact) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
h.mail = c.Mail
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Contact) Routes(g *echo.Group) {
|
||||
g.GET("/contact", h.Page).Name = routeNameContact
|
||||
g.POST("/contact", h.Submit).Name = routeNameContactSubmit
|
||||
g.GET("/contact", h.Page).Name = routenames.Contact
|
||||
g.POST("/contact", h.Submit).Name = routenames.ContactSubmit
|
||||
}
|
||||
|
||||
func (h *Contact) Page(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageContact
|
||||
p.Title = "Contact us"
|
||||
p.Form = form.Get[contactForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.ContactUs(ctx, form.Get[forms.Contact](ctx))
|
||||
}
|
||||
|
||||
func (h *Contact) Submit(ctx echo.Context) error {
|
||||
var input contactForm
|
||||
var input forms.Contact
|
||||
|
||||
err := form.Submit(ctx, &input)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,27 +6,23 @@ import (
|
|||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
type Error struct{}
|
||||
|
||||
func (e *Error) Page(err error, ctx echo.Context) {
|
||||
if ctx.Response().Committed || context.IsCanceledError(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the error status code
|
||||
// Determine the error status code.
|
||||
code := http.StatusInternalServerError
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
code = he.Code
|
||||
}
|
||||
|
||||
// Log the error
|
||||
// Log the error.
|
||||
logger := log.Ctx(ctx)
|
||||
switch {
|
||||
case code >= 500:
|
||||
|
|
@ -35,15 +31,11 @@ func (e *Error) Page(err error, ctx echo.Context) {
|
|||
logger.Warn(err.Error())
|
||||
}
|
||||
|
||||
// Render the error page
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageError
|
||||
p.Title = http.StatusText(code)
|
||||
p.StatusCode = code
|
||||
p.HTMX.Request.Enabled = false
|
||||
// Set the status code.
|
||||
ctx.Response().Status = code
|
||||
|
||||
if err = e.RenderPage(ctx, p); err != nil {
|
||||
// Render the error page.
|
||||
if err = pages.Error(ctx, code); err != nil {
|
||||
log.Ctx(ctx).Error("failed to render error page",
|
||||
"error", err,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,69 +7,48 @@ import (
|
|||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameFiles = "files"
|
||||
routeNameFilesSubmit = "files.submit"
|
||||
)
|
||||
|
||||
type (
|
||||
Files struct {
|
||||
files afero.Fs
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
File struct {
|
||||
Name string
|
||||
Size int64
|
||||
Modified string
|
||||
}
|
||||
)
|
||||
type Files struct {
|
||||
files afero.Fs
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(new(Files))
|
||||
}
|
||||
|
||||
func (h *Files) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
h.files = c.Files
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Files) Routes(g *echo.Group) {
|
||||
g.GET("/files", h.Page).Name = routeNameFiles
|
||||
g.POST("/files", h.Submit).Name = routeNameFilesSubmit
|
||||
g.GET("/files", h.Page).Name = routenames.Files
|
||||
g.POST("/files", h.Submit).Name = routenames.FilesSubmit
|
||||
}
|
||||
|
||||
func (h *Files) Page(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageFiles
|
||||
p.Title = "Upload a file"
|
||||
|
||||
// Send a list of all uploaded files to the template to be rendered.
|
||||
// Compile a list of all uploaded files to be rendered.
|
||||
info, err := afero.ReadDir(h.files, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files := make([]File, 0)
|
||||
files := make([]*models.File, 0)
|
||||
for _, file := range info {
|
||||
files = append(files, File{
|
||||
files = append(files, &models.File{
|
||||
Name: file.Name(),
|
||||
Size: file.Size(),
|
||||
Modified: file.ModTime().Format(time.DateTime),
|
||||
})
|
||||
}
|
||||
|
||||
p.Data = files
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.UploadFile(ctx, files)
|
||||
}
|
||||
|
||||
func (h *Files) Submit(ctx echo.Context) error {
|
||||
|
|
|
|||
|
|
@ -2,74 +2,46 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameAbout = "about"
|
||||
routeNameHome = "home"
|
||||
)
|
||||
|
||||
type (
|
||||
Pages struct {
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
post struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
aboutData struct {
|
||||
ShowCacheWarning bool
|
||||
FrontendTabs []aboutTab
|
||||
BackendTabs []aboutTab
|
||||
}
|
||||
|
||||
aboutTab struct {
|
||||
Title string
|
||||
Body template.HTML
|
||||
}
|
||||
)
|
||||
type Pages struct{}
|
||||
|
||||
func init() {
|
||||
Register(new(Pages))
|
||||
}
|
||||
|
||||
func (h *Pages) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Pages) Routes(g *echo.Group) {
|
||||
g.GET("/", h.Home).Name = routeNameHome
|
||||
g.GET("/about", h.About).Name = routeNameAbout
|
||||
g.GET("/", h.Home).Name = routenames.Home
|
||||
g.GET("/about", h.About).Name = routenames.About
|
||||
}
|
||||
|
||||
func (h *Pages) Home(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageHome
|
||||
p.Metatags.Description = "Welcome to the homepage."
|
||||
p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
|
||||
p.Pager = page.NewPager(ctx, 4)
|
||||
p.Data = h.fetchPosts(&p.Pager)
|
||||
pgr := pager.NewPager(ctx, 4)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.Home(ctx, &models.Posts{
|
||||
Posts: h.fetchPosts(&pgr),
|
||||
Pager: pgr,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchPosts is an mock example of fetching posts to illustrate how paging works
|
||||
func (h *Pages) fetchPosts(pager *page.Pager) []post {
|
||||
// fetchPosts is a mock example of fetching posts to illustrate how paging works.
|
||||
func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
|
||||
pager.SetItems(20)
|
||||
posts := make([]post, 20)
|
||||
posts := make([]models.Post, 20)
|
||||
|
||||
for k := range posts {
|
||||
posts[k] = 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),
|
||||
}
|
||||
|
|
@ -78,44 +50,5 @@ func (h *Pages) fetchPosts(pager *page.Pager) []post {
|
|||
}
|
||||
|
||||
func (h *Pages) About(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageAbout
|
||||
p.Title = "About"
|
||||
|
||||
// This page will be cached!
|
||||
p.Cache.Enabled = true
|
||||
p.Cache.Tags = []string{"page_about", "page:list"}
|
||||
|
||||
// A simple example of how the Data field can contain anything you want to send to the templates
|
||||
// even though you wouldn't normally send markup like this
|
||||
p.Data = aboutData{
|
||||
ShowCacheWarning: true,
|
||||
FrontendTabs: []aboutTab{
|
||||
{
|
||||
Title: "HTMX",
|
||||
Body: template.HTML(`Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href="https://htmx.org/">htmx.org</a> to learn more.`),
|
||||
},
|
||||
{
|
||||
Title: "Alpine.js",
|
||||
Body: template.HTML(`Drop-in, Vue-like functionality written directly in your markup. Visit <a href="https://alpinejs.dev/">alpinejs.dev</a> to learn more.`),
|
||||
},
|
||||
{
|
||||
Title: "Bulma",
|
||||
Body: template.HTML(`Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href="https://bulma.io/">bulma.io</a> to learn more.`),
|
||||
},
|
||||
},
|
||||
BackendTabs: []aboutTab{
|
||||
{
|
||||
Title: "Echo",
|
||||
Body: template.HTML(`High performance, extensible, minimalist Go web framework. Visit <a href="https://echo.labstack.com/">echo.labstack.com</a> to learn more.`),
|
||||
},
|
||||
{
|
||||
Title: "Ent",
|
||||
Body: template.HTML(`Simple, yet powerful ORM for modeling and querying data. Visit <a href="https://entgo.io/">entgo.io</a> to learn more.`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.About(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -6,27 +6,28 @@ 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"
|
||||
)
|
||||
|
||||
// BuildRouter builds the router
|
||||
// BuildRouter builds the router.
|
||||
func BuildRouter(c *services.Container) error {
|
||||
// Static files with proper cache control
|
||||
// funcmap.File() should be used in templates to append a cache key to the URL in order to break cache
|
||||
// after each server restart
|
||||
// Static files with proper cache control.
|
||||
// ui.File() should be used in ui components to append a cache key to the URL in order to break cache
|
||||
// after each server restart.
|
||||
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
|
||||
Static(config.StaticPrefix, config.StaticDir)
|
||||
|
||||
// Non-static file route group
|
||||
// Non-static file route group.
|
||||
g := c.Web.Group("")
|
||||
|
||||
// Force HTTPS, if enabled
|
||||
// Force HTTPS, if enabled.
|
||||
if c.Config.HTTP.TLS.Enabled {
|
||||
g.Use(echomw.HTTPSRedirect())
|
||||
}
|
||||
|
||||
// Create a cookie store for session data
|
||||
// Create a cookie store for session data.
|
||||
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
|
||||
cookieStore.Options.HttpOnly = true
|
||||
cookieStore.Options.Secure = true
|
||||
|
|
@ -45,22 +46,22 @@ 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),
|
||||
middleware.ServeCachedPage(c.TemplateRenderer),
|
||||
echomw.CSRFWithConfig(echomw.CSRFConfig{
|
||||
TokenLookup: "form:csrf",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSecure: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
ContextKey: context.CSRFKey,
|
||||
}),
|
||||
)
|
||||
|
||||
// Error handler
|
||||
err := Error{c.TemplateRenderer}
|
||||
c.Web.HTTPErrorHandler = err.Page
|
||||
// Error handler.
|
||||
c.Web.HTTPErrorHandler = new(Error).Page
|
||||
|
||||
// Initialize and register all handlers
|
||||
// Initialize and register all handlers.
|
||||
for _, h := range GetHandlers() {
|
||||
if err := h.Init(c); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -5,56 +5,40 @@ import (
|
|||
"math/rand"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
)
|
||||
|
||||
const routeNameSearch = "search"
|
||||
|
||||
type (
|
||||
Search struct {
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
searchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
)
|
||||
type Search struct{}
|
||||
|
||||
func init() {
|
||||
Register(new(Search))
|
||||
}
|
||||
|
||||
func (h *Search) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Search) Routes(g *echo.Group) {
|
||||
g.GET("/search", h.Page).Name = routeNameSearch
|
||||
g.GET("/search", h.Page).Name = routenames.Search
|
||||
}
|
||||
|
||||
func (h *Search) Page(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageSearch
|
||||
|
||||
// Fake search results
|
||||
var results []searchResult
|
||||
// Fake search results.
|
||||
results := make([]*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, searchResult{
|
||||
results = append(results, &models.SearchResult{
|
||||
Title: title,
|
||||
URL: fmt.Sprintf("https://www.%s.com", search),
|
||||
})
|
||||
}
|
||||
}
|
||||
p.Data = results
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.SearchResults(ctx, results)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,64 +2,45 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/backlite"
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"time"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/pages"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
"github.com/mikestefanello/pagoda/pkg/tasks"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
)
|
||||
|
||||
const (
|
||||
routeNameTask = "task"
|
||||
routeNameTaskSubmit = "task.submit"
|
||||
)
|
||||
|
||||
type (
|
||||
Task struct {
|
||||
tasks *backlite.Client
|
||||
*services.TemplateRenderer
|
||||
}
|
||||
|
||||
taskForm struct {
|
||||
Delay int `form:"delay" validate:"gte=0"`
|
||||
Message string `form:"message" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
)
|
||||
type Task struct {
|
||||
tasks *backlite.Client
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(new(Task))
|
||||
}
|
||||
|
||||
func (h *Task) Init(c *services.Container) error {
|
||||
h.TemplateRenderer = c.TemplateRenderer
|
||||
h.tasks = c.Tasks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Task) Routes(g *echo.Group) {
|
||||
g.GET("/task", h.Page).Name = routeNameTask
|
||||
g.POST("/task", h.Submit).Name = routeNameTaskSubmit
|
||||
g.GET("/task", h.Page).Name = routenames.Task
|
||||
g.POST("/task", h.Submit).Name = routenames.TaskSubmit
|
||||
}
|
||||
|
||||
func (h *Task) Page(ctx echo.Context) error {
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutMain
|
||||
p.Name = templates.PageTask
|
||||
p.Title = "Create a task"
|
||||
p.Form = form.Get[taskForm](ctx)
|
||||
|
||||
return h.RenderPage(ctx, p)
|
||||
return pages.AddTask(ctx, form.Get[forms.Task](ctx))
|
||||
}
|
||||
|
||||
func (h *Task) Submit(ctx echo.Context) error {
|
||||
var input taskForm
|
||||
var input forms.Task
|
||||
|
||||
err := form.Submit(ctx, &input)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
)
|
||||
|
||||
// Request headers: https://htmx.org/docs/#request-headers
|
||||
|
|
@ -28,7 +29,7 @@ const (
|
|||
)
|
||||
|
||||
type (
|
||||
// Request contains data that HTMX provides during requests
|
||||
// Request contains data that HTMX provides during requests.
|
||||
Request struct {
|
||||
Enabled bool
|
||||
Boosted bool
|
||||
|
|
@ -39,7 +40,7 @@ type (
|
|||
Prompt string
|
||||
}
|
||||
|
||||
// Response contain data that the server can communicate back to HTMX
|
||||
// Response contain data that the server can communicate back to HTMX.
|
||||
Response struct {
|
||||
PushURL string
|
||||
Redirect string
|
||||
|
|
@ -52,20 +53,22 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
// GetRequest extracts HTMX data from the request
|
||||
func GetRequest(ctx echo.Context) Request {
|
||||
return Request{
|
||||
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
|
||||
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
|
||||
Trigger: ctx.Request().Header.Get(HeaderTrigger),
|
||||
TriggerName: ctx.Request().Header.Get(HeaderTriggerName),
|
||||
Target: ctx.Request().Header.Get(HeaderTarget),
|
||||
Prompt: ctx.Request().Header.Get(HeaderPrompt),
|
||||
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true",
|
||||
}
|
||||
// GetRequest extracts HTMX data from the request,
|
||||
func GetRequest(ctx echo.Context) *Request {
|
||||
return context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
|
||||
return &Request{
|
||||
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
|
||||
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
|
||||
Trigger: ctx.Request().Header.Get(HeaderTrigger),
|
||||
TriggerName: ctx.Request().Header.Get(HeaderTriggerName),
|
||||
Target: ctx.Request().Header.Get(HeaderTarget),
|
||||
Prompt: ctx.Request().Header.Get(HeaderPrompt),
|
||||
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Apply applies data from a Response to a server response
|
||||
// Apply applies data from a Response to a server response.
|
||||
func (r Response) Apply(ctx echo.Context) {
|
||||
if r.PushURL != "" {
|
||||
ctx.Response().Header().Set(HeaderPushURL, r.PushURL)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -29,6 +30,11 @@ func TestSetRequest(t *testing.T) {
|
|||
assert.Equal(t, "b", r.TriggerName)
|
||||
assert.Equal(t, "c", r.Target)
|
||||
assert.Equal(t, "d", r.Prompt)
|
||||
|
||||
cached := context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
|
||||
return nil
|
||||
})
|
||||
assert.Equal(t, r, cached)
|
||||
}
|
||||
|
||||
func TestResponse_Apply(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import (
|
|||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
)
|
||||
|
||||
// Set sets a logger in the context
|
||||
// Set sets a logger in the context.
|
||||
func Set(ctx echo.Context, logger *slog.Logger) {
|
||||
ctx.Set(context.LoggerKey, logger)
|
||||
}
|
||||
|
||||
// Ctx returns the logger stored in context, or provides the default logger if one is not present
|
||||
// Ctx returns the logger stored in context, or provides the default logger if one is not present.
|
||||
func Ctx(ctx echo.Context) *slog.Logger {
|
||||
if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok {
|
||||
return l
|
||||
|
|
@ -21,7 +21,7 @@ func Ctx(ctx echo.Context) *slog.Logger {
|
|||
return Default()
|
||||
}
|
||||
|
||||
// Default returns the default logger
|
||||
// Default returns the default logger.
|
||||
func Default() *slog.Logger {
|
||||
return slog.Default()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,13 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
"github.com/mikestefanello/pagoda/pkg/services"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// ServeCachedPage attempts to load a page from the cache by matching on the complete request URL
|
||||
// If a page is cached for the requested URL, it will be served here and the request terminated.
|
||||
// Any request made by an authenticated user or that is not a GET will be skipped.
|
||||
func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
// Skip non GET requests
|
||||
if ctx.Request().Method != http.MethodGet {
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
// Skip if the user is authenticated
|
||||
if ctx.Get(context.AuthenticatedUserKey) != nil {
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
// Attempt to load from cache
|
||||
page, err := t.GetCachedPage(ctx, ctx.Request().URL.String())
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrCacheMiss):
|
||||
case context.IsCanceledError(err):
|
||||
return nil
|
||||
default:
|
||||
log.Ctx(ctx).Error("failed getting cached page",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
// Set any headers
|
||||
if page.Headers != nil {
|
||||
for k, v := range page.Headers {
|
||||
ctx.Response().Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
log.Ctx(ctx).Debug("serving cached page")
|
||||
|
||||
return ctx.HTMLBlob(page.StatusCode, page.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -1,52 +1,14 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/tests"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestServeCachedPage(t *testing.T) {
|
||||
// Cache a page
|
||||
ctx, rec := tests.NewContext(c.Web, "/cache")
|
||||
p := page.New(ctx)
|
||||
p.Layout = templates.LayoutHTMX
|
||||
p.Name = templates.PageHome
|
||||
p.Cache.Enabled = true
|
||||
p.Cache.Expiration = time.Minute
|
||||
p.StatusCode = http.StatusCreated
|
||||
p.Headers["a"] = "b"
|
||||
p.Headers["c"] = "d"
|
||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
||||
output := rec.Body.Bytes()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request the URL of the cached page
|
||||
ctx, rec = tests.NewContext(c.Web, "/cache")
|
||||
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, p.StatusCode, ctx.Response().Status)
|
||||
assert.Equal(t, p.Headers["a"], ctx.Response().Header().Get("a"))
|
||||
assert.Equal(t, p.Headers["c"], ctx.Response().Header().Get("c"))
|
||||
assert.Equal(t, output, rec.Body.Bytes())
|
||||
|
||||
// Login and try again
|
||||
tests.InitSession(ctx)
|
||||
err = c.Auth.Login(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
_ = tests.ExecuteMiddleware(ctx, LoadAuthenticatedUser(c.Auth))
|
||||
err = tests.ExecuteMiddleware(ctx, ServeCachedPage(c.TemplateRenderer))
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestCacheControl(t *testing.T) {
|
||||
ctx, _ := tests.NewContext(c.Web, "/")
|
||||
_ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5))
|
||||
|
|
|
|||
17
pkg/middleware/config.go
Normal file
17
pkg/middleware/config.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
pkg/middleware/config_test.go
Normal file
22
pkg/middleware/config_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,44 +7,44 @@ import (
|
|||
"github.com/mikestefanello/pagoda/pkg/session"
|
||||
)
|
||||
|
||||
// Type is a message type
|
||||
// Type is a message type.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// TypeSuccess represents a success message type
|
||||
// TypeSuccess represents a success message type.
|
||||
TypeSuccess Type = "success"
|
||||
|
||||
// TypeInfo represents a info message type
|
||||
// TypeInfo represents a info message type.
|
||||
TypeInfo Type = "info"
|
||||
|
||||
// TypeWarning represents a warning message type
|
||||
// TypeWarning represents a warning message type.
|
||||
TypeWarning Type = "warning"
|
||||
|
||||
// TypeDanger represents a danger message type
|
||||
// TypeDanger represents a danger message type.
|
||||
TypeDanger Type = "danger"
|
||||
)
|
||||
|
||||
const (
|
||||
// sessionName stores the name of the session which contains flash messages
|
||||
// sessionName stores the name of the session which contains flash messages.
|
||||
sessionName = "msg"
|
||||
)
|
||||
|
||||
// Success sets a success flash message
|
||||
// Success sets a success flash message.
|
||||
func Success(ctx echo.Context, message string) {
|
||||
Set(ctx, TypeSuccess, message)
|
||||
}
|
||||
|
||||
// Info sets an info flash message
|
||||
// Info sets an info flash message.
|
||||
func Info(ctx echo.Context, message string) {
|
||||
Set(ctx, TypeInfo, message)
|
||||
}
|
||||
|
||||
// Warning sets a warning flash message
|
||||
// Warning sets a warning flash message.
|
||||
func Warning(ctx echo.Context, message string) {
|
||||
Set(ctx, TypeWarning, message)
|
||||
}
|
||||
|
||||
// Danger sets a danger flash message
|
||||
// Danger sets a danger flash message.
|
||||
func Danger(ctx echo.Context, message string) {
|
||||
Set(ctx, TypeDanger, message)
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ func Get(ctx echo.Context, typ Type) []string {
|
|||
return msgs
|
||||
}
|
||||
|
||||
// getSession gets the flash message session
|
||||
// getSession gets the flash message session.
|
||||
func getSession(ctx echo.Context) (*sessions.Session, error) {
|
||||
sess, err := session.Get(ctx, sessionName)
|
||||
if err != nil {
|
||||
|
|
@ -87,7 +87,7 @@ func getSession(ctx echo.Context) (*sessions.Session, error) {
|
|||
return sess, err
|
||||
}
|
||||
|
||||
// save saves the flash message session
|
||||
// save saves the flash message session.
|
||||
func save(ctx echo.Context, sess *sessions.Session) {
|
||||
if err := sess.Save(ctx.Request(), ctx.Response()); err != nil {
|
||||
log.Ctx(ctx).Error("failed to set flash message",
|
||||
|
|
|
|||
162
pkg/page/page.go
162
pkg/page/page.go
|
|
@ -1,162 +0,0 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/pagoda/ent"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/htmx"
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
|
||||
echomw "github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Page consists of all data that will be used to render a page response for a given route.
|
||||
// While it's not required for a handler to render a Page on a route, this is the common data
|
||||
// object that will be passed to the templates, making it easy for all handlers to share
|
||||
// functionality both on the back and frontend. The Page can be expanded to include anything else
|
||||
// your app wants to support.
|
||||
// Methods on this page also then become available in the templates, which can be more useful than
|
||||
// the funcmap if your methods require data stored in the page, such as the context.
|
||||
type Page struct {
|
||||
// AppName stores the name of the application.
|
||||
// If omitted, the configuration value will be used.
|
||||
AppName string
|
||||
|
||||
// Title stores the title of the page
|
||||
Title string
|
||||
|
||||
// Context stores the request context
|
||||
Context echo.Context
|
||||
|
||||
// Path stores the path of the current request
|
||||
Path string
|
||||
|
||||
// URL stores the URL of the current request
|
||||
URL string
|
||||
|
||||
// Data stores whatever additional data that needs to be passed to the templates.
|
||||
// This is what the handler uses to pass the content of the page.
|
||||
Data any
|
||||
|
||||
// Form stores a struct that represents a form on the page.
|
||||
// This should be a struct with fields for each form field, using both "form" and "validate" tags
|
||||
// It should also contain form.FormSubmission if you wish to have validation
|
||||
// messages and markup presented to the user
|
||||
Form any
|
||||
|
||||
// Layout stores the name of the layout base template file which will be used when the page is rendered.
|
||||
// This should match a template file located within the layouts directory inside the templates directory.
|
||||
// The template extension should not be included in this value.
|
||||
Layout templates.Layout
|
||||
|
||||
// Name stores the name of the page as well as the name of the template file which will be used to render
|
||||
// the content portion of the layout template.
|
||||
// This should match a template file located within the pages directory inside the templates directory.
|
||||
// The template extension should not be included in this value.
|
||||
Name templates.Page
|
||||
|
||||
// IsHome stores whether the requested page is the home page or not
|
||||
IsHome bool
|
||||
|
||||
// IsAuth stores whether the user is authenticated
|
||||
IsAuth bool
|
||||
|
||||
// AuthUser stores the authenticated user
|
||||
AuthUser *ent.User
|
||||
|
||||
// StatusCode stores the HTTP status code that will be returned
|
||||
StatusCode int
|
||||
|
||||
// Metatags stores metatag values
|
||||
Metatags struct {
|
||||
// Description stores the description metatag value
|
||||
Description string
|
||||
|
||||
// Keywords stores the keywords metatag values
|
||||
Keywords []string
|
||||
}
|
||||
|
||||
// Pager stores a pager which can be used to page lists of results
|
||||
Pager Pager
|
||||
|
||||
// CSRF stores the CSRF token for the given request.
|
||||
// This will only be populated if the CSRF middleware is in effect for the given request.
|
||||
// If this is populated, all forms must include this value otherwise the requests will be rejected.
|
||||
CSRF string
|
||||
|
||||
// Headers stores a list of HTTP headers and values to be set on the response
|
||||
Headers map[string]string
|
||||
|
||||
// RequestID stores the ID of the given request.
|
||||
// This will only be populated if the request ID middleware is in effect for the given request.
|
||||
RequestID string
|
||||
|
||||
// HTMX provides the ability to interact with the HTMX library
|
||||
HTMX struct {
|
||||
// Request contains the information provided by HTMX about the current request
|
||||
Request htmx.Request
|
||||
|
||||
// Response contains values to pass back to HTMX
|
||||
Response *htmx.Response
|
||||
}
|
||||
|
||||
// Cache stores values for caching the response of this page
|
||||
Cache struct {
|
||||
// Enabled dictates if the response of this page should be cached.
|
||||
// Cached responses are served via middleware.
|
||||
Enabled bool
|
||||
|
||||
// Expiration stores the amount of time that the cache entry should live for before expiring.
|
||||
// If omitted, the configuration value will be used.
|
||||
Expiration time.Duration
|
||||
|
||||
// Tags stores a list of tags to apply to the cache entry.
|
||||
// These are useful when invalidating cache for dynamic events such as entity operations.
|
||||
Tags []string
|
||||
}
|
||||
}
|
||||
|
||||
// New creates and initiatizes a new Page for a given request context
|
||||
func New(ctx echo.Context) Page {
|
||||
p := Page{
|
||||
Context: ctx,
|
||||
Path: ctx.Request().URL.Path,
|
||||
URL: ctx.Request().URL.String(),
|
||||
StatusCode: http.StatusOK,
|
||||
Pager: NewPager(ctx, DefaultItemsPerPage),
|
||||
Headers: make(map[string]string),
|
||||
RequestID: ctx.Response().Header().Get(echo.HeaderXRequestID),
|
||||
}
|
||||
|
||||
p.IsHome = p.Path == "/"
|
||||
|
||||
if csrf := ctx.Get(echomw.DefaultCSRFConfig.ContextKey); csrf != nil {
|
||||
p.CSRF = csrf.(string)
|
||||
}
|
||||
|
||||
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
|
||||
p.IsAuth = true
|
||||
p.AuthUser = u.(*ent.User)
|
||||
}
|
||||
|
||||
p.HTMX.Request = htmx.GetRequest(ctx)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// GetMessages gets all flash messages for a given type.
|
||||
// This allows for easy access to flash messages from the templates.
|
||||
func (p Page) GetMessages(typ msg.Type) []template.HTML {
|
||||
strs := msg.Get(p.Context, typ)
|
||||
ret := make([]template.HTML, len(strs))
|
||||
for k, v := range strs {
|
||||
ret[k] = template.HTML(v)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package page
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/ent"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/pkg/tests"
|
||||
|
||||
echomw "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
e := echo.New()
|
||||
ctx, _ := tests.NewContext(e, "/")
|
||||
p := New(ctx)
|
||||
assert.Same(t, ctx, p.Context)
|
||||
assert.Equal(t, "/", p.Path)
|
||||
assert.Equal(t, "/", p.URL)
|
||||
assert.Equal(t, http.StatusOK, p.StatusCode)
|
||||
assert.Equal(t, NewPager(ctx, DefaultItemsPerPage), p.Pager)
|
||||
assert.Empty(t, p.Headers)
|
||||
assert.True(t, p.IsHome)
|
||||
assert.False(t, p.IsAuth)
|
||||
assert.Empty(t, p.CSRF)
|
||||
assert.Empty(t, p.RequestID)
|
||||
assert.False(t, p.Cache.Enabled)
|
||||
|
||||
ctx, _ = tests.NewContext(e, "/abc?def=123")
|
||||
usr := &ent.User{
|
||||
ID: 1,
|
||||
}
|
||||
ctx.Set(context.AuthenticatedUserKey, usr)
|
||||
ctx.Set(echomw.DefaultCSRFConfig.ContextKey, "csrf")
|
||||
p = New(ctx)
|
||||
assert.Equal(t, "/abc", p.Path)
|
||||
assert.Equal(t, "/abc?def=123", p.URL)
|
||||
assert.False(t, p.IsHome)
|
||||
assert.True(t, p.IsAuth)
|
||||
assert.Equal(t, usr, p.AuthUser)
|
||||
assert.Equal(t, "csrf", p.CSRF)
|
||||
}
|
||||
|
||||
func TestPage_GetMessages(t *testing.T) {
|
||||
ctx, _ := tests.NewContext(echo.New(), "/")
|
||||
tests.InitSession(ctx)
|
||||
p := New(ctx)
|
||||
|
||||
// Set messages
|
||||
msgTests := make(map[msg.Type][]string)
|
||||
msgTests[msg.TypeWarning] = []string{
|
||||
"abc",
|
||||
"def",
|
||||
}
|
||||
msgTests[msg.TypeInfo] = []string{
|
||||
"123",
|
||||
"456",
|
||||
}
|
||||
for typ, values := range msgTests {
|
||||
for _, value := range values {
|
||||
msg.Set(ctx, typ, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the messages
|
||||
for typ, values := range msgTests {
|
||||
msgs := p.GetMessages(typ)
|
||||
|
||||
for i, message := range msgs {
|
||||
assert.Equal(t, values[i], string(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package page
|
||||
package pager
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
|
@ -7,30 +7,25 @@ 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.
|
||||
const QueryKey = "page"
|
||||
|
||||
// PageQueryKey stores the query key used to indicate the current page
|
||||
PageQueryKey = "page"
|
||||
)
|
||||
|
||||
// Pager provides a mechanism to allow a user to page results via a query parameter
|
||||
// Pager provides a mechanism to allow a user to page results via a query parameter.
|
||||
type Pager struct {
|
||||
// Items stores the total amount of items in the result set
|
||||
// Items stores the total amount of items in the result set.
|
||||
Items int
|
||||
|
||||
// Page stores the current page number
|
||||
// Page stores the current page number.
|
||||
Page int
|
||||
|
||||
// ItemsPerPage stores the amount of items to display per page
|
||||
// ItemsPerPage stores the amount of items to display per page.
|
||||
ItemsPerPage int
|
||||
|
||||
// Pages stores the total amount of pages in the result set
|
||||
// Pages stores the total amount of pages in the result set.
|
||||
Pages int
|
||||
}
|
||||
|
||||
// NewPager creates a new Pager
|
||||
// NewPager creates a new Pager.
|
||||
func NewPager(ctx echo.Context, itemsPerPage int) Pager {
|
||||
p := Pager{
|
||||
ItemsPerPage: itemsPerPage,
|
||||
|
|
@ -38,7 +33,7 @@ func NewPager(ctx echo.Context, itemsPerPage int) Pager {
|
|||
Page: 1,
|
||||
}
|
||||
|
||||
if page := ctx.QueryParam(PageQueryKey); page != "" {
|
||||
if page := ctx.QueryParam(QueryKey); page != "" {
|
||||
if pageInt, err := strconv.Atoi(page); err == nil {
|
||||
if pageInt > 0 {
|
||||
p.Page = pageInt
|
||||
|
|
@ -67,18 +62,18 @@ func (p *Pager) SetItems(items int) {
|
|||
}
|
||||
|
||||
// IsBeginning determines if the pager is at the beginning of the pages
|
||||
func (p Pager) IsBeginning() bool {
|
||||
func (p *Pager) IsBeginning() bool {
|
||||
return p.Page == 1
|
||||
}
|
||||
|
||||
// IsEnd determines if the pager is at the end of the pages
|
||||
func (p Pager) IsEnd() bool {
|
||||
func (p *Pager) IsEnd() bool {
|
||||
return p.Page >= p.Pages
|
||||
}
|
||||
|
||||
// GetOffset determines the offset of the results in order to get the items for
|
||||
// the current page
|
||||
func (p Pager) GetOffset() int {
|
||||
func (p *Pager) GetOffset() int {
|
||||
if p.Page == 0 {
|
||||
p.Page = 1
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package page
|
||||
package pager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -19,11 +19,11 @@ func TestNewPager(t *testing.T) {
|
|||
assert.Equal(t, 0, pgr.Items)
|
||||
assert.Equal(t, 1, pgr.Pages)
|
||||
|
||||
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, 2))
|
||||
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, 2))
|
||||
pgr = NewPager(ctx, 10)
|
||||
assert.Equal(t, 2, pgr.Page)
|
||||
|
||||
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", PageQueryKey, -2))
|
||||
ctx, _ = tests.NewContext(e, fmt.Sprintf("/abc?%s=%d", QueryKey, -2))
|
||||
pgr = NewPager(ctx, 10)
|
||||
assert.Equal(t, 1, pgr.Page)
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ type Redirect struct {
|
|||
func New(ctx echo.Context) *Redirect {
|
||||
return &Redirect{
|
||||
ctx: ctx,
|
||||
status: http.StatusFound,
|
||||
status: http.StatusTemporaryRedirect,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
25
pkg/routenames/names.go
Normal file
25
pkg/routenames/names.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package routenames
|
||||
|
||||
const (
|
||||
Home = "home"
|
||||
About = "about"
|
||||
Contact = "contact"
|
||||
ContactSubmit = "contact.submit"
|
||||
Login = "login"
|
||||
LoginSubmit = "login.submit"
|
||||
Register = "register"
|
||||
RegisterSubmit = "register.submit"
|
||||
ForgotPassword = "forgot_password"
|
||||
ForgotPasswordSubmit = "forgot_password.submit"
|
||||
Logout = "logout"
|
||||
VerifyEmail = "verify_email"
|
||||
ResetPassword = "reset_password"
|
||||
ResetPasswordSubmit = "reset_password.submit"
|
||||
Search = "search"
|
||||
Task = "task"
|
||||
TaskSubmit = "task.submit"
|
||||
Cache = "cache"
|
||||
CacheSubmit = "cache.submit"
|
||||
Files = "files"
|
||||
FilesSubmit = "files.submit"
|
||||
)
|
||||
|
|
@ -16,51 +16,47 @@ import (
|
|||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/ent"
|
||||
"github.com/mikestefanello/pagoda/pkg/funcmap"
|
||||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
|
||||
// Require by ent
|
||||
// Required by ent.
|
||||
_ "github.com/mikestefanello/pagoda/ent/runtime"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// TemplateRenderer stores a service to easily render and cache templates
|
||||
TemplateRenderer *TemplateRenderer
|
||||
|
||||
// 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()
|
||||
|
|
@ -71,7 +67,6 @@ func NewContainer() *Container {
|
|||
c.initFiles()
|
||||
c.initORM()
|
||||
c.initAuth()
|
||||
c.initTemplateRenderer()
|
||||
c.initMail()
|
||||
c.initTasks()
|
||||
return c
|
||||
|
|
@ -107,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 {
|
||||
|
|
@ -115,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)
|
||||
|
|
@ -124,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 {
|
||||
|
|
@ -146,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
|
||||
|
|
@ -180,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))
|
||||
|
|
@ -191,30 +186,25 @@ 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)
|
||||
}
|
||||
|
||||
// initTemplateRenderer initializes the template renderer
|
||||
func (c *Container) initTemplateRenderer() {
|
||||
c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Cache, funcmap.NewFuncMap(c.Web))
|
||||
}
|
||||
|
||||
// initMail initialize the mail client
|
||||
// initMail initialize the mail client.
|
||||
func (c *Container) initMail() {
|
||||
var err error
|
||||
c.Mail, err = NewMailClient(c.Config, c.TemplateRenderer)
|
||||
c.Mail, err = NewMailClient(c.Config)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create mail client: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 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(),
|
||||
|
|
@ -232,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, "/")
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,5 @@ func TestNewContainer(t *testing.T) {
|
|||
assert.NotNil(t, c.ORM)
|
||||
assert.NotNil(t, c.Mail)
|
||||
assert.NotNil(t, c.Auth)
|
||||
assert.NotNil(t, c.TemplateRenderer)
|
||||
assert.NotNil(t, c.Tasks)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
"maragu.dev/gomponents"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
|
@ -14,36 +15,31 @@ type (
|
|||
// MailClient provides a client for sending email
|
||||
// This is purposely not completed because there are many different methods and services
|
||||
// for sending email, many of which are very different. Choose what works best for you
|
||||
// and populate the methods below
|
||||
// and populate the methods below. For now, emails will just be logged.
|
||||
MailClient struct {
|
||||
// config stores application configuration
|
||||
// config stores application configuration.
|
||||
config *config.Config
|
||||
|
||||
// templates stores the template renderer
|
||||
templates *TemplateRenderer
|
||||
}
|
||||
|
||||
// mail represents an email to be sent
|
||||
// mail represents an email to be sent.
|
||||
mail struct {
|
||||
client *MailClient
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
body string
|
||||
template string
|
||||
templateData any
|
||||
client *MailClient
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
body string
|
||||
component gomponents.Node
|
||||
}
|
||||
)
|
||||
|
||||
// NewMailClient creates a new MailClient
|
||||
func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient, error) {
|
||||
// NewMailClient creates a new MailClient.
|
||||
func NewMailClient(cfg *config.Config) (*MailClient, error) {
|
||||
return &MailClient{
|
||||
config: cfg,
|
||||
templates: templates,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compose creates a new email
|
||||
// Compose creates a new email.
|
||||
func (m *MailClient) Compose() *mail {
|
||||
return &mail{
|
||||
client: m,
|
||||
|
|
@ -51,39 +47,33 @@ func (m *MailClient) Compose() *mail {
|
|||
}
|
||||
}
|
||||
|
||||
// skipSend determines if mail sending should be skipped
|
||||
// skipSend determines if mail sending should be skipped.
|
||||
func (m *MailClient) skipSend() bool {
|
||||
return m.config.App.Environment != config.EnvProduction
|
||||
}
|
||||
|
||||
// send attempts to send the email
|
||||
// send attempts to send the email.
|
||||
func (m *MailClient) send(email *mail, ctx echo.Context) error {
|
||||
switch {
|
||||
case email.to == "":
|
||||
return errors.New("email cannot be sent without a to address")
|
||||
case email.body == "" && email.template == "":
|
||||
return errors.New("email cannot be sent without a body or template")
|
||||
case email.body == "" && email.component == nil:
|
||||
return errors.New("email cannot be sent without a body or component to render")
|
||||
}
|
||||
|
||||
// Check if a template was supplied
|
||||
if email.template != "" {
|
||||
// Parse and execute template
|
||||
buf, err := m.templates.
|
||||
Parse().
|
||||
Group("mail").
|
||||
Key(email.template).
|
||||
Base(email.template).
|
||||
Files(fmt.Sprintf("emails/%s", email.template)).
|
||||
Execute(email.templateData)
|
||||
|
||||
if err != nil {
|
||||
// Check if a component was supplied.
|
||||
if email.component != nil {
|
||||
// Render the component and use as the body.
|
||||
// TODO pool the buffers?
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := email.component.Render(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
email.body = buf.String()
|
||||
}
|
||||
|
||||
// Check if mail sending should be skipped
|
||||
// Check if mail sending should be skipped.
|
||||
if m.skipSend() {
|
||||
log.Ctx(ctx).Debug("skipping email delivery",
|
||||
"to", email.to,
|
||||
|
|
@ -91,52 +81,47 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// TODO: Finish based on your mail sender of choice!
|
||||
// TODO: Finish based on your mail sender of choice or stop logging below!
|
||||
log.Ctx(ctx).Info("sending email",
|
||||
"to", email.to,
|
||||
"subject", email.subject,
|
||||
"body", email.body,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// From sets the email from address
|
||||
// From sets the email from address.
|
||||
func (m *mail) From(from string) *mail {
|
||||
m.from = from
|
||||
return m
|
||||
}
|
||||
|
||||
// To sets the email address this email will be sent to
|
||||
// To sets the email address this email will be sent to.
|
||||
func (m *mail) To(to string) *mail {
|
||||
m.to = to
|
||||
return m
|
||||
}
|
||||
|
||||
// Subject sets the subject line of the email
|
||||
// Subject sets the subject line of the email.
|
||||
func (m *mail) Subject(subject string) *mail {
|
||||
m.subject = subject
|
||||
return m
|
||||
}
|
||||
|
||||
// Body sets the body of the email
|
||||
// This is not required and will be ignored if a template via Template()
|
||||
// Body sets the body of the email.
|
||||
// This is not required and will be ignored if a component is set via Component().
|
||||
func (m *mail) Body(body string) *mail {
|
||||
m.body = body
|
||||
return m
|
||||
}
|
||||
|
||||
// Template sets the template to be used to produce the body of the email
|
||||
// The template name should only include the filename without the extension or directory.
|
||||
// The template must reside within the emails sub-directory.
|
||||
// The funcmap will be automatically added to the template.
|
||||
// Use TemplateData() to supply the data that will be passed in to the template.
|
||||
func (m *mail) Template(template string) *mail {
|
||||
m.template = template
|
||||
// Component sets a renderable component to use as the body of the email.
|
||||
func (m *mail) Component(component gomponents.Node) *mail {
|
||||
m.component = component
|
||||
return m
|
||||
}
|
||||
|
||||
// TemplateData sets the data that will be passed to the template specified when calling Template()
|
||||
func (m *mail) TemplateData(data any) *mail {
|
||||
m.templateData = data
|
||||
return m
|
||||
}
|
||||
|
||||
// Send attempts to send the email
|
||||
// Send attempts to send the email.
|
||||
func (m *mail) Send(ctx echo.Context) error {
|
||||
return m.client.send(m, ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,376 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/log"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
)
|
||||
|
||||
// cachedPageGroup stores the cache group for cached pages
|
||||
const cachedPageGroup = "page"
|
||||
|
||||
type (
|
||||
// TemplateRenderer provides a flexible and easy to use method of rendering simple templates or complex sets of
|
||||
// templates while also providing caching and/or hot-reloading depending on your current environment
|
||||
TemplateRenderer struct {
|
||||
// templateCache stores a cache of parsed page templates
|
||||
templateCache sync.Map
|
||||
|
||||
// funcMap stores the template function map
|
||||
funcMap template.FuncMap
|
||||
|
||||
// config stores application configuration
|
||||
config *config.Config
|
||||
|
||||
// cache stores the cache client
|
||||
cache *CacheClient
|
||||
}
|
||||
|
||||
// TemplateParsed is a wrapper around parsed templates which are stored in the TemplateRenderer cache
|
||||
TemplateParsed struct {
|
||||
// Template is the parsed template
|
||||
Template *template.Template
|
||||
|
||||
// build stores the build data used to parse the template
|
||||
build *templateBuild
|
||||
}
|
||||
|
||||
// templateBuild stores the build data used to parse a template
|
||||
templateBuild struct {
|
||||
group string
|
||||
key string
|
||||
base string
|
||||
files []string
|
||||
directories []string
|
||||
}
|
||||
|
||||
// templateBuilder handles chaining a template parse operation
|
||||
templateBuilder struct {
|
||||
build *templateBuild
|
||||
renderer *TemplateRenderer
|
||||
}
|
||||
|
||||
// CachedPage is what is used to store a rendered Page in the cache
|
||||
CachedPage struct {
|
||||
// URL stores the URL of the requested page
|
||||
URL string
|
||||
|
||||
// HTML stores the complete HTML of the rendered Page
|
||||
HTML []byte
|
||||
|
||||
// StatusCode stores the HTTP status code
|
||||
StatusCode int
|
||||
|
||||
// Headers stores the HTTP headers
|
||||
Headers map[string]string
|
||||
}
|
||||
)
|
||||
|
||||
// NewTemplateRenderer creates a new TemplateRenderer
|
||||
func NewTemplateRenderer(cfg *config.Config, cache *CacheClient, fm template.FuncMap) *TemplateRenderer {
|
||||
return &TemplateRenderer{
|
||||
templateCache: sync.Map{},
|
||||
funcMap: fm,
|
||||
config: cfg,
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse creates a template build operation
|
||||
func (t *TemplateRenderer) Parse() *templateBuilder {
|
||||
return &templateBuilder{
|
||||
renderer: t,
|
||||
build: &templateBuild{},
|
||||
}
|
||||
}
|
||||
|
||||
// RenderPage renders a Page as an HTTP response
|
||||
func (t *TemplateRenderer) RenderPage(ctx echo.Context, page page.Page) error {
|
||||
var buf *bytes.Buffer
|
||||
var err error
|
||||
templateGroup := "page"
|
||||
|
||||
// Page name is required
|
||||
if page.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "page render failed due to missing name")
|
||||
}
|
||||
|
||||
// Use the app name in configuration if a value was not set
|
||||
if page.AppName == "" {
|
||||
page.AppName = t.config.App.Name
|
||||
}
|
||||
|
||||
// Check if this is an HTMX non-boosted request which indicates that only partial
|
||||
// content should be rendered
|
||||
if page.HTMX.Request.Enabled && !page.HTMX.Request.Boosted {
|
||||
// Switch the layout which will only render the page content
|
||||
page.Layout = templates.LayoutHTMX
|
||||
|
||||
// Alter the template group so this is cached separately
|
||||
templateGroup = "page:htmx"
|
||||
}
|
||||
|
||||
// Parse and execute the templates for the Page
|
||||
// As mentioned in the documentation for the Page struct, the templates used for the page will be:
|
||||
// 1. The layout/base template specified in Page.Layout
|
||||
// 2. The content template specified in Page.Name
|
||||
// 3. All templates within the components directory
|
||||
// Also included is the function map provided by the funcmap package
|
||||
buf, err = t.
|
||||
Parse().
|
||||
Group(templateGroup).
|
||||
Key(string(page.Name)).
|
||||
Base(string(page.Layout)).
|
||||
Files(
|
||||
fmt.Sprintf("layouts/%s", page.Layout),
|
||||
fmt.Sprintf("pages/%s", page.Name),
|
||||
).
|
||||
Directories("components").
|
||||
Execute(page)
|
||||
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(
|
||||
http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to parse and execute templates: %s", err),
|
||||
)
|
||||
}
|
||||
|
||||
// Set the status code
|
||||
ctx.Response().Status = page.StatusCode
|
||||
|
||||
// Set any headers
|
||||
for k, v := range page.Headers {
|
||||
ctx.Response().Header().Set(k, v)
|
||||
}
|
||||
|
||||
// Apply the HTMX response, if one
|
||||
if page.HTMX.Response != nil {
|
||||
page.HTMX.Response.Apply(ctx)
|
||||
}
|
||||
|
||||
// Cache this page, if caching was enabled
|
||||
t.cachePage(ctx, page, buf)
|
||||
|
||||
return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes())
|
||||
}
|
||||
|
||||
// cachePage caches the HTML for a given Page if the Page has caching enabled
|
||||
func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *bytes.Buffer) {
|
||||
if !page.Cache.Enabled || page.IsAuth {
|
||||
return
|
||||
}
|
||||
|
||||
// If no expiration time was provided, default to the configuration value
|
||||
if page.Cache.Expiration == 0 {
|
||||
page.Cache.Expiration = t.config.Cache.Expiration.Page
|
||||
}
|
||||
|
||||
// Extract the headers
|
||||
headers := make(map[string]string)
|
||||
for k, v := range ctx.Response().Header() {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
|
||||
// The request URL is used as the cache key so the middleware can serve the
|
||||
// cached page on matching requests
|
||||
key := ctx.Request().URL.String()
|
||||
cp := &CachedPage{
|
||||
URL: key,
|
||||
HTML: html.Bytes(),
|
||||
Headers: headers,
|
||||
StatusCode: ctx.Response().Status,
|
||||
}
|
||||
|
||||
err := t.cache.
|
||||
Set().
|
||||
Group(cachedPageGroup).
|
||||
Key(key).
|
||||
Tags(page.Cache.Tags...).
|
||||
Expiration(page.Cache.Expiration).
|
||||
Data(cp).
|
||||
Save(ctx.Request().Context())
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
log.Ctx(ctx).Debug("cached page")
|
||||
case !context.IsCanceledError(err):
|
||||
log.Ctx(ctx).Error("failed to cache page",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCachedPage attempts to fetch a cached page for a given URL
|
||||
func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedPage, error) {
|
||||
p, err := t.cache.
|
||||
Get().
|
||||
Group(cachedPageGroup).
|
||||
Key(url).
|
||||
Fetch(ctx.Request().Context())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.(*CachedPage), nil
|
||||
}
|
||||
|
||||
// getCacheKey gets a cache key for a given group and ID
|
||||
func (t *TemplateRenderer) getCacheKey(group, key string) string {
|
||||
if group != "" {
|
||||
return fmt.Sprintf("%s:%s", group, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// parse parses a set of templates and caches them for quick execution
|
||||
// If the application environment is set to local, the cache will be bypassed and templates will be
|
||||
// parsed upon each request so hot-reloading is possible without restarts.
|
||||
// Also included will be the function map provided by the funcmap package.
|
||||
func (t *TemplateRenderer) parse(build *templateBuild) (*TemplateParsed, error) {
|
||||
var tp *TemplateParsed
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case build.key == "":
|
||||
return nil, errors.New("cannot parse template without key")
|
||||
case len(build.files) == 0 && len(build.directories) == 0:
|
||||
return nil, errors.New("cannot parse template without files or directories")
|
||||
case build.base == "":
|
||||
return nil, errors.New("cannot parse template without base")
|
||||
}
|
||||
|
||||
// Generate the cache key
|
||||
cacheKey := t.getCacheKey(build.group, build.key)
|
||||
|
||||
// Check if the template has not yet been parsed or if the app environment is local, so that
|
||||
// templates reflect changes without having the restart the server
|
||||
if tp, err = t.Load(build.group, build.key); err != nil || t.config.App.Environment == config.EnvLocal {
|
||||
// Initialize the parsed template with the function map
|
||||
parsed := template.New(build.base + config.TemplateExt).
|
||||
Funcs(t.funcMap)
|
||||
|
||||
// Format the requested files
|
||||
for k, v := range build.files {
|
||||
build.files[k] = fmt.Sprintf("%s%s", v, config.TemplateExt)
|
||||
}
|
||||
|
||||
// Include all files within the requested directories
|
||||
for k, v := range build.directories {
|
||||
build.directories[k] = fmt.Sprintf("%s/*%s", v, config.TemplateExt)
|
||||
}
|
||||
|
||||
// Get the templates
|
||||
var tpl fs.FS
|
||||
if t.config.App.Environment == config.EnvLocal {
|
||||
tpl = templates.GetOS()
|
||||
} else {
|
||||
tpl = templates.Get()
|
||||
}
|
||||
|
||||
// Parse the templates
|
||||
parsed, err = parsed.ParseFS(tpl, append(build.files, build.directories...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store the template so this process only happens once
|
||||
tp = &TemplateParsed{
|
||||
Template: parsed,
|
||||
build: build,
|
||||
}
|
||||
t.templateCache.Store(cacheKey, tp)
|
||||
}
|
||||
|
||||
return tp, nil
|
||||
}
|
||||
|
||||
// Load loads a template from the cache
|
||||
func (t *TemplateRenderer) Load(group, key string) (*TemplateParsed, error) {
|
||||
load, ok := t.templateCache.Load(t.getCacheKey(group, key))
|
||||
if !ok {
|
||||
return nil, errors.New("uncached page template requested")
|
||||
}
|
||||
|
||||
tmpl, ok := load.(*TemplateParsed)
|
||||
if !ok {
|
||||
return nil, errors.New("unable to cast cached template")
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// Execute executes a template with the given data and provides the output
|
||||
func (t *TemplateParsed) Execute(data any) (*bytes.Buffer, error) {
|
||||
if t.Template == nil {
|
||||
return nil, errors.New("cannot execute template: template not initialized")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
err := t.Template.ExecuteTemplate(buf, t.build.base+config.TemplateExt, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Group sets the cache group for the template being built
|
||||
func (t *templateBuilder) Group(group string) *templateBuilder {
|
||||
t.build.group = group
|
||||
return t
|
||||
}
|
||||
|
||||
// Key sets the cache key for the template being built
|
||||
func (t *templateBuilder) Key(key string) *templateBuilder {
|
||||
t.build.key = key
|
||||
return t
|
||||
}
|
||||
|
||||
// Base sets the name of the base template to be used during template parsing and execution.
|
||||
// This should be only the file name without a directory or extension.
|
||||
func (t *templateBuilder) Base(base string) *templateBuilder {
|
||||
t.build.base = base
|
||||
return t
|
||||
}
|
||||
|
||||
// Files sets a list of template files to include in the parse.
|
||||
// This should not include the file extension and the paths should be relative to the templates directory.
|
||||
func (t *templateBuilder) Files(files ...string) *templateBuilder {
|
||||
t.build.files = files
|
||||
return t
|
||||
}
|
||||
|
||||
// Directories sets a list of directories that all template files within will be parsed.
|
||||
// The paths should be relative to the templates directory.
|
||||
func (t *templateBuilder) Directories(directories ...string) *templateBuilder {
|
||||
t.build.directories = directories
|
||||
return t
|
||||
}
|
||||
|
||||
// Store parsed the templates and stores them in the cache
|
||||
func (t *templateBuilder) Store() (*TemplateParsed, error) {
|
||||
return t.renderer.parse(t.build)
|
||||
}
|
||||
|
||||
// Execute executes the template with the given data.
|
||||
// If the template has not already been cached, this will parse and cache the template
|
||||
func (t *templateBuilder) Execute(data any) (*bytes.Buffer, error) {
|
||||
tp, err := t.Store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tp.Execute(data)
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/pkg/htmx"
|
||||
"github.com/mikestefanello/pagoda/pkg/page"
|
||||
"github.com/mikestefanello/pagoda/pkg/tests"
|
||||
"github.com/mikestefanello/pagoda/templates"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTemplateRenderer(t *testing.T) {
|
||||
group := "test"
|
||||
id := "parse"
|
||||
|
||||
// Should not exist yet
|
||||
_, err := c.TemplateRenderer.Load(group, id)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Parse in to the cache
|
||||
tpl, err := c.TemplateRenderer.
|
||||
Parse().
|
||||
Group(group).
|
||||
Key(id).
|
||||
Base("htmx").
|
||||
Files("layouts/htmx", "pages/error").
|
||||
Directories("components").
|
||||
Store()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should exist now
|
||||
parsed, err := c.TemplateRenderer.Load(group, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that all expected templates are included
|
||||
expectedTemplates := make(map[string]bool)
|
||||
expectedTemplates["htmx"+config.TemplateExt] = true
|
||||
expectedTemplates["error"+config.TemplateExt] = true
|
||||
components, err := templates.Get().ReadDir("components")
|
||||
require.NoError(t, err)
|
||||
for _, f := range components {
|
||||
expectedTemplates[f.Name()] = true
|
||||
}
|
||||
for _, v := range parsed.Template.Templates() {
|
||||
delete(expectedTemplates, v.Name())
|
||||
}
|
||||
assert.Empty(t, expectedTemplates)
|
||||
|
||||
data := struct {
|
||||
StatusCode int
|
||||
}{
|
||||
StatusCode: 500,
|
||||
}
|
||||
buf, err := tpl.Execute(data)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, buf)
|
||||
assert.Contains(t, buf.String(), "Please try again")
|
||||
|
||||
buf, err = c.TemplateRenderer.
|
||||
Parse().
|
||||
Group(group).
|
||||
Key(id).
|
||||
Base("htmx").
|
||||
Files("htmx", "pages/error").
|
||||
Directories("components").
|
||||
Execute(data)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, buf)
|
||||
assert.Contains(t, buf.String(), "Please try again")
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_RenderPage(t *testing.T) {
|
||||
setup := func() (echo.Context, *httptest.ResponseRecorder, page.Page) {
|
||||
ctx, rec := tests.NewContext(c.Web, "/test/TestTemplateRenderer_RenderPage")
|
||||
tests.InitSession(ctx)
|
||||
|
||||
p := page.New(ctx)
|
||||
p.Name = "home"
|
||||
p.Layout = "main"
|
||||
p.Cache.Enabled = false
|
||||
p.Headers["A"] = "b"
|
||||
p.Headers["C"] = "d"
|
||||
p.StatusCode = http.StatusCreated
|
||||
return ctx, rec, p
|
||||
}
|
||||
|
||||
t.Run("missing name", func(t *testing.T) {
|
||||
// Rendering should fail if the Page has no name
|
||||
ctx, _, p := setup()
|
||||
p.Name = ""
|
||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("no page cache", func(t *testing.T) {
|
||||
ctx, _, p := setup()
|
||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check status code and headers
|
||||
assert.Equal(t, http.StatusCreated, ctx.Response().Status)
|
||||
for k, v := range p.Headers {
|
||||
assert.Equal(t, v, ctx.Response().Header().Get(k))
|
||||
}
|
||||
|
||||
// Check the template cache
|
||||
parsed, err := c.TemplateRenderer.Load("page", string(p.Name))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that all expected templates were parsed.
|
||||
// This includes the name, layout and all components
|
||||
expectedTemplates := make(map[string]bool)
|
||||
expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true
|
||||
expectedTemplates[fmt.Sprintf("%s%s", p.Layout, config.TemplateExt)] = true
|
||||
components, err := templates.Get().ReadDir("components")
|
||||
require.NoError(t, err)
|
||||
for _, f := range components {
|
||||
expectedTemplates[f.Name()] = true
|
||||
}
|
||||
|
||||
for _, v := range parsed.Template.Templates() {
|
||||
delete(expectedTemplates, v.Name())
|
||||
}
|
||||
assert.Empty(t, expectedTemplates)
|
||||
})
|
||||
|
||||
t.Run("htmx rendering", func(t *testing.T) {
|
||||
ctx, _, p := setup()
|
||||
p.HTMX.Request.Enabled = true
|
||||
p.HTMX.Response = &htmx.Response{
|
||||
Trigger: "trigger",
|
||||
}
|
||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check HTMX header
|
||||
assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger))
|
||||
|
||||
// Check the template cache
|
||||
parsed, err := c.TemplateRenderer.Load("page:htmx", string(p.Name))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that all expected templates were parsed.
|
||||
// This includes the name, htmx and all components
|
||||
expectedTemplates := make(map[string]bool)
|
||||
expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true
|
||||
expectedTemplates["htmx"+config.TemplateExt] = true
|
||||
components, err := templates.Get().ReadDir("components")
|
||||
require.NoError(t, err)
|
||||
for _, f := range components {
|
||||
expectedTemplates[f.Name()] = true
|
||||
}
|
||||
|
||||
for _, v := range parsed.Template.Templates() {
|
||||
delete(expectedTemplates, v.Name())
|
||||
}
|
||||
assert.Empty(t, expectedTemplates)
|
||||
})
|
||||
|
||||
t.Run("page cache", func(t *testing.T) {
|
||||
ctx, rec, p := setup()
|
||||
p.Cache.Enabled = true
|
||||
p.Cache.Tags = []string{"tag1"}
|
||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fetch from the cache
|
||||
cp, err := c.TemplateRenderer.GetCachedPage(ctx, p.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare the cached page
|
||||
assert.Equal(t, p.URL, cp.URL)
|
||||
assert.Equal(t, p.Headers, cp.Headers)
|
||||
assert.Equal(t, p.StatusCode, cp.StatusCode)
|
||||
assert.Equal(t, rec.Body.Bytes(), cp.HTML)
|
||||
|
||||
// Clear the tag
|
||||
err = c.Cache.
|
||||
Flush().
|
||||
Tags(p.Cache.Tags[0]).
|
||||
Execute(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Refetch from the cache and expect no results
|
||||
_, err = c.TemplateRenderer.GetCachedPage(ctx, p.URL)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
64
pkg/ui/cache/cache.go
vendored
Normal file
64
pkg/ui/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
|
||||
"maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
var (
|
||||
// cache stores a cache of assembled components by key.
|
||||
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]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
57
pkg/ui/cache/cache_test.go
vendored
Normal file
57
pkg/ui/cache/cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func TestCache_GetSet(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())
|
||||
}
|
||||
|
||||
func TestCache_SetIfNotExists(t *testing.T) {
|
||||
key := "test2"
|
||||
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, `<div>hello</div>`, 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)
|
||||
}
|
||||
64
pkg/ui/components/alerts.go
Normal file
64
pkg/ui/components/alerts.go
Normal file
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
203
pkg/ui/components/form.go
Normal file
203
pkg/ui/components/form.go
Normal file
|
|
@ -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, len(controls))
|
||||
for i, control := range controls {
|
||||
g[i] = 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, len(el.Options))
|
||||
for i, opt := range el.Options {
|
||||
buttons[i] = 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, len(errs))
|
||||
for i, err := range errs {
|
||||
g[i] = 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),
|
||||
)
|
||||
}
|
||||
60
pkg/ui/components/head.go
Normal file
60
pkg/ui/components/head.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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 htmxCSRF = `
|
||||
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(htmxCSRF, 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()),
|
||||
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(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, ", ")))),
|
||||
}
|
||||
}
|
||||
9
pkg/ui/components/htmx.go
Normal file
9
pkg/ui/components/htmx.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
func HxBoost() Node {
|
||||
return Attr("hx-boost", "true")
|
||||
}
|
||||
19
pkg/ui/components/nav.go
Normal file
19
pkg/ui/components/nav.go
Normal file
|
|
@ -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 ...any) Node {
|
||||
href := r.Path(routeName, routeParams...)
|
||||
|
||||
return Li(
|
||||
A(
|
||||
Href(href),
|
||||
Text(title),
|
||||
If(href == r.CurrentPath, Class("is-active")),
|
||||
),
|
||||
)
|
||||
}
|
||||
56
pkg/ui/components/tabs.go
Normal file
56
pkg/ui/components/tabs.go
Normal file
|
|
@ -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, len(items))
|
||||
for i, item := range items {
|
||||
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, len(items))
|
||||
for i, item := range items {
|
||||
g[i] = 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
pkg/ui/emails/auth.go
Normal file
22
pkg/ui/emails/auth.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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(ctx echo.Context, username, token string) Node {
|
||||
url := ui.NewRequest(ctx).
|
||||
Url(routenames.VerifyEmail, token)
|
||||
|
||||
return Group{
|
||||
Strong(Textf("Hello %s,", username)),
|
||||
Br(),
|
||||
P(Text("Please click on the following link to confirm your email address:")),
|
||||
Br(),
|
||||
A(Href(url), Text(url)),
|
||||
}
|
||||
}
|
||||
53
pkg/ui/forms/cache.go
Normal file
53
pkg/ui/forms/cache.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
58
pkg/ui/forms/contact.go
Normal file
58
pkg/ui/forms/contact.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
27
pkg/ui/forms/file.go
Normal file
27
pkg/ui/forms/file.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
39
pkg/ui/forms/forgot_password.go
Normal file
39
pkg/ui/forms/forgot_password.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
49
pkg/ui/forms/login.go
Normal file
49
pkg/ui/forms/login.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
66
pkg/ui/forms/register.go
Normal file
66
pkg/ui/forms/register.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
46
pkg/ui/forms/reset_password.go
Normal file
46
pkg/ui/forms/reset_password.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
49
pkg/ui/forms/task.go
Normal file
49
pkg/ui/forms/task.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
64
pkg/ui/layouts/auth.go
Normal file
64
pkg/ui/layouts/auth.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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"
|
||||
)
|
||||
|
||||
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 cache.SetIfNotExists("authNavBar", func() 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")),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
158
pkg/ui/layouts/primary.go
Normal file
158
pkg/ui/layouts/primary.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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"
|
||||
)
|
||||
|
||||
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 cache.SetIfNotExists("layout.headerNavBar", func() 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 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"),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
pkg/ui/models/file.go
Normal file
22
pkg/ui/models/file.go
Normal file
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
85
pkg/ui/models/post.go
Normal file
85
pkg/ui/models/post.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type (
|
||||
Posts struct {
|
||||
Posts []Post
|
||||
Pager pager.Pager
|
||||
}
|
||||
|
||||
Post struct {
|
||||
Title, Body string
|
||||
}
|
||||
)
|
||||
|
||||
func (p *Posts) Render(path string) Node {
|
||||
g := make(Group, len(p.Posts))
|
||||
for i, post := range p.Posts {
|
||||
g[i] = post.Render()
|
||||
}
|
||||
|
||||
return Div(
|
||||
ID("posts"),
|
||||
g,
|
||||
Div(
|
||||
Class("field is-grouped is-grouped-centered"),
|
||||
If(!p.Pager.IsBeginning(), P(
|
||||
Class("control"),
|
||||
Button(
|
||||
Class("button is-primary"),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
|
||||
Attr("hx-target", "#posts"),
|
||||
Text("Previous page"),
|
||||
),
|
||||
)),
|
||||
If(!p.Pager.IsEnd(), P(
|
||||
Class("control"),
|
||||
Button(
|
||||
Class("button is-primary"),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
|
||||
Attr("hx-target", "#posts"),
|
||||
Text("Next page"),
|
||||
),
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Post) Render() Node {
|
||||
return Article(
|
||||
Class("media"),
|
||||
Figure(
|
||||
Class("media-left"),
|
||||
P(
|
||||
Class("image is-64x64"),
|
||||
Img(
|
||||
Src(ui.File("gopher.png")),
|
||||
Alt("Gopher"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Div(
|
||||
Class("media-content"),
|
||||
Div(
|
||||
Class("content"),
|
||||
P(
|
||||
Strong(
|
||||
Text(p.Title),
|
||||
),
|
||||
Br(),
|
||||
Text(p.Body),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
19
pkg/ui/models/search_result.go
Normal file
19
pkg/ui/models/search_result.go
Normal file
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
58
pkg/ui/pages/about.go
Normal file
58
pkg/ui/pages/about.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"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"
|
||||
)
|
||||
|
||||
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."
|
||||
|
||||
// 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.",
|
||||
[]Tab{
|
||||
{
|
||||
Title: "HTMX",
|
||||
Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href=\"https://htmx.org/\">htmx.org</a> to learn more.",
|
||||
},
|
||||
{
|
||||
Title: "Alpine.js",
|
||||
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> 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 <a href=\"https://bulma.io/\">bulma.io</a> 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 <a href=\"https://echo.labstack.com/\">echo.labstack.com</a> to learn more.",
|
||||
},
|
||||
{
|
||||
Title: "Ent",
|
||||
Body: "Simple, yet powerful ORM for modeling and querying data. Visit <a href=\"https://entgo.io/\">entgo.io</a> to learn more.",
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return r.Render(layouts.Primary, tabs)
|
||||
}
|
||||
46
pkg/ui/pages/auth.go
Normal file
46
pkg/ui/pages/auth.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
15
pkg/ui/pages/cache.go
Normal file
15
pkg/ui/pages/cache.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
41
pkg/ui/pages/contact.go
Normal file
41
pkg/ui/pages/contact.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
38
pkg/ui/pages/error.go
Normal file
38
pkg/ui/pages/error.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
53
pkg/ui/pages/file.go
Normal file
53
pkg/ui/pages/file.go
Normal file
|
|
@ -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, len(files))
|
||||
for i, file := range files {
|
||||
fileList[i] = 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(),
|
||||
forms.File{}.Render(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(layouts.Primary, n)
|
||||
}
|
||||
69
pkg/ui/pages/home.go
Normal file
69
pkg/ui/pages/home.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
20
pkg/ui/pages/search.go
Normal file
20
pkg/ui/pages/search.go
Normal file
|
|
@ -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, len(results))
|
||||
for i, result := range results {
|
||||
g[i] = result.Render()
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
33
pkg/ui/pages/task.go
Normal file
33
pkg/ui/pages/task.go
Normal file
|
|
@ -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 <i>ExampleTask</i> 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)
|
||||
}
|
||||
110
pkg/ui/request.go
Normal file
110
pkg/ui/request.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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"
|
||||
"maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
// Context stores the request context.
|
||||
Context echo.Context
|
||||
|
||||
// CurrentPath stores the path of the current request.
|
||||
CurrentPath string
|
||||
|
||||
// IsHome stores whether the requested page is the home page.
|
||||
IsHome bool
|
||||
|
||||
// IsAuth stores whether the user is authenticated.
|
||||
IsAuth bool
|
||||
|
||||
// AuthUser stores the authenticated user.
|
||||
AuthUser *ent.User
|
||||
|
||||
// Metatags stores metatag values.
|
||||
Metatags struct {
|
||||
// Description stores the description metatag value.
|
||||
Description string
|
||||
|
||||
// Keywords stores the keywords metatag values.
|
||||
Keywords []string
|
||||
}
|
||||
|
||||
// CSRF stores the CSRF token for the given request.
|
||||
// This will only be populated if the CSRF middleware is in effect for the given request.
|
||||
// If this is populated, all forms must include this value otherwise the requests will be rejected.
|
||||
CSRF string
|
||||
|
||||
// Htmx 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.
|
||||
// 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,
|
||||
CurrentPath: ctx.Request().URL.Path,
|
||||
Htmx: htmx.GetRequest(ctx),
|
||||
}
|
||||
|
||||
p.IsHome = p.CurrentPath == "/"
|
||||
|
||||
if csrf := ctx.Get(context.CSRFKey); csrf != nil {
|
||||
p.CSRF = csrf.(string)
|
||||
}
|
||||
|
||||
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
|
||||
p.IsAuth = true
|
||||
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 ...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.
|
||||
// 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)
|
||||
}
|
||||
|
||||
return layout(r, node).Render(r.Context.Response().Writer)
|
||||
}
|
||||
93
pkg/ui/request_test.go
Normal file
93
pkg/ui/request_test.go
Normal file
|
|
@ -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, `<div class="test"><p>hello</p></div>`, 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, `<p>hello</p>`, rec.Body.String())
|
||||
})
|
||||
}
|
||||
18
pkg/ui/ui.go
Normal file
18
pkg/ui/ui.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// 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.
|
||||
func File(filepath string) string {
|
||||
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
|
||||
}
|
||||
16
pkg/ui/ui_test.go
Normal file
16
pkg/ui/ui_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
path := "abc.txt"
|
||||
got := File(path)
|
||||
expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue