Migrate from templates to Gomponents (#103)

This commit is contained in:
Mike Stefanello 2025-03-05 20:01:58 -05:00 committed by GitHub
parent 5cad4cbd33
commit 62d46c475c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 2768 additions and 2824 deletions

52
.air.toml Normal file
View file

@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/web"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.idea .idea
dbs dbs
uploads uploads
tmp

View file

@ -1,30 +1,37 @@
# Install Ent code-generation module .PHONY: help
help: ## Print make targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: ent-install .PHONY: ent-install
ent-install: ent-install: ## Install Ent code-generation module
go get entgo.io/ent/cmd/ent go get entgo.io/ent/cmd/ent
# Generate Ent code .PHONY: air-install
air-install: ## Install air
go install github.com/air-verse/air@latest
.PHONY: ent-gen .PHONY: ent-gen
ent-gen: ent-gen: ## Generate Ent code
go generate ./ent go generate ./ent
# Create a new Ent entity
.PHONY: ent-new .PHONY: ent-new
ent-new: ent-new: ## Create a new Ent entity (ie, make ent-new NAME=MyEntity)
go run entgo.io/ent/cmd/ent new $(name) go run entgo.io/ent/cmd/ent new $(name)
# Run the application
.PHONY: run .PHONY: run
run: run: ## Run the application
clear clear
go run cmd/web/main.go go run cmd/web/main.go
# Run all tests .PHONY: watch
.PHONY: test watch: ## Run the application and watch for changes with air to automatically rebuild
test: clear
go test -count=1 -p 1 ./... air
.PHONY: test
test: ## Run all tests
go test ./...
# Check for direct dependency updates
.PHONY: check-updates .PHONY: check-updates
check-updates: check-updates: ## Check for direct dependency updates
go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\[" go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\["

773
README.md
View file

@ -20,7 +20,7 @@
* [Getting started](#getting-started) * [Getting started](#getting-started)
* [Dependencies](#dependencies) * [Dependencies](#dependencies)
* [Start the application](#start-the-application) * [Start the application](#start-the-application)
* [Running tests](#running-tests) * [Live reloading](#live-reloading)
* [Service container](#service-container) * [Service container](#service-container)
* [Dependency injection](#dependency-injection) * [Dependency injection](#dependency-injection)
* [Test dependencies](#test-dependencies) * [Test dependencies](#test-dependencies)
@ -46,34 +46,32 @@
* [Custom middleware](#custom-middleware) * [Custom middleware](#custom-middleware)
* [Handlers](#handlers) * [Handlers](#handlers)
* [Errors](#errors) * [Errors](#errors)
* [Redirects](#redirects)
* [Testing](#testing) * [Testing](#testing)
* [HTTP server](#http-server) * [HTTP server](#http-server)
* [Request / Request helpers](#request--response-helpers) * [Request / Request helpers](#request--response-helpers)
* [Goquery](#goquery) * [Goquery](#goquery)
* [Pages](#pages) * [User interface](#user-interface)
* [Flash messaging](#flash-messaging) * [Why Gomponents?](#why-gomponents)
* [Pager](#pager) * [HTMX support](#htmx-support)
* [CSRF](#csrf) * [Header management](#header-management)
* [Automatic template parsing](#automatic-template-parsing) * [Conditional and partial rendering](#conditional-and-partial-rendering)
* [Cached responses](#cached-responses) * [CSRF token](#csrf-token)
* [Cache tags](#cache-tags) * [Request](#request)
* [Cache middleware](#cache-middleware) * [Title and metatags](#title-and-metatags)
* [Data](#data) * [URL generation](#url-generation)
* [Components](#components)
* [Layouts](#layouts)
* [Pages](#pages)
* [Rendering](#rendering)
* [Forms](#forms) * [Forms](#forms)
* [Submission processing](#submission-processing) * [Submission processing](#submission-processing)
* [Inline validation](#inline-validation) * [Inline validation](#inline-validation)
* [Headers](#headers) * [CSRF](#csrf)
* [Status code](#status-code) * [Models](#models)
* [Metatags](#metatags) * [Node caching](#node-caching)
* [URL and link generation](#url-and-link-generation) * [Flash messaging](#flash-messaging)
* [HTMX support](#htmx-support) * [Pager](#pager)
* [Rendering the page](#rendering-the-page)
* [Template renderer](#template-renderer)
* [Custom functions](#custom-functions)
* [Caching](#caching)
* [Hot-reload for development](#hot-reload-for-development)
* [File configuration](#file-configuration)
* [Funcmap](#funcmap)
* [Cache](#cache) * [Cache](#cache)
* [Set data](#set-data) * [Set data](#set-data)
* [Get data](#get-data) * [Get data](#get-data)
@ -101,16 +99,17 @@ _Pagoda_ is not a framework but rather a base starter-kit for rapid, easy full-s
Built on a solid [foundation](#foundation) of well-established frameworks and modules, _Pagoda_ aims to be a starting point for any web application with the benefit over a mega-framework in that you have full control over all of the code, the ability to easily swap any frameworks or modules in or out, no strict patterns or interfaces to follow, and no fear of lock-in. Built on a solid [foundation](#foundation) of well-established frameworks and modules, _Pagoda_ aims to be a starting point for any web application with the benefit over a mega-framework in that you have full control over all of the code, the ability to easily swap any frameworks or modules in or out, no strict patterns or interfaces to follow, and no fear of lock-in.
While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ templates can be powerful and easy to work with, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS at all. While separate JavaScript frontends have surged in popularity, many prefer the reliability, simplicity and speed of a full-stack approach with server-side rendered HTML. Even the popular JS frameworks all have SSR options. This project aims to highlight that _Go_ alone can be powerful and easy to work with as a full-stack solution, and interesting [frontend](#frontend) libraries can provide the same modern functionality and behavior without having to write any JS or CSS at all. In fact, you can even avoid writing HTML as well.
### Foundation ### Foundation
While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **not required to use any of these**. Swapping any of them out will be relatively easy. While many great projects were used to build this, all of which are listed in the [credits](#credits) section, the following provide the foundation of the back and frontend. It's important to note that you are **<ins>not required to use any of these</ins>**. Swapping any of them out will be relatively easy.
#### Backend #### Backend
- [Echo](https://echo.labstack.com/): High performance, extensible, minimalist Go web framework. - [Echo](https://echo.labstack.com/): High performance, extensible, minimalist Go web framework.
- [Ent](https://entgo.io/): Simple, yet powerful ORM for modeling and querying data. - [Ent](https://entgo.io/): Simple, yet powerful ORM for modeling and querying data.
- [Gomponents](https://github.com/maragudk/gomponents): HTML components written in pure Go. They render to HTML 5, and make it easy for you to build reusable components.
#### Frontend #### Frontend
@ -158,31 +157,30 @@ make run
Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`. Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`.
By default, you should be able to access the application in your browser at `localhost:8000`. This can be changed via the [configuration](#configuration). By default, you should be able to access the application in your browser at `localhost:8000`. Your data will be stored within the `dbs` directory. If you ever want to quickly delete all data, just remove this directory.
By default, your data will be stored within the `dbs` directory. If you ever want to quickly delete all data just remove this directory. These settings, and many others, can be changed via the [configuration](#configuration).
### Running tests ### Live reloading
To run all tests in the application, execute `make test`. This ensures that the tests from each package are not run in parallel. This is required since many packages contain tests that connect to the test database which is stored in memory and reset automatically for each package. Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading.
## Service container ## Service container
The container is located at `pkg/services/container.go` and is meant to house all of your application's services and/or dependencies. It is easily extensible and can be created and initialized in a single call. The services currently included in the container are: The container is located at `pkg/services/container.go` and is meant to house all of your application's services and/or dependencies. It is easily extensible and can be created and initialized in a single call. The services currently included in the container are:
- Configuration
- Cache
- Database
- ORM
- Web
- Validator
- Authentication - Authentication
- Mail - Cache
- Template renderer - Configuration
- Tasks - Database
- Files - Files
- Mail
- ORM
- Tasks
- Validator
- Web
A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`. A new container can be created and initialized via `services.NewContainer()`. It can be later shutdown via `Shutdown()`, which will attempt to gracefully shutdown all services.
### Dependency injection ### Dependency injection
@ -196,7 +194,7 @@ It is common that your tests will require access to dependencies, like the datab
The `config` package provides a flexible, extensible way to store all configuration for the application. Configuration is added to the `Container` as a _Service_, making it accessible across most of the application. The `config` package provides a flexible, extensible way to store all configuration for the application. Configuration is added to the `Container` as a _Service_, making it accessible across most of the application.
Be sure to review and adjust all of the default configuration values provided in `config/config.yaml`. Be sure to review and adjust all the default configuration values provided in `config/config.yaml`.
### Environment overrides ### Environment overrides
@ -394,7 +392,6 @@ For this example, we'll create a new handler which includes a GET and POST route
```go ```go
type Example struct { type Example struct {
orm *ent.Client orm *ent.Client
*services.TemplateRenderer
} }
``` ```
@ -410,7 +407,6 @@ func init() {
```go ```go
func (e *Example) Init(c *services.Container) error { func (e *Example) Init(c *services.Container) error {
e.TemplateRenderer = c.TemplateRenderer
e.orm = c.ORM e.orm = c.ORM
return nil return nil
} }
@ -418,12 +414,12 @@ func (e *Example) Init(c *services.Container) error {
4) Declare the routes 4) Declare the routes
**It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. **It is highly recommended** that you provide a `Name` for your routes. Most methods on the back and frontend leverage the route name and parameters in order to generate URLs. All route names are currently stored as consts in the `routenames` package so they are accessible from within the `ui` layer.
```go ```go
func (e *Example) Routes(g *echo.Group) { func (e *Example) Routes(g *echo.Group) {
g.GET("/example", e.Page).Name = "example" g.GET("/example", e.Page).Name = routenames.Example
g.POST("/example", c.PageSubmit).Name = "example.submit" g.POST("/example", c.PageSubmit).Name = routenames.ExampleSubmit
} }
``` ```
@ -441,9 +437,9 @@ func (e *Example) PageSubmit(ctx echo.Context) error {
### Errors ### Errors
Routes can return errors to indicate that something wrong happened. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code. You can use `return echo.NewHTTPError(http.StatusInternalServerError)`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed. Routes can return errors to indicate that something wrong happened and an error page should be rendered for the request. Ideally, the error is of type `*echo.HTTPError` to indicate the intended HTTP response code, and optionally a message that will be logged. You can use `return echo.NewHTTPError(http.StatusInternalServerError, "optional message")`, for example. If an error of a different type is returned, an _Internal Server Error_ is assumed.
The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route conveniently constructs and renders a `Page` which uses the template `templates/pages/error.gohtml`. The status code is passed to the template so you can easily alter the markup depending on the error type. The [error handler](https://echo.labstack.com/guide/error-handling/) is set to the provided `Handler` in `pkg/handlers/error.go` in the `BuildRouter()` function. That means that if any middleware or route return an error, the request gets routed there. This route passes the status code to the `pages.Error` UI component page, allowing you to easily adjust the markup depending on the error type.
### Redirects ### Redirects
@ -469,7 +465,7 @@ Only a brief example of route tests were provided in order to highlight what is
#### HTTP server #### HTTP server
When the route tests initialize, a new `Container` is created which provides full access to all of the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses. When the route tests initialize, a new `Container` is created which provides full access to all the _Services_ that will be available during normal application execution. Also provided is a test HTTP server with the router added. This means your tests can make requests and expect responses exactly as the application would behave outside of tests. You do not need to mock the requests and responses.
#### Request / Response helpers #### Request / Response helpers
@ -501,35 +497,290 @@ assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text()) assert.Equal(t, "About", h1.Text())
``` ```
## Pages ## User interface
The `Page` is the major building block of your `Handler` responses. It is a _struct_ type located at `pkg/page/page.go`. The concept of the `Page` is that it provides a consistent structure for building responses and transmitting data and functionality to the templates. Pages are rendered with the `TemplateRenderer`. ### Why Gomponents?
All example routes provided construct and _render_ a `Page`. It's recommended that you review both the `Page` and the example routes as they try to illustrate all included functionality. Originally, standard Go templates were chosen for this project and a lot of code was written to build tools to make using them as easy and flexible as possible. That code remains archived in [this branch](https://github.com/mikestefanello/pagoda/tree/templates) but is no longer maintained. Despite providing tools such as a powerful _template renderer_, which did things like automatically compile nested templates to separate layouts from pages, automatically include component templates, support HTMX partial rendering, provide _funcmap_ function helpers, and more, the end result left a lot to be desired. Templates provide no type-safety, child templates are difficult to call when you have multiple arguments, templates are not flexible enough to easily provide reusable components and elements, the _funcmap_ and form submission code often had to return HTML or CSS classes, and more.
As you develop your application, the `Page` can be easily extended to include whatever data or functions you want to provide to your templates. While I was extremely hesitant to adopt a rendering option outside the standard library, if an option exists that I personally feel is far superior, that is what I'm going to go with. [Templ](https://github.com/a-h/templ) was also a consideration as that project has made massive progress, seen an explosion in adoption, and aims to solve all the problems previously mentioned. I did not feel that it was a good fit for this project though as it requires you to know and understand their templating language, to install a CLI and an IDE plugin (which does not work with all IDEs; especially GoLand), and separately compile template code.
Initializing a new page is simple: [Gomponents](https://github.com/maragudk/gomponents) allows you to build HTML using nothing except pure, type-safe Go; whether that's entire documents or dynamic, reusable components. [Here](https://www.gomponents.com/) are some basic examples to give you an idea of how it works and [this tool](https://gomponents.morehart.dev/) is incredibly useful for quickly converting HTML to _gomponent_ Go code. When I first came across this library, I was very much against it, and couldn't imagine writing tons of nested function calls just to produce some HTML; especially for complex markup. But after actually spending some time using it to replicate the UI of this project, and feeling the downsides of Go templates, I quickly became a big fan and supporter of this approach. Between this and the chosen JS/CSS libraries, you can literally write your entire frontend without leaving Go.
Before making any quick judgements of your own, I ask that you deeply consider what you've used in the past, review what previously existed in this project, and compare to the current solution and code presented here. I believe I've laid out the `ui` package in a way that makes building your frontend with _gomponents_ very easy and enjoyable.
### HTMX support
[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
Many examples of its usage are available in the included examples:
- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience.
- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX.
- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI.
- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX.
All of this can be easily accomplished without writing any JavaScript at all.
Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend, though many things are provided here to make it simple.
#### Header management
Included is an [htmx package](https://github.com/mikestefanello/pagoda/blob/main/pkg/htmx/htmx.go) to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands for both the request and response. This allows you, for example, to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ and your _ui_.
From within your _route_, you can fetch HTMX request details by calling `htmx.GetRequest(ctx)`, and you can send commands back to HTMX by calling `htmx.Response{...}.Apply(ctx)`, and populating any fields on the `htmx.Response` struct.
From within your _ui_, the [Request](#request) object will automatically contain the request details on the `Htmx` field.
#### Conditional and partial rendering
Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _ui_, if needed.
The most important case to support is _partial_ rendering. If HTMX is making a request, unless it is [boosted](https://htmx.org/docs/#boosting), you only want to render the _content_ of your _route_, and not the entire [layout](#layouts). This is automatically handled by the `Render()` method on the [Request](#request) type. More can be read about that [here](#rendering).
If your routes aren't doing multiple things, you may not need _conditional_ rendering, but it's worth knowing how flexible you can be. A simple example of this:
```go ```go
func (c *home) Get(ctx echo.Context) error { if htmx.GetRequest(ctx).Target == "search" {
p := page.New(ctx) // This request is HTMX fetching content just for the #search element
} }
``` ```
Using the `echo.Context`, the `Page` will be initialized with the following fields populated: #### CSRF token
- `Context`: The passed in _context_ If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events).
- `Path`: The requested URL path
- `URL`: The requested URL ### Request
- `StatusCode`: Defaults to 200
- `Pager`: Initialized `Pager` (see below) The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires.
- `RequestID`: The request ID, if the middleware is being used
`NewRequest()` will automatically populate the following fields using the `echo.Context` from the current request:
- `Context`: The provided _echo.Context_
- `CurrentPath`: The requested URL path
- `IsHome`: If the request was for the homepage - `IsHome`: If the request was for the homepage
- `IsAuth`: If the user is authenticated - `IsAuth`: If the user is authenticated
- `AuthUser`: The logged in user entity, if one - `AuthUser`: The logged-in user entity, if one
- `CSRF`: The CSRF token, if the middleware is being used - `CSRF`: The CSRF token, if the middleware is being used
- `HTMX.Request`: Data from the HTMX headers, if HTMX made the request (see below) - `Htmx`: Data from the HTMX headers, if HTMX made the request
- `Config`: The application configuration, if the middleware is being used
#### Title and metatags
The `Request` type has additional fields to make it easy to set static values within components being rendered on a given page. While the _title_ is always important, the others are provided as an example:
* `Title`: The page title
* `Metatags`:
* `Description`: The description of the page
* `Tags`: A slice of keyword tags
#### URL generation
As mentioned in the [Routes](#routes) section, it is recommended, though not required, to provide names for each of your routes. These are currently defined as consts in the `routenames` package. If you use names for your routes, you can leverage the URL generation methods on the `Request`. This allows you to prevent hard-coding your route paths and parameters in multiple places.
The methods both take a route name and optional variadic route parameters:
* `Path()`: Generates a relative path for a given route.
* `Url()`: Generates an absolute URL for a given route. This uses the `App.Host` field in your [configuration](#configuration) to determine the host of the URL.
**Example:**
```go
g.GET("/user/:uid", profilePage).Name = routenames.Profile
```
```go
func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node {
return A(
Class("profile"),
Href(r.Path(routenames.Profile, userID)),
Text(userName),
)
}
```
### Components
The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation).
Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements.
### Layouts
_Layouts_ are full HTML templates that are used by [pages](#pages) to inject themselves in to, allowing you to easily have multiple pages that all use the same layout, and to easily switch layouts between different pages. [Included](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/layouts) is a _primary_ and _auth_ layout as an example, which you can see in action by navigating between the links on the _General_ and _Account_ sidebar menus.
### Pages
_Pages_ are what [route handlers](#handlers) ultimately assemble and render. They may accept primitives, [models](#models), [forms](#forms), or nothing at all, and they embed themselves in a [layout](#layouts) of their choice. Each _page_ represents a different page of your web application and many [examples](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/pages) are provided for reference. See below for a minimal example.
#### Rendering
The `Request` type contains a `Render()` method which makes rendering your page within a given layout simple. It automatically handles partial rendering, omitting the [layout](#layouts) and only rendering the [page](#pages) if the request is made by HTMX and is not boosted. Using HTMX is completely optional. This is accomplished by passing in your layout and _page_ separately, for example:
```go
func MyPage(ctx echo.Context, username string) error {
r := ui.NewRequest(ctx)
r.Title = "My page"
node := Div(Textf("Hello, %s!", username))
return r.Render(layouts.Primary, node)
}
```
Using `Render()`, in this example, only `node` will render if HTMX made the request in a non-boosted fashion, otherwise `node` will render within `layouts.Primary`.
And from within your [route handler](#handlers), simply:
```go
func (e *ExampleHandler) Page(ctx echo.Context) error {
return pages.MyPage(ctx, "abcd")
}
```
### Forms
Building, rendering, validating and processing forms is made extremely easy with [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator), [form.Submission](https://github.com/mikestefanello/pagoda/blob/templates/pkg/form/submission.go), and the provided _gomponent_ [components](#components).
Start by declaring the form within the [forms](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/forms) package:
```go
type Guestbook struct {
Message string `form:"message" validate:"required"`
form.Submission
}
```
Embedding `form.Submission` satisfies the `form.Form` interface and handles submissions and validation for you.
Next, provide a method that renders the form:
```go
func (f *Guestbook) Render(r *ui.Request) Node {
return Form(
ID("guestbook"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.GuestbookSubmit)),
TextareaField(TextareaFieldParams{
Form: f,
FormField: "Message",
Name: "message",
Label: "Message",
Value: f.Message,
}),
ControlGroup(
FormButton("is-link", "Submit"),
),
CSRF(r),
)
}
```
Then, create a _page_ that includes your form:
```go
func UserGuestbook(ctx echo.Context, form *forms.Guestbook) error {
r := ui.NewRequest(ctx)
r.Title = "User page"
content := Div(
Class("guestbook"),
H2(Text("My guestbook")),
P(Text("Hi, please sign my guestbook!")),
form.Render(r)
)
return r.Render(layouts.Primary, content)
}
```
And last, have your handler render the _page_ in a route, and provide a route for the submission.
```go
func (e *Example) Routes(g *echo.Group) {
g.GET("/guestbook", e.Page).Name = routenames.Guestbook
g.POST("/guestbook", c.PageSubmit).Name = routenames.GuestbookSubmit
}
func (e *Example) Page(ctx echo.Context) error {
return pages.UserGuestbook(ctx, form.Get[forms.Guestbook](ctx))
}
```
`form.Get` will either initialize a new form, or load one previously stored in the context (ie, if it was already submitted).
#### Submission processing
Using the example form above, this is all you would have to do within the _POST_ callback for your route:
Start by submitting the form via `form.Submit()`, along with the request context. This will:
1. Store a pointer to the form in the _context_ so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided.
2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields.
3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags.
Then, evaluate the error returned, if one, and process the form values however you need to:
```go
func (e *Example) Submit(ctx echo.Context) error {
var input forms.Guestbook
// Submit the form.
err := form.Submit(ctx, &input)
// Check the error returned, and act accordingly.
switch err.(type) {
case nil:
// All good!
case validator.ValidationErrors:
// The form input was not valid, so re-render the form with the errors included.
return e.Page(ctx)
default:
// Request failed, show the error page.
return err
}
msg.Success(fmt.Sprintf("Your message was: %s", input.Message))
return redirect.New(ctx).
Route(routenames.Home).
Go()
}
```
#### Inline validation
The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that the provided form [components](#components), such as `TextareaField` shown in the example above, use to automatically provide classes and error messages. The example form above will have inline validation without requiring anything other than what is shown above.
While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed.
#### CSRF
By default, all non `GET` requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router.
The `Request` automatically extracts the CSRF token from the context, but you must include it in your forms by using the provided `CSRF()` [component](#components) as shown in the example above.
### Models
Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example.
### Node caching
While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change.
A good example of this, and one included, is the entire upper navigation bar, search form, and search modal in the _Primary_ layout. It contains a large amount of nested _gomponent_ function calls and a lot of rendering is required. There is no reason to do this more than once.
The cache functions are available in `pkg/ui/cache` and can most easily used like this:
```go
func SearchModal() gomponents.Node {
return cache.SetIfNotExists("searchModal", func() gomponents.Node {
return Div(...your entire nested node...)
})
}
```
`cache.SetIfNotExists()`is a helper function that uses `cache.Get()` to check if the `Node` is already cached under the provided _key_, and if not, executes the _func_ to generate the `Node`, and caches that via `cache.Set()`.
`cache.Set()` does more than just cache the `Node` in-memory. It renders the entire `Node` into a `bytes.Buffer`, then stores a `Raw()` `Node` using the rendered content. This means that everytime the `Node` is taken from the cache and rendered, the pre-rendered `string` is used rather than having to iterate through the nested component, executing all of the element functions and rendering and building the entire HTML output.
It's worth noting that my benchmarking was very limited and cannot be considered anything definitive. In my tests, gomponents was faster, allocated less overall, but had more allocations in total. If you're able to cache static nodes, gomponents can perform significantly better. Reiterating, for most applications, these differences in nanoseconds and bytes will most likely be completely insignificant and unnoticed; but it's worth being aware of.
### Flash messaging ### Flash messaging
@ -545,380 +796,30 @@ There are four types of messages, and each can be created as follows:
- Warning: `msg.Warning(ctx echo.Context, message string)` - Warning: `msg.Warning(ctx echo.Context, message string)`
- Danger: `msg.Danger(ctx echo.Context, message string)` - Danger: `msg.Danger(ctx echo.Context, message string)`
The _message_ string can contain HTML.
#### Rendering messages #### Rendering messages
When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again. When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again.
The `Page` has a method that can be used to fetch messages for a given type from within the template: `Page.GetMessages(typ msg.Type)`. This is used rather than the _funcmap_ because the `Page` contains the request context which is required in order to access the session data. Since the `Page` is the data destined for the templates, you can use: `{{.GetMessages "success"}}` for example. A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
To make things easier, a template _component_ is already provided, located at `templates/components/messages.gohtml`. This will render all messages of all types simply by using `{{template "messages" .}}` either within your page or layout template. ## Pager
### Pager A very basic mechanism is provided to handle and facilitate paging located in `pkg/pager` and can be initialized via `pager.NewPager()`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager. This query key can be controlled via the `QueryKey` constant.
A very basic mechanism is provided to handle and facilitate paging located in `pkg/page/pager.go`. When a `Page` is initialized, so is a `Pager` at `Page.Pager`. If the requested URL contains a `page` query parameter with a numeric value, that will be set as the page number in the pager.
During initialization, the _items per page_ amount will be set to the default, controlled via constant, which has a value of 20. It can be overridden by changing `Pager.ItemsPerPage` but should be done before other values are set in order to not provide incorrect calculations.
Methods include: Methods include:
- `SetItems(items int)`: Set the total amount of items in the entire result-set - `SetItems(items int)`: Set the total amount of items in the entire result-set
- `IsBeginning()`: Determine if the pager is at the beginning of the pages - `IsBeginning()`: Determine if the pager is at the beginning of the pages
- `IsEnd()`: Determine if the pager is at the end of the pages - `IsEnd()`: Determine if the pager is at the end of the pages
- `GetOffset()`: Get the offset which can be useful is constructing a paged database query - `GetOffset()`: Get the offset which can be useful in constructing a paged database query
There is currently no template (yet) to easily render a pager. There is currently no generic component to easily render a pager, but the homepage does have an example.
### CSRF
By default, all non GET requests will require a CSRF token be provided as a form value. This is provided by middleware and can be adjusted or removed in the router.
The `Page` will contain the CSRF token for the given request. There is a CSRF helper component template which can be used to easily render a hidden form element in your form which will contain the CSRF token and the proper element name. Simply include `{{template "csrf" .}}` within your form.
### Automatic template parsing
Dealing with templates can be quite tedious and annoying so the `Page` aims to make it as simple as possible with the help of the [template renderer](#template-renderer). To start, templates for _pages_ are grouped in the following directories within the `templates` directory:
- `layouts`: Base templates that provide the entire HTML wrapper/layout. This template should include a call to `{{template "content" .}}` to render the content of the `Page`.
- `pages`: Templates that are specific for a given route/page. These must contain `{{define "content"}}{{end}}` which will be injected in to the _layout_ template.
- `components`: A shared library of common components that the layout and base template can leverage.
Specifying which templates to render for a given `Page` is as easy as:
```go
page.Name = "home"
page.Layout = "main"
```
That alone will result in the following templates being parsed and executed when the `Page` is rendered:
1) `layouts/main.gohtml` as the base template
2) `pages/home.gohtml` to provide the `content` template for the layout
3) All template files located within the `components` directory
4) The entire [funcmap](#funcmap)
The [template renderer](#template-renderer) also provides caching and local hot-reloading.
### Cached responses
A `Page` can have cached enabled just by setting `Page.Cache.Enabled` to `true`. The `TemplateRenderer` will automatically handle caching the HTML output, headers and status code. Cached pages are stored using a key that matches the full request URL and [middleware](#cache-middleware) is used to serve it on matching requests.
By default, the cache expiration time will be set according to the configuration value located at `Config.Cache.Expiration.Page` but it can be set per-page at `Page.Cache.Expiration`.
#### Cache tags
You can optionally specify cache tags for the `Page` by setting a slice of strings on `Page.Cache.Tags`. This provides the ability to build in cache invalidation logic in your application driven by events such as entity operations, for example.
You can use the [cache client](#cache) on the `Container` to easily [flush cache tags](#flush-tags), if needed.
#### Cache middleware
Cached pages are served via the middleware `ServeCachedPage()` in the `middleware` package.
The cache is bypassed if the requests meet any of the following criteria:
1) Is not a GET request
2) Is made by an authenticated user
Cached pages are looked up for a key that matches the exact, full URL of the given request.
### Data
The `Data` field on the `Page` is of type `any` and is what allows your route to pass whatever it requires to the templates, alongside the `Page` itself.
### Forms
The `Form` field on the `Page` is similar to the `Data` field, but it's meant to store a struct that represents a form being rendered on the page.
An example of this pattern is:
```go
type ContactForm struct {
Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required"`
form.Submission
}
```
Embedding `form.Submission` satisfies the `form.Form` interface and makes dealing with submissions and validation extremely easy.
Then in your page:
```go
p := page.New(ctx)
p.Form = form.Get[ContactForm](ctx)
```
This will either initialize a new form to be rendered, or load one previously stored in the context (ie, if it was already submitted). How the _form_ gets populated with values so that your template can render them is covered in the next section.
#### Submission processing
Form submission processing is made extremely simple by leveraging functionality provided by [Echo binding](https://echo.labstack.com/guide/binding/), [validator](https://github.com/go-playground/validator) and the `Submission` struct located in `pkg/form/submission.go`.
Using the example form above, this is all you would have to do within the _POST_ callback for your route:
Start by submitting the form along with the request context. This will:
1. Store a pointer to the form so that your _GET_ callback can access the form values (shown previously). That allows the form to easily be re-rendered with any validation errors it may have as well as the values that were provided.
2. Parse the input in the _POST_ data to map to the struct so the fields becomes populated. This uses the `form` struct tags to map form input values to the struct fields.
3. Validate the values in the struct fields according to the rules provided in the optional `validate` struct tags.
```go
var input ContactForm
err := form.Submit(ctx, &input)
```
Check the error returned, and act accordingly. For example:
```go
switch err.(type) {
case nil:
// All good!
case validator.ValidationErrors:
// The form input was not valid, so re-render the form
return c.Page(ctx)
default:
// Request failed, show the error page
return err
}
```
And finally, your template:
```html
<form id="contact" method="post" hx-post="{{url "contact.post"}}">
<input id="email" name="email" type="email" class="input" value="{{.Form.Email}}">
<input id="message" name="message" type="text" class="input" value="{{.Form.Message}}">
</form
```
#### Inline validation
The `Submission` makes inline validation easier because it will store all validation errors in a map, keyed by the form struct field name. It also contains helper methods that your templates can use to provide classes and extract the error messages.
While [validator](https://github.com/go-playground/validator) is a great package that is used to validate based on struct tags, the downside is that the messaging, by default, is not very human-readable or easy to override. Within `Submission.setErrorMessages()` the validation errors are converted to more readable messages based on the tag that failed validation. Only a few tags are provided as an example, so be sure to expand on that as needed.
To provide the inline validation in your template, there are two things that need to be done.
First, include a status class on the element so it will highlight green or red based on the validation:
```html
<input id="email" name="email" type="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
```
Second, render the error messages, if there are any for a given field:
```go
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
```
### Headers
HTTP headers can be set either via the `Page` or the _context_:
```go
p := page.New(ctx)
p.Headers["HeaderName"] = "header-value"
```
```go
ctx.Response().Header().Set("HeaderName", "header-value")
```
### Status code
The HTTP response status code can be set either via the `Page` or the _context_:
```go
p := page.New(ctx)
p.StatusCode = http.StatusTooManyRequests
```
```go
ctx.Response().Status = http.StatusTooManyRequests
```
### Metatags
The `Page` provides the ability to set basic HTML metatags which can be especially useful if your web application is publicly accessible. Only fields for the _description_ and _keywords_ are provided but adding additional fields is very easy.
```go
p := page.New(ctx)
p.Metatags.Description = "The page description."
p.Metatags.Keywords = []string{"Go", "Software"}
```
A _component_ template is included to render metatags in `core.gohtml` which can be used by adding `{{template "metatags" .}}` to your _layout_.
### URL and link generation
Generating URLs in the templates is made easy if you follow the [routing patterns](#patterns) and provide names for your routes. Echo provides a `Reverse` function to generate a route URL with a given route name and optional parameters. This function is made accessible to the templates via _funcmap_ function `url`.
As an example, if you have route such as:
```go
e.GET("/user/profile/:user", handler.Get).Name = "user_profile"
```
And you want to generate a URL in the template, you can:
```go
{{url "user_profile" 1}
```
Which will generate: `/user/profile/1`
There is also a helper function provided in the [funcmap](#funcmap) to generate links which has the benefit of adding an _active_ class if the link URL matches the current path. This is especially useful for navigation menus.
```go
{{link (url "user_profile" .AuthUser.ID) "Profile" .Path "extra-class"}}
```
Will generate:
```html
<a href="/user/profile/1" class="is-active extra-class">Profile</a>
```
Assuming the current _path_ is `/user/profile/1`; otherwise the `is-active` class will be excluded.
### HTMX support
[HTMX](https://htmx.org/) is an awesome JavaScript library allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
Many examples of its usage are available in the included examples:
- All navigation links use [boost](https://htmx.org/docs/#boosting) which dynamically replaces the page content with an AJAX request, providing a SPA-like experience.
- All forms use either [boost](https://htmx.org/docs/#boosting) or [hx-post](https://htmx.org/docs/#triggers) to submit via AJAX.
- The mock search autocomplete modal uses [hx-get](https://htmx.org/docs/#targets) to fetch search results from the server via AJAX and update the UI.
- The mock posts on the homepage/dashboard use [hx-get](https://htmx.org/docs/#targets) to fetch and page posts via AJAX.
All of this can be easily accomplished without writing any JavaScript at all.
Another benefit of [HTMX](https://htmx.org/) is that it's completely backend-agnostic and does not require any special tools or integrations on the backend. But to make things easier, included is a small package to read and write [HTTP headers](https://htmx.org/docs/#requests) that HTMX uses to communicate additional information and commands.
The `htmx` package contains the headers for the _request_ and _response_. When a `Page` is initialized, `Page.HTMX.Request` will also be initialized and populated with the headers that HTMX provides, if HTMX made the request. This allows you to determine if HTMX is making the given request and what exactly it is doing, which could be useful both in your _route_ as well as your _templates_.
If you need to set any HTMX headers in your `Page` response, this can be done by altering `Page.HTMX.Response`.
#### Layout template override
To facilitate easy partial rendering for HTMX requests, the `Page` will automatically change your _Layout_ template to use `htmx.gohtml`, which currently only renders `{{template "content" .}}`. This allows you to use an HTMX request to only update the content portion of the page, rather than the entire HTML.
This override only happens if the HTMX request being made is **not a boost** request because **boost** requests replace the entire `body` element so there is no need to do a partial render.
#### Conditional processing / rendering
Since HTMX communicates what it is doing with the server, you can use the request headers to conditionally process in your _route_ or render in your _template_, if needed. If your routes aren't doing multiple things, you may not need this, but it's worth knowing how flexible you can be.
A simple example of this:
```go
if page.HTMX.Request.Target == "search" {
// You know this request HTMX is fetching content just for the #search element
}
```
```go
{{if eq .HTMX.Request.Target "search"}}
// Render content for the #search element
{{end}}
```
#### CSRF token
If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `footer` template by leveraging HTMX [events](https://htmx.org/reference/#events).
### Rendering the page
Once your `Page` is fully built, rendering it via the embedded `TemplateRenderer` in your _handler_ can be done simply by calling `RenderPage()`:
```go
func (c *home) Get(ctx echo.Context) error {
p := page.New(ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageHome
return c.RenderPage(ctx, p)
}
```
## Template renderer
The _template renderer_ is a _Service_ on the `Container` that aims to make template parsing and rendering easy and flexible. It is the mechanism that allows the `Page` to do [automatic template parsing](#automatic-template-parsing). The standard `html/template` is still the engine used behind the scenes. The code can be found in `pkg/services/template_renderer.go`.
Here is an example of a complex rendering that uses multiple template files as well as an entire directory of template files:
```go
buf, err = c.TemplateRenderer.
Parse().
Group("page").
Key("home").
Base("main").
Files("layouts/main", "pages/home").
Directories("components").
Execute(data)
```
This will do the following:
- [Cache](#caching) the parsed template with a _group_ of `page` and _key_ of `home` so this parse only happens once
- Set the _base template file_ as `main`
- Include the templates `templates/layout/main.gohtml` and `templates/pages/home.gohtml`
- Include all templates located within the directory `templates/components`
- Include the [funcmap](#funcmap)
- Execute the parsed template with `data` being passed in to the templates
Using the example from the [page rendering](#rendering-the-page), this is will execute:
```go
buf, err = c.TemplateRenderer.
Parse().
Group("page").
Key(page.Name).
Base(page.Layout).
Files(
fmt.Sprintf("layouts/%s", page.Layout),
fmt.Sprintf("pages/%s", page.Name),
).
Directories("components").
Execute(page)
```
If you have a need to _separately_ parse and cache the templates then later execute, you can separate the operations:
```go
_, err := c.TemplateRenderer.
Parse().
Group("my-group").
Key("my-key").
Base("auth").
Files("layouts/auth", "pages/login").
Directories("components").
Store()
```
```go
tpl, err := c.TemplateRenderer.Load("my-group", "my-key")
buf, err := tpl.Execute(data)
```
### Custom functions
All templates will be parsed with the [funcmap](#funcmap) so all of your custom functions as well as the functions provided by [sprig](https://github.com/Masterminds/sprig) will be available.
### Caching
Parsed templates will be cached within a `sync.Map` so the operation will only happen once per cache _group_ and _ID_. Be careful with your cache _group_ and _ID_ parameters to avoid collisions.
### Hot-reload for development
If the current [environment](#environments) is set to `config.EnvLocal`, which is the default, the cache will be bypassed and templates will be parsed every time they are requested. This allows you to have hot-reloading without having to restart the application so you can see your HTML changes in the browser immediately.
### File configuration
To make things easier and less repetitive, parameters given to the _template renderer_ must not include the `templates` directory or the template file extensions. The file extension is stored as a constant (`TemplateExt`) within the `config` package.
## Funcmap
The `funcmap` package provides a _function map_ (`template.FuncMap`) which will be included for all templates rendered with the [template renderer](#template-renderer). Aside from a few custom functions, [sprig](https://github.com/Masterminds/sprig) is included which provides over 100 commonly used template functions. The full list is available [here](http://masterminds.github.io/sprig/).
To include additional custom functions, add to the map in `NewFuncMap()` and define the function in the package. It will then become automatically available in all templates.
## Cache ## Cache
As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that.
The built-in usage of the cache is currently only for optional [page caching](#cached-responses) and a simple example route located at `/cache` where you can set and view the value of a given cache entry. The built-in usage of the cache is currently only used for a simple example route located at `/cache` where you can set and view the value of a given cache entry.
Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in. Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in.
@ -996,7 +897,7 @@ As shown in the previous examples, cache tags were provided because they can be
## Tasks ## Tasks
Tasks are queued operations to be executed in the background, either immediately, at a specfic time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc. Tasks are queued operations to be executed in the background, either immediately, at a specific time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc.
Since we're already using [SQLite](https://sqlite.org/) for our database, it's available to act as a persistent store for queued tasks so that tasks are never lost, can be retried until successful, and their concurrent execution can be managed. [Backlite](https://github.com/mikestefanello/backlite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously. I wrote that specifically to address the requirements I wanted to satisfy for this project. Since we're already using [SQLite](https://sqlite.org/) for our database, it's available to act as a persistent store for queued tasks so that tasks are never lost, can be retried until successful, and their concurrent execution can be managed. [Backlite](https://github.com/mikestefanello/backlite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously. I wrote that specifically to address the requirements I wanted to satisfy for this project.
@ -1044,25 +945,27 @@ The cache max-life is controlled by the configuration at `Config.Cache.Expiratio
### Cache-buster ### Cache-buster
While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function is provided in the [funcmap](#funcmap) to generate a static file URL for a given file that appends a cache-buster query. This query string is randomly generated and persisted until the application restarts. While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
For example, to render a file located in `static/picture.png`, you would use: For example, to render a file located in `static/picture.png`, you would use:
```html ```go
<img src="{{file "picture.png"}}"/> return Img(Src(ui.File("picture.png")))
``` ```
Which would result in: Which would result in:
```html ```html
<img src="/files/picture.png?v=9fhe73kaf3"/> <img src="/files/picture.png?v=1741053493"/>
``` ```
Where `9fhe73kaf3` is the randomly-generated cache-buster. Where `1741053493` is the cache-buster.
## Email ## Email
An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is because there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications. An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
The structure in the client (`MailClient`) makes composing emails very easy and you have the option to construct the body using either a simple string or with a template by leveraging the [template renderer](#template-renderer). The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`. The structure in the client (`MailClient`) makes composing emails very easy, and you have the option to construct the body using either a simple string or with a renderable _gomponent_, as explained in the [user interface](#user-interface), in order to produce HTML emails. A simple example is provided in `pkg/ui/emails`.
The standard library can be used if you wish to send email via SMTP and most SaaS providers have a Go package that can be used if you choose to go that direction. **You must** finish the implementation of `MailClient.send`.
The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address. The _from_ address will default to the configuration value at `Config.Mail.FromAddress`. This can be overridden per-email by calling `From()` on the email and passing in the desired address.
@ -1079,19 +982,18 @@ err = c.Mail.
Send(ctx) Send(ctx)
``` ```
**Sending with a template body**: **Sending an HTML body using a gomponent**:
```go ```go
err = c.Mail. err = c.Mail.
Compose(). Compose().
To("hello@example.com"). To("hello@example.com").
Subject("Welcome!"). Subject("Confirm your email address").
Template("welcome"). Component(emails.ConfirmEmailAddress(ctx, username, token)).
TemplateData(templateData).
Send(ctx) Send(ctx)
``` ```
This will use the template located at `templates/emails/welcome.gohtml` and pass `templateData` to it. This will use the HTML provided when rendering the _gomponent_ as the email body.
## HTTPS ## HTTPS
@ -1159,9 +1061,10 @@ Future work includes but is not limited to:
## Credits ## Credits
Thank you to all of the following amazing projects for making this possible. Thank you to all the following amazing projects for making this possible.
- [afero](https://github.com/spf13/afero) - [afero](https://github.com/spf13/afero)
- [air](https://github.com/air-verse/air)
- [alpinejs](https://github.com/alpinejs/alpine) - [alpinejs](https://github.com/alpinejs/alpine)
- [backlite](https://github.com/mikestefanello/backlite) - [backlite](https://github.com/mikestefanello/backlite)
- [bulma](https://github.com/jgthms/bulma) - [bulma](https://github.com/jgthms/bulma)
@ -1169,12 +1072,12 @@ Thank you to all of the following amazing projects for making this possible.
- [ent](https://github.com/ent/ent) - [ent](https://github.com/ent/ent)
- [go](https://go.dev/) - [go](https://go.dev/)
- [go-sqlite3](https://github.com/mattn/go-sqlite3) - [go-sqlite3](https://github.com/mattn/go-sqlite3)
- [gomponents](https://github.com/maragudk/gomponents)
- [goquery](https://github.com/PuerkitoBio/goquery) - [goquery](https://github.com/PuerkitoBio/goquery)
- [htmx](https://github.com/bigskysoftware/htmx) - [htmx](https://github.com/bigskysoftware/htmx)
- [jwt](https://github.com/golang-jwt/jwt) - [jwt](https://github.com/golang-jwt/jwt)
- [otter](https://github.com/maypok86/otter) - [otter](https://github.com/maypok86/otter)
- [sessions](https://github.com/gorilla/sessions) - [sessions](https://github.com/gorilla/sessions)
- [sprig](https://github.com/Masterminds/sprig)
- [sqlite](https://sqlite.org/) - [sqlite](https://sqlite.org/)
- [testify](https://github.com/stretchr/testify) - [testify](https://github.com/stretchr/testify)
- [validator](https://github.com/go-playground/validator) - [validator](https://github.com/go-playground/validator)

View file

@ -9,35 +9,32 @@ import (
) )
const ( const (
// TemplateExt stores the extension used for the template files // StaticDir stores the name of the directory that will serve static files.
TemplateExt = ".gohtml"
// StaticDir stores the name of the directory that will serve static files
StaticDir = "static" StaticDir = "static"
// StaticPrefix stores the URL prefix used when serving static files // StaticPrefix stores the URL prefix used when serving static files.
StaticPrefix = "files" StaticPrefix = "files"
) )
type environment string type environment string
const ( const (
// EnvLocal represents the local environment // EnvLocal represents the local environment.
EnvLocal environment = "local" EnvLocal environment = "local"
// EnvTest represents the test environment // EnvTest represents the test environment.
EnvTest environment = "test" EnvTest environment = "test"
// EnvDevelop represents the development environment // EnvDevelop represents the development environment.
EnvDevelop environment = "dev" EnvDevelop environment = "dev"
// EnvStaging represents the staging environment // EnvStaging represents the staging environment.
EnvStaging environment = "staging" EnvStaging environment = "staging"
// EnvQA represents the qa environment // EnvQA represents the qa environment.
EnvQA environment = "qa" EnvQA environment = "qa"
// EnvProduction represents the production environment // EnvProduction represents the production environment.
EnvProduction environment = "prod" EnvProduction environment = "prod"
) )
@ -51,7 +48,7 @@ func SwitchEnvironment(env environment) {
} }
type ( type (
// Config stores complete configuration // Config stores complete configuration.
Config struct { Config struct {
HTTP HTTPConfig HTTP HTTPConfig
App AppConfig App AppConfig
@ -62,7 +59,7 @@ type (
Mail MailConfig Mail MailConfig
} }
// HTTPConfig stores HTTP configuration // HTTPConfig stores HTTP configuration.
HTTPConfig struct { HTTPConfig struct {
Hostname string Hostname string
Port uint16 Port uint16
@ -77,9 +74,10 @@ type (
} }
} }
// AppConfig stores application configuration // AppConfig stores application configuration.
AppConfig struct { AppConfig struct {
Name string Name string
Host string
Environment environment Environment environment
EncryptionKey string EncryptionKey string
Timeout time.Duration Timeout time.Duration
@ -90,7 +88,7 @@ type (
EmailVerificationTokenExpiration time.Duration EmailVerificationTokenExpiration time.Duration
} }
// CacheConfig stores the cache configuration // CacheConfig stores the cache configuration.
CacheConfig struct { CacheConfig struct {
Capacity int Capacity int
Expiration struct { Expiration struct {
@ -99,19 +97,19 @@ type (
} }
} }
// DatabaseConfig stores the database configuration // DatabaseConfig stores the database configuration.
DatabaseConfig struct { DatabaseConfig struct {
Driver string Driver string
Connection string Connection string
TestConnection string TestConnection string
} }
// FilesConfig stores the file system configuration // FilesConfig stores the file system configuration.
FilesConfig struct { FilesConfig struct {
Directory string Directory string
} }
// TasksConfig stores the tasks configuration // TasksConfig stores the tasks configuration.
TasksConfig struct { TasksConfig struct {
Goroutines int Goroutines int
ReleaseAfter time.Duration ReleaseAfter time.Duration
@ -119,7 +117,7 @@ type (
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
} }
// MailConfig stores the mail configuration // MailConfig stores the mail configuration.
MailConfig struct { MailConfig struct {
Hostname string Hostname string
Port uint16 Port uint16
@ -129,11 +127,11 @@ type (
} }
) )
// GetConfig loads and returns configuration // GetConfig loads and returns configuration.
func GetConfig() (Config, error) { func GetConfig() (Config, error) {
var c Config var c Config
// Load the config file // Load the config file.
viper.SetConfigName("config") viper.SetConfigName("config")
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.AddConfigPath(".") viper.AddConfigPath(".")
@ -141,7 +139,7 @@ func GetConfig() (Config, error) {
viper.AddConfigPath("../config") viper.AddConfigPath("../config")
viper.AddConfigPath("../../config") viper.AddConfigPath("../../config")
// Load env variables // Load env variables.
viper.SetEnvPrefix("pagoda") viper.SetEnvPrefix("pagoda")
viper.AutomaticEnv() viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

View file

@ -12,8 +12,11 @@ http:
app: app:
name: "Pagoda" name: "Pagoda"
# We manually set this rather than using the HTTP settings in order to build absolute URLs for users
# since it's likely your app's HTTP settings are not identical to what is exposed by your server.
host: "http://localhost:8000"
environment: "local" environment: "local"
# Change this on any live environments # Change this on any live environments.
encryptionKey: "?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf" encryptionKey: "?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf"
timeout: "20s" timeout: "20s"
passwordToken: passwordToken:

10
go.mod
View file

@ -4,14 +4,12 @@ go 1.23.0
require ( require (
entgo.io/ent v0.14.2 entgo.io/ent v0.14.2
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/PuerkitoBio/goquery v1.10.1 github.com/PuerkitoBio/goquery v1.10.1
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gorilla/context v1.1.2 github.com/gorilla/context v1.1.2
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/labstack/echo/v4 v4.13.3 github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/maypok86/otter v1.2.4 github.com/maypok86/otter v1.2.4
github.com/mikestefanello/backlite v0.2.0 github.com/mikestefanello/backlite v0.2.0
@ -19,12 +17,11 @@ require (
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.33.0
maragu.dev/gomponents v1.1.0
) )
require ( require (
ariga.io/atlas v0.31.1-0.20250212144724-069be8033e83 // indirect ariga.io/atlas v0.31.1-0.20250212144724-069be8033e83 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/agext/levenshtein v1.2.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
@ -42,16 +39,13 @@ require (
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.20.1 // indirect github.com/hashicorp/hcl/v2 v2.20.1 // indirect
github.com/huandu/xstrings v1.4.0 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect

22
go.sum
View file

@ -4,12 +4,6 @@ entgo.io/ent v0.14.2 h1:ywld/j2Rx4EmnIKs8eZ29cbFA1zpB+DA9TLL5l3rlq0=
entgo.io/ent v0.14.2/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= entgo.io/ent v0.14.2/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU= github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY= github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
@ -64,10 +58,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc=
github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -88,24 +78,16 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/mikestefanello/backlite v0.2.0 h1:nX+QUy/z5FEV+ApMqvZ1jGqyRPGbuBI2mJR0BvirT9s= github.com/mikestefanello/backlite v0.2.0 h1:nX+QUy/z5FEV+ApMqvZ1jGqyRPGbuBI2mJR0BvirT9s=
github.com/mikestefanello/backlite v0.2.0/go.mod h1:/vj8LPZWG/xqK/3uHaqOtu5JRLDEWqeyJKWTAlADTV0= github.com/mikestefanello/backlite v0.2.0/go.mod h1:/vj8LPZWG/xqK/3uHaqOtu5JRLDEWqeyJKWTAlADTV0=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -123,8 +105,6 @@ github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@ -245,3 +225,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=

View file

@ -3,29 +3,54 @@ package context
import ( import (
"context" "context"
"errors" "errors"
"github.com/labstack/echo/v4"
) )
const ( 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" 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" 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" 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" 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" 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" 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 { func IsCanceledError(err error) bool {
return errors.Is(err, context.Canceled) 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
}

View file

@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -22,3 +23,25 @@ func TestIsCanceled(t *testing.T) {
assert.False(t, IsCanceledError(errors.New("test error"))) 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)
}

View file

@ -5,7 +5,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/context" "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 { type Form interface {
// Submit marks the form as submitted, stores a pointer to it in the context, binds the request // 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. // 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. // Returns an echo.HTTPError if the request failed to process.
Submit(c echo.Context, form any) error 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 IsSubmitted() bool
// IsValid returns true if the form has no validation errors // IsValid returns true if the form has no validation errors.
IsValid() bool 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 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 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) 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 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 { func Get[T any](ctx echo.Context) *T {
if v := ctx.Get(context.FormKey); v != nil { if v := ctx.Get(context.FormKey); v != nil {
return v.(*T) return v.(*T)
@ -44,13 +41,13 @@ func Get[T any](ctx echo.Context) *T {
return &v return &v
} }
// Clear removes the form set in the context // Clear removes the form set in the context.
func Clear(ctx echo.Context) { func Clear(ctx echo.Context) {
ctx.Set(context.FormKey, nil) ctx.Set(context.FormKey, nil)
} }
// Submit submits a form // Submit submits a form.
// See Form.Submit() // See Form.Submit().
func Submit(ctx echo.Context, form Form) error { func Submit(ctx echo.Context, form Form) error {
return form.Submit(ctx, form) return form.Submit(ctx, form)
} }

View file

@ -13,25 +13,25 @@ import (
// Submission represents the state of the submission of a form, not including the form itself. // Submission represents the state of the submission of a form, not including the form itself.
// This satisfies the Form interface. // This satisfies the Form interface.
type Submission struct { type Submission struct {
// isSubmitted indicates if the form has been submitted // isSubmitted indicates if the form has been submitted.
isSubmitted bool 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 errors map[string][]string
} }
func (f *Submission) Submit(ctx echo.Context, form any) error { func (f *Submission) Submit(ctx echo.Context, form any) error {
f.isSubmitted = true 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) 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 { if err := ctx.Bind(form); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("unable to bind form: %v", err)) 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 { if err := ctx.Validate(form); err != nil {
f.setErrorMessages(err) f.setErrorMessages(err)
return err return err
@ -73,17 +73,7 @@ func (f *Submission) GetFieldErrors(fieldName string) []string {
return f.errors[fieldName] return f.errors[fieldName]
} }
func (f *Submission) GetFieldStatusClass(fieldName string) string { // setErrorMessages sets errors messages on the submission for all fields that failed validation.
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
func (f *Submission) setErrorMessages(err error) { func (f *Submission) setErrorMessages(err error) {
// Only this is supported right now // Only this is supported right now
ves, ok := err.(validator.ValidationErrors) ves, ok := err.(validator.ValidationErrors)
@ -94,8 +84,8 @@ func (f *Submission) setErrorMessages(err error) {
for _, ve := range ves { for _, ve := range ves {
var message string var message string
// Provide better error messages depending on the failed validation tag // Provide better error messages depending on the failed validation tag.
// This should be expanded as you use additional tags in your validation // This should be expanded as you use additional tags in your validation.
switch ve.Tag() { switch ve.Tag() {
case "required": case "required":
message = "This field is required." message = "This field is required."
@ -109,7 +99,7 @@ func (f *Submission) setErrorMessages(err error) {
message = "Invalid value." message = "Invalid value."
} }
// Add the error // Add the error.
f.SetFieldError(ve.Field(), message) f.SetFieldError(ve.Field(), message)
} }
} }

View file

@ -40,8 +40,6 @@ func TestFormSubmission(t *testing.T) {
require.Len(t, form.GetFieldErrors("Name"), 1) require.Len(t, form.GetFieldErrors("Name"), 1)
assert.Len(t, form.GetFieldErrors("Email"), 0) assert.Len(t, form.GetFieldErrors("Email"), 0)
assert.Equal(t, "This field is required.", form.GetFieldErrors("Name")[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()) assert.False(t, form.IsDone())
formInCtx := Get[formTest](ctx) formInCtx := Get[formTest](ctx)

View file

@ -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...)
}

View file

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

View file

@ -13,65 +13,25 @@ import (
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/msg" "github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/redirect" "github.com/mikestefanello/pagoda/pkg/redirect"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services" "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 ( type Auth struct {
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 auth *services.AuthClient
mail *services.MailClient mail *services.MailClient
orm *ent.Client 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
}
)
func init() { func init() {
Register(new(Auth)) Register(new(Auth))
} }
func (h *Auth) Init(c *services.Container) error { func (h *Auth) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.orm = c.ORM h.orm = c.ORM
h.auth = c.Auth h.auth = c.Auth
h.mail = c.Mail h.mail = c.Mail
@ -79,37 +39,31 @@ func (h *Auth) Init(c *services.Container) error {
} }
func (h *Auth) Routes(g *echo.Group) { func (h *Auth) Routes(g *echo.Group) {
g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routeNameLogout g.GET("/logout", h.Logout, middleware.RequireAuthentication()).Name = routenames.Logout
g.GET("/email/verify/:token", h.VerifyEmail).Name = routeNameVerifyEmail g.GET("/email/verify/:token", h.VerifyEmail).Name = routenames.VerifyEmail
noAuth := g.Group("/user", middleware.RequireNoAuthentication()) noAuth := g.Group("/user", middleware.RequireNoAuthentication())
noAuth.GET("/login", h.LoginPage).Name = routeNameLogin noAuth.GET("/login", h.LoginPage).Name = routenames.Login
noAuth.POST("/login", h.LoginSubmit).Name = routeNameLoginSubmit noAuth.POST("/login", h.LoginSubmit).Name = routenames.LoginSubmit
noAuth.GET("/register", h.RegisterPage).Name = routeNameRegister noAuth.GET("/register", h.RegisterPage).Name = routenames.Register
noAuth.POST("/register", h.RegisterSubmit).Name = routeNameRegisterSubmit noAuth.POST("/register", h.RegisterSubmit).Name = routenames.RegisterSubmit
noAuth.GET("/password", h.ForgotPasswordPage).Name = routeNameForgotPassword noAuth.GET("/password", h.ForgotPasswordPage).Name = routenames.ForgotPassword
noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routeNameForgotPasswordSubmit noAuth.POST("/password", h.ForgotPasswordSubmit).Name = routenames.ForgotPasswordSubmit
resetGroup := noAuth.Group("/password/reset", resetGroup := noAuth.Group("/password/reset",
middleware.LoadUser(h.orm), middleware.LoadUser(h.orm),
middleware.LoadValidPasswordToken(h.auth), middleware.LoadValidPasswordToken(h.auth),
) )
resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routeNameResetPassword resetGroup.GET("/token/:user/:password_token/:token", h.ResetPasswordPage).Name = routenames.ResetPassword
resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routeNameResetPasswordSubmit resetGroup.POST("/token/:user/:password_token/:token", h.ResetPasswordSubmit).Name = routenames.ResetPasswordSubmit
} }
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error { func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
p := page.New(ctx) return pages.ForgotPassword(ctx, form.Get[forms.ForgotPassword](ctx))
p.Layout = templates.LayoutAuth
p.Name = templates.PageForgotPassword
p.Title = "Forgot password"
p.Form = form.Get[forgotPasswordForm](ctx)
return h.RenderPage(ctx, p)
} }
func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error { func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
var input forgotPasswordForm var input forms.ForgotPassword
succeed := func() error { succeed := func() error {
form.Clear(ctx) form.Clear(ctx)
@ -127,7 +81,7 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
return err return err
} }
// Attempt to load the user // Attempt to load the user.
u, err := h.orm.User. u, err := h.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(input.Email))). 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") return fail(err, "error querying user during forgot password")
} }
// Generate the token // Generate the token.
token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID) token, pt, err := h.auth.GeneratePasswordResetToken(ctx, u.ID)
if err != nil { if err != nil {
return fail(err, "error generating password reset token") return fail(err, "error generating password reset token")
@ -151,8 +105,8 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
"user_id", u.ID, "user_id", u.ID,
) )
// Email the user // Email the user.
url := ctx.Echo().Reverse(routeNameResetPassword, u.ID, pt.ID, token) url := ctx.Echo().Reverse(routenames.ResetPassword, u.ID, pt.ID, token)
err = h.mail. err = h.mail.
Compose(). Compose().
To(u.Email). To(u.Email).
@ -168,17 +122,11 @@ func (h *Auth) ForgotPasswordSubmit(ctx echo.Context) error {
} }
func (h *Auth) LoginPage(ctx echo.Context) error { func (h *Auth) LoginPage(ctx echo.Context) error {
p := page.New(ctx) return pages.Login(ctx, form.Get[forms.Login](ctx))
p.Layout = templates.LayoutAuth
p.Name = templates.PageLogin
p.Title = "Log in"
p.Form = form.Get[loginForm](ctx)
return h.RenderPage(ctx, p)
} }
func (h *Auth) LoginSubmit(ctx echo.Context) error { func (h *Auth) LoginSubmit(ctx echo.Context) error {
var input loginForm var input forms.Login
authFailed := func() error { authFailed := func() error {
input.SetFieldError("Email", "") input.SetFieldError("Email", "")
@ -197,7 +145,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
return err return err
} }
// Attempt to load the user // Attempt to load the user.
u, err := h.orm.User. u, err := h.orm.User.
Query(). Query().
Where(user.Email(strings.ToLower(input.Email))). 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") 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) err = h.auth.CheckPassword(input.Password, u.Password)
if err != nil { if err != nil {
return authFailed() return authFailed()
} }
// Log the user in // Log the user in.
err = h.auth.Login(ctx, u.ID) err = h.auth.Login(ctx, u.ID)
if err != nil { if err != nil {
return fail(err, "unable to log in user") 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). return redirect.New(ctx).
Route(routeNameHome). Route(routenames.Home).
Go() Go()
} }
@ -237,22 +185,16 @@ func (h *Auth) Logout(ctx echo.Context) error {
msg.Danger(ctx, "An error occurred. Please try again.") msg.Danger(ctx, "An error occurred. Please try again.")
} }
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameHome). Route(routenames.Home).
Go() Go()
} }
func (h *Auth) RegisterPage(ctx echo.Context) error { func (h *Auth) RegisterPage(ctx echo.Context) error {
p := page.New(ctx) return pages.Register(ctx, form.Get[forms.Register](ctx))
p.Layout = templates.LayoutAuth
p.Name = templates.PageRegister
p.Title = "Register"
p.Form = form.Get[registerForm](ctx)
return h.RenderPage(ctx, p)
} }
func (h *Auth) RegisterSubmit(ctx echo.Context) error { func (h *Auth) RegisterSubmit(ctx echo.Context) error {
var input registerForm var input forms.Register
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -264,13 +206,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
return err return err
} }
// Hash the password // Hash the password.
pwHash, err := h.auth.HashPassword(input.Password) pwHash, err := h.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return fail(err, "unable to hash password") return fail(err, "unable to hash password")
} }
// Attempt creating the user // Attempt creating the user.
u, err := h.orm.User. u, err := h.orm.User.
Create(). Create().
SetName(input.Name). SetName(input.Name).
@ -287,13 +229,13 @@ func (h *Auth) RegisterSubmit(ctx echo.Context) error {
case *ent.ConstraintError: case *ent.ConstraintError:
msg.Warning(ctx, "A user with this email address already exists. Please log in.") msg.Warning(ctx, "A user with this email address already exists. Please log in.")
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameLogin). Route(routenames.Login).
Go() Go()
default: default:
return fail(err, "unable to create user") return fail(err, "unable to create user")
} }
// Log the user in // Log the user in.
err = h.auth.Login(ctx, u.ID) err = h.auth.Login(ctx, u.ID)
if err != nil { if err != nil {
log.Ctx(ctx).Error("unable to log user in", 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.") msg.Info(ctx, "Your account has been created.")
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameLogin). Route(routenames.Login).
Go() Go()
} }
msg.Success(ctx, "Your account has been created. You are now logged in.") 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) h.sendVerificationEmail(ctx, u)
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameHome). Route(routenames.Home).
Go() Go()
} }
func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) { func (h *Auth) sendVerificationEmail(ctx echo.Context, usr *ent.User) {
// Generate a token // Generate a token.
token, err := h.auth.GenerateEmailVerificationToken(usr.Email) token, err := h.auth.GenerateEmailVerificationToken(usr.Email)
if err != nil { if err != nil {
log.Ctx(ctx).Error("unable to generate email verification token", 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 return
} }
// Send the email // Send the email.
url := ctx.Echo().Reverse(routeNameVerifyEmail, token)
err = h.mail. err = h.mail.
Compose(). Compose().
To(usr.Email). To(usr.Email).
Subject("Confirm your email address"). 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) Send(ctx)
if err != nil { 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 { func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
p := page.New(ctx) return pages.ResetPassword(ctx, form.Get[forms.ResetPassword](ctx))
p.Layout = templates.LayoutAuth
p.Name = templates.PageResetPassword
p.Title = "Reset password"
p.Form = form.Get[resetPasswordForm](ctx)
return h.RenderPage(ctx, p)
} }
func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error { func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
var input resetPasswordForm var input forms.ResetPassword
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)
@ -370,16 +305,16 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
return err return err
} }
// Hash the new password // Hash the new password.
hash, err := h.auth.HashPassword(input.Password) hash, err := h.auth.HashPassword(input.Password)
if err != nil { if err != nil {
return fail(err, "unable to hash password") return fail(err, "unable to hash password")
} }
// Get the requesting user // Get the requesting user.
usr := ctx.Get(context.UserKey).(*ent.User) usr := ctx.Get(context.UserKey).(*ent.User)
// Update the user // Update the user.
_, err = usr. _, err = usr.
Update(). Update().
SetPassword(hash). SetPassword(hash).
@ -389,7 +324,7 @@ func (h *Auth) ResetPasswordSubmit(ctx echo.Context) error {
return fail(err, "unable to update password") 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) err = h.auth.DeletePasswordTokens(ctx, usr.ID)
if err != nil { if err != nil {
return fail(err, "unable to delete password tokens") 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.") msg.Success(ctx, "Your password has been updated.")
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameLogin). Route(routenames.Login).
Go() Go()
} }
func (h *Auth) VerifyEmail(ctx echo.Context) error { func (h *Auth) VerifyEmail(ctx echo.Context) error {
var usr *ent.User var usr *ent.User
// Validate the token // Validate the token.
token := ctx.Param("token") token := ctx.Param("token")
email, err := h.auth.ValidateEmailVerificationToken(token) email, err := h.auth.ValidateEmailVerificationToken(token)
if err != nil { if err != nil {
msg.Warning(ctx, "The link is either invalid or has expired.") msg.Warning(ctx, "The link is either invalid or has expired.")
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameHome). Route(routenames.Home).
Go() Go()
} }
// Check if it matches the authenticated user // Check if it matches the authenticated user.
if u := ctx.Get(context.AuthenticatedUserKey); u != nil { if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
authUser := u.(*ent.User) 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 { if usr == nil {
usr, err = h.orm.User. usr, err = h.orm.User.
Query(). 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 { if !usr.Verified {
usr, err = usr. usr, err = usr.
Update(). Update().
@ -449,6 +384,6 @@ func (h *Auth) VerifyEmail(ctx echo.Context) error {
msg.Success(ctx, "Your email has been successfully verified.") msg.Success(ctx, "Your email has been successfully verified.")
return redirect.New(ctx). return redirect.New(ctx).
Route(routeNameHome). Route(routenames.Home).
Go() Go()
} }

View file

@ -2,79 +2,63 @@ package handlers
import ( import (
"errors" "errors"
"time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form" "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/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/pkg/ui/forms"
"time" "github.com/mikestefanello/pagoda/pkg/ui/pages"
) )
const ( type Cache struct {
routeNameCache = "cache"
routeNameCacheSubmit = "cache.submit"
)
type (
Cache struct {
cache *services.CacheClient cache *services.CacheClient
*services.TemplateRenderer }
}
cacheForm struct {
Value string `form:"value"`
form.Submission
}
)
func init() { func init() {
Register(new(Cache)) Register(new(Cache))
} }
func (h *Cache) Init(c *services.Container) error { func (h *Cache) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.cache = c.Cache h.cache = c.Cache
return nil return nil
} }
func (h *Cache) Routes(g *echo.Group) { func (h *Cache) Routes(g *echo.Group) {
g.GET("/cache", h.Page).Name = routeNameCache g.GET("/cache", h.Page).Name = routenames.Cache
g.POST("/cache", h.Submit).Name = routeNameCacheSubmit g.POST("/cache", h.Submit).Name = routenames.CacheSubmit
} }
func (h *Cache) Page(ctx echo.Context) error { func (h *Cache) Page(ctx echo.Context) error {
p := page.New(ctx) f := form.Get[forms.Cache](ctx)
p.Layout = templates.LayoutMain
p.Name = templates.PageCache
p.Title = "Set a cache entry"
p.Form = form.Get[cacheForm](ctx)
// Fetch the value from the cache // Fetch the value from the cache.
value, err := h.cache. value, err := h.cache.
Get(). Get().
Key("page_cache_example"). Key("page_cache_example").
Fetch(ctx.Request().Context()) 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 { switch {
case err == nil: case err == nil:
p.Data = value.(string) f.CurrentValue = value.(string)
case errors.Is(err, services.ErrCacheMiss): case errors.Is(err, services.ErrCacheMiss):
default: default:
return fail(err, "failed to fetch from cache") 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 { func (h *Cache) Submit(ctx echo.Context) error {
var input cacheForm var input forms.Cache
if err := form.Submit(ctx, &input); err != nil { if err := form.Submit(ctx, &input); err != nil {
return err return err
} }
// Set the cache // Set the cache.
err := h.cache. err := h.cache.
Set(). Set().
Key("page_cache_example"). Key("page_cache_example").

View file

@ -2,60 +2,40 @@ package handlers
import ( import (
"fmt" "fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form" "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/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
) )
const ( type Contact struct {
routeNameContact = "contact"
routeNameContactSubmit = "contact.submit"
)
type (
Contact struct {
mail *services.MailClient 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
}
)
func init() { func init() {
Register(new(Contact)) Register(new(Contact))
} }
func (h *Contact) Init(c *services.Container) error { func (h *Contact) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.mail = c.Mail h.mail = c.Mail
return nil return nil
} }
func (h *Contact) Routes(g *echo.Group) { func (h *Contact) Routes(g *echo.Group) {
g.GET("/contact", h.Page).Name = routeNameContact g.GET("/contact", h.Page).Name = routenames.Contact
g.POST("/contact", h.Submit).Name = routeNameContactSubmit g.POST("/contact", h.Submit).Name = routenames.ContactSubmit
} }
func (h *Contact) Page(ctx echo.Context) error { func (h *Contact) Page(ctx echo.Context) error {
p := page.New(ctx) return pages.ContactUs(ctx, form.Get[forms.Contact](ctx))
p.Layout = templates.LayoutMain
p.Name = templates.PageContact
p.Title = "Contact us"
p.Form = form.Get[contactForm](ctx)
return h.RenderPage(ctx, p)
} }
func (h *Contact) Submit(ctx echo.Context) error { func (h *Contact) Submit(ctx echo.Context) error {
var input contactForm var input forms.Contact
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)

View file

@ -6,27 +6,23 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context" "github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/ui/pages"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates"
) )
type Error struct { type Error struct{}
*services.TemplateRenderer
}
func (e *Error) Page(err error, ctx echo.Context) { func (e *Error) Page(err error, ctx echo.Context) {
if ctx.Response().Committed || context.IsCanceledError(err) { if ctx.Response().Committed || context.IsCanceledError(err) {
return return
} }
// Determine the error status code // Determine the error status code.
code := http.StatusInternalServerError code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok { if he, ok := err.(*echo.HTTPError); ok {
code = he.Code code = he.Code
} }
// Log the error // Log the error.
logger := log.Ctx(ctx) logger := log.Ctx(ctx)
switch { switch {
case code >= 500: case code >= 500:
@ -35,15 +31,11 @@ func (e *Error) Page(err error, ctx echo.Context) {
logger.Warn(err.Error()) logger.Warn(err.Error())
} }
// Render the error page // Set the status code.
p := page.New(ctx) ctx.Response().Status = code
p.Layout = templates.LayoutMain
p.Name = templates.PageError
p.Title = http.StatusText(code)
p.StatusCode = code
p.HTMX.Request.Enabled = false
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", log.Ctx(ctx).Error("failed to render error page",
"error", err, "error", err,
) )

View file

@ -7,69 +7,48 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/msg" "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/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" "github.com/spf13/afero"
) )
const ( type Files struct {
routeNameFiles = "files"
routeNameFilesSubmit = "files.submit"
)
type (
Files struct {
files afero.Fs files afero.Fs
*services.TemplateRenderer }
}
File struct {
Name string
Size int64
Modified string
}
)
func init() { func init() {
Register(new(Files)) Register(new(Files))
} }
func (h *Files) Init(c *services.Container) error { func (h *Files) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.files = c.Files h.files = c.Files
return nil return nil
} }
func (h *Files) Routes(g *echo.Group) { func (h *Files) Routes(g *echo.Group) {
g.GET("/files", h.Page).Name = routeNameFiles g.GET("/files", h.Page).Name = routenames.Files
g.POST("/files", h.Submit).Name = routeNameFilesSubmit g.POST("/files", h.Submit).Name = routenames.FilesSubmit
} }
func (h *Files) Page(ctx echo.Context) error { func (h *Files) Page(ctx echo.Context) error {
p := page.New(ctx) // Compile a list of all uploaded files to be rendered.
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.
info, err := afero.ReadDir(h.files, "") info, err := afero.ReadDir(h.files, "")
if err != nil { if err != nil {
return err return err
} }
files := make([]File, 0) files := make([]*models.File, 0)
for _, file := range info { for _, file := range info {
files = append(files, File{ files = append(files, &models.File{
Name: file.Name(), Name: file.Name(),
Size: file.Size(), Size: file.Size(),
Modified: file.ModTime().Format(time.DateTime), Modified: file.ModTime().Format(time.DateTime),
}) })
} }
p.Data = files return pages.UploadFile(ctx, files)
return h.RenderPage(ctx, p)
} }
func (h *Files) Submit(ctx echo.Context) error { func (h *Files) Submit(ctx echo.Context) error {

View file

@ -2,74 +2,46 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/templates" "github.com/mikestefanello/pagoda/pkg/ui/models"
"github.com/mikestefanello/pagoda/pkg/ui/pages"
) )
const ( type Pages struct{}
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
}
)
func init() { func init() {
Register(new(Pages)) Register(new(Pages))
} }
func (h *Pages) Init(c *services.Container) error { func (h *Pages) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
return nil return nil
} }
func (h *Pages) Routes(g *echo.Group) { func (h *Pages) Routes(g *echo.Group) {
g.GET("/", h.Home).Name = routeNameHome g.GET("/", h.Home).Name = routenames.Home
g.GET("/about", h.About).Name = routeNameAbout g.GET("/about", h.About).Name = routenames.About
} }
func (h *Pages) Home(ctx echo.Context) error { func (h *Pages) Home(ctx echo.Context) error {
p := page.New(ctx) pgr := pager.NewPager(ctx, 4)
p.Layout = templates.LayoutMain
p.Name = templates.PageHome
p.Metatags.Description = "Welcome to the homepage."
p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
p.Pager = page.NewPager(ctx, 4)
p.Data = h.fetchPosts(&p.Pager)
return h.RenderPage(ctx, p) 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 // fetchPosts is a mock example of fetching posts to illustrate how paging works.
func (h *Pages) fetchPosts(pager *page.Pager) []post { func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
pager.SetItems(20) pager.SetItems(20)
posts := make([]post, 20) posts := make([]models.Post, 20)
for k := range posts { for k := range posts {
posts[k] = post{ posts[k] = models.Post{
Title: fmt.Sprintf("Post example #%d", k+1), 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), 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 { func (h *Pages) About(ctx echo.Context) error {
p := page.New(ctx) return pages.About(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)
} }

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -11,7 +12,7 @@ import (
// this test package // this test package
func TestPages__About(t *testing.T) { func TestPages__About(t *testing.T) {
doc := request(t). doc := request(t).
setRoute(routeNameAbout). setRoute(routenames.About).
get(). get().
assertStatusCode(http.StatusOK). assertStatusCode(http.StatusOK).
toDoc() toDoc()

View file

@ -6,27 +6,28 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
echomw "github.com/labstack/echo/v4/middleware" echomw "github.com/labstack/echo/v4/middleware"
"github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/middleware" "github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
) )
// BuildRouter builds the router // BuildRouter builds the router.
func BuildRouter(c *services.Container) error { func BuildRouter(c *services.Container) error {
// Static files with proper cache control // 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 // 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 // after each server restart.
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)). c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
Static(config.StaticPrefix, config.StaticDir) Static(config.StaticPrefix, config.StaticDir)
// Non-static file route group // Non-static file route group.
g := c.Web.Group("") g := c.Web.Group("")
// Force HTTPS, if enabled // Force HTTPS, if enabled.
if c.Config.HTTP.TLS.Enabled { if c.Config.HTTP.TLS.Enabled {
g.Use(echomw.HTTPSRedirect()) 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 := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
cookieStore.Options.HttpOnly = true cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = true cookieStore.Options.Secure = true
@ -45,22 +46,22 @@ func BuildRouter(c *services.Container) error {
echomw.TimeoutWithConfig(echomw.TimeoutConfig{ echomw.TimeoutWithConfig(echomw.TimeoutConfig{
Timeout: c.Config.App.Timeout, Timeout: c.Config.App.Timeout,
}), }),
middleware.Config(c.Config),
middleware.Session(cookieStore), middleware.Session(cookieStore),
middleware.LoadAuthenticatedUser(c.Auth), middleware.LoadAuthenticatedUser(c.Auth),
middleware.ServeCachedPage(c.TemplateRenderer),
echomw.CSRFWithConfig(echomw.CSRFConfig{ echomw.CSRFWithConfig(echomw.CSRFConfig{
TokenLookup: "form:csrf", TokenLookup: "form:csrf",
CookieHTTPOnly: true, CookieHTTPOnly: true,
CookieSecure: true, CookieSecure: true,
CookieSameSite: http.SameSiteStrictMode, CookieSameSite: http.SameSiteStrictMode,
ContextKey: context.CSRFKey,
}), }),
) )
// Error handler // Error handler.
err := Error{c.TemplateRenderer} c.Web.HTTPErrorHandler = new(Error).Page
c.Web.HTTPErrorHandler = err.Page
// Initialize and register all handlers // Initialize and register all handlers.
for _, h := range GetHandlers() { for _, h := range GetHandlers() {
if err := h.Init(c); err != nil { if err := h.Init(c); err != nil {
return err return err

View file

@ -5,56 +5,40 @@ import (
"math/rand" "math/rand"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/page" "github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/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{}
type (
Search struct {
*services.TemplateRenderer
}
searchResult struct {
Title string
URL string
}
)
func init() { func init() {
Register(new(Search)) Register(new(Search))
} }
func (h *Search) Init(c *services.Container) error { func (h *Search) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
return nil return nil
} }
func (h *Search) Routes(g *echo.Group) { 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 { func (h *Search) Page(ctx echo.Context) error {
p := page.New(ctx) // Fake search results.
p.Layout = templates.LayoutMain results := make([]*models.SearchResult, 0, 5)
p.Name = templates.PageSearch
// Fake search results
var results []searchResult
if search := ctx.QueryParam("query"); search != "" { if search := ctx.QueryParam("query"); search != "" {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
title := "Lorem ipsum example ddolor sit amet" title := "Lorem ipsum example ddolor sit amet"
index := rand.Intn(len(title)) index := rand.Intn(len(title))
title = title[:index] + search + title[index:] title = title[:index] + search + title[index:]
results = append(results, searchResult{ results = append(results, &models.SearchResult{
Title: title, Title: title,
URL: fmt.Sprintf("https://www.%s.com", search), URL: fmt.Sprintf("https://www.%s.com", search),
}) })
} }
} }
p.Data = results
return h.RenderPage(ctx, p) return pages.SearchResults(ctx, results)
} }

View file

@ -2,64 +2,45 @@ package handlers
import ( import (
"fmt" "fmt"
"time"
"github.com/mikestefanello/backlite" "github.com/mikestefanello/backlite"
"github.com/mikestefanello/pagoda/pkg/msg" "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/go-playground/validator/v10"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/form" "github.com/mikestefanello/pagoda/pkg/form"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/services" "github.com/mikestefanello/pagoda/pkg/services"
"github.com/mikestefanello/pagoda/pkg/tasks" "github.com/mikestefanello/pagoda/pkg/tasks"
"github.com/mikestefanello/pagoda/templates"
) )
const ( type Task struct {
routeNameTask = "task"
routeNameTaskSubmit = "task.submit"
)
type (
Task struct {
tasks *backlite.Client tasks *backlite.Client
*services.TemplateRenderer }
}
taskForm struct {
Delay int `form:"delay" validate:"gte=0"`
Message string `form:"message" validate:"required"`
form.Submission
}
)
func init() { func init() {
Register(new(Task)) Register(new(Task))
} }
func (h *Task) Init(c *services.Container) error { func (h *Task) Init(c *services.Container) error {
h.TemplateRenderer = c.TemplateRenderer
h.tasks = c.Tasks h.tasks = c.Tasks
return nil return nil
} }
func (h *Task) Routes(g *echo.Group) { func (h *Task) Routes(g *echo.Group) {
g.GET("/task", h.Page).Name = routeNameTask g.GET("/task", h.Page).Name = routenames.Task
g.POST("/task", h.Submit).Name = routeNameTaskSubmit g.POST("/task", h.Submit).Name = routenames.TaskSubmit
} }
func (h *Task) Page(ctx echo.Context) error { func (h *Task) Page(ctx echo.Context) error {
p := page.New(ctx) return pages.AddTask(ctx, form.Get[forms.Task](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)
} }
func (h *Task) Submit(ctx echo.Context) error { func (h *Task) Submit(ctx echo.Context) error {
var input taskForm var input forms.Task
err := form.Submit(ctx, &input) err := form.Submit(ctx, &input)

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/context"
) )
// Request headers: https://htmx.org/docs/#request-headers // Request headers: https://htmx.org/docs/#request-headers
@ -28,7 +29,7 @@ const (
) )
type ( type (
// Request contains data that HTMX provides during requests // Request contains data that HTMX provides during requests.
Request struct { Request struct {
Enabled bool Enabled bool
Boosted bool Boosted bool
@ -39,7 +40,7 @@ type (
Prompt string 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 { Response struct {
PushURL string PushURL string
Redirect string Redirect string
@ -52,9 +53,10 @@ type (
} }
) )
// GetRequest extracts HTMX data from the request // GetRequest extracts HTMX data from the request,
func GetRequest(ctx echo.Context) Request { func GetRequest(ctx echo.Context) *Request {
return Request{ return context.Cache(ctx, context.HTMXRequestKey, func(ctx echo.Context) *Request {
return &Request{
Enabled: ctx.Request().Header.Get(HeaderRequest) == "true", Enabled: ctx.Request().Header.Get(HeaderRequest) == "true",
Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true", Boosted: ctx.Request().Header.Get(HeaderBoosted) == "true",
Trigger: ctx.Request().Header.Get(HeaderTrigger), Trigger: ctx.Request().Header.Get(HeaderTrigger),
@ -63,9 +65,10 @@ func GetRequest(ctx echo.Context) Request {
Prompt: ctx.Request().Header.Get(HeaderPrompt), Prompt: ctx.Request().Header.Get(HeaderPrompt),
HistoryRestore: ctx.Request().Header.Get(HeaderHistoryRestoreRequest) == "true", 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) { func (r Response) Apply(ctx echo.Context) {
if r.PushURL != "" { if r.PushURL != "" {
ctx.Response().Header().Set(HeaderPushURL, r.PushURL) ctx.Response().Header().Set(HeaderPushURL, r.PushURL)

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"testing" "testing"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -29,6 +30,11 @@ func TestSetRequest(t *testing.T) {
assert.Equal(t, "b", r.TriggerName) assert.Equal(t, "b", r.TriggerName)
assert.Equal(t, "c", r.Target) assert.Equal(t, "c", r.Target)
assert.Equal(t, "d", r.Prompt) 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) { func TestResponse_Apply(t *testing.T) {

View file

@ -7,12 +7,12 @@ import (
"github.com/mikestefanello/pagoda/pkg/context" "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) { func Set(ctx echo.Context, logger *slog.Logger) {
ctx.Set(context.LoggerKey, 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 { func Ctx(ctx echo.Context) *slog.Logger {
if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok { if l, ok := ctx.Get(context.LoggerKey).(*slog.Logger); ok {
return l return l
@ -21,7 +21,7 @@ func Ctx(ctx echo.Context) *slog.Logger {
return Default() return Default()
} }
// Default returns the default logger // Default returns the default logger.
func Default() *slog.Logger { func Default() *slog.Logger {
return slog.Default() return slog.Default()
} }

View file

@ -1,66 +1,13 @@
package middleware package middleware
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"time" "time"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/services"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// ServeCachedPage attempts to load a page from the cache by matching on the complete request URL // CacheControl sets a Cache-Control header with a given max age.
// 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
func CacheControl(maxAge time.Duration) echo.MiddlewareFunc { func CacheControl(maxAge time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error { return func(ctx echo.Context) error {

View file

@ -1,52 +1,14 @@
package middleware package middleware
import ( import (
"net/http"
"testing" "testing"
"time" "time"
"github.com/mikestefanello/pagoda/pkg/page"
"github.com/mikestefanello/pagoda/pkg/tests" "github.com/mikestefanello/pagoda/pkg/tests"
"github.com/mikestefanello/pagoda/templates"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert" "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) { func TestCacheControl(t *testing.T) {
ctx, _ := tests.NewContext(c.Web, "/") ctx, _ := tests.NewContext(c.Web, "/")
_ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5)) _ = tests.ExecuteMiddleware(ctx, CacheControl(time.Second*5))

17
pkg/middleware/config.go Normal file
View 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)
}
}
}

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

View file

@ -12,7 +12,7 @@ import (
"github.com/labstack/echo/v4" "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 { func LoadUser(orm *ent.Client) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {

View file

@ -7,44 +7,44 @@ import (
"github.com/mikestefanello/pagoda/pkg/session" "github.com/mikestefanello/pagoda/pkg/session"
) )
// Type is a message type // Type is a message type.
type Type string type Type string
const ( const (
// TypeSuccess represents a success message type // TypeSuccess represents a success message type.
TypeSuccess Type = "success" TypeSuccess Type = "success"
// TypeInfo represents a info message type // TypeInfo represents a info message type.
TypeInfo Type = "info" TypeInfo Type = "info"
// TypeWarning represents a warning message type // TypeWarning represents a warning message type.
TypeWarning Type = "warning" TypeWarning Type = "warning"
// TypeDanger represents a danger message type // TypeDanger represents a danger message type.
TypeDanger Type = "danger" TypeDanger Type = "danger"
) )
const ( 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" sessionName = "msg"
) )
// Success sets a success flash message // Success sets a success flash message.
func Success(ctx echo.Context, message string) { func Success(ctx echo.Context, message string) {
Set(ctx, TypeSuccess, message) Set(ctx, TypeSuccess, message)
} }
// Info sets an info flash message // Info sets an info flash message.
func Info(ctx echo.Context, message string) { func Info(ctx echo.Context, message string) {
Set(ctx, TypeInfo, message) Set(ctx, TypeInfo, message)
} }
// Warning sets a warning flash message // Warning sets a warning flash message.
func Warning(ctx echo.Context, message string) { func Warning(ctx echo.Context, message string) {
Set(ctx, TypeWarning, message) Set(ctx, TypeWarning, message)
} }
// Danger sets a danger flash message // Danger sets a danger flash message.
func Danger(ctx echo.Context, message string) { func Danger(ctx echo.Context, message string) {
Set(ctx, TypeDanger, message) Set(ctx, TypeDanger, message)
} }
@ -76,7 +76,7 @@ func Get(ctx echo.Context, typ Type) []string {
return msgs return msgs
} }
// getSession gets the flash message session // getSession gets the flash message session.
func getSession(ctx echo.Context) (*sessions.Session, error) { func getSession(ctx echo.Context) (*sessions.Session, error) {
sess, err := session.Get(ctx, sessionName) sess, err := session.Get(ctx, sessionName)
if err != nil { if err != nil {
@ -87,7 +87,7 @@ func getSession(ctx echo.Context) (*sessions.Session, error) {
return sess, err return sess, err
} }
// save saves the flash message session // save saves the flash message session.
func save(ctx echo.Context, sess *sessions.Session) { func save(ctx echo.Context, sess *sessions.Session) {
if err := sess.Save(ctx.Request(), ctx.Response()); err != nil { if err := sess.Save(ctx.Request(), ctx.Response()); err != nil {
log.Ctx(ctx).Error("failed to set flash message", log.Ctx(ctx).Error("failed to set flash message",

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package page package pager
import ( import (
"math" "math"
@ -7,30 +7,25 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
const ( // QueryKey stores the query key used to indicate the current page.
// DefaultItemsPerPage stores the default amount of items per page const QueryKey = "page"
DefaultItemsPerPage = 20
// PageQueryKey stores the query key used to indicate the current page // Pager provides a mechanism to allow a user to page results via a query parameter.
PageQueryKey = "page"
)
// Pager provides a mechanism to allow a user to page results via a query parameter
type Pager struct { 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 Items int
// Page stores the current page number // Page stores the current page number.
Page int Page int
// ItemsPerPage stores the amount of items to display per page // ItemsPerPage stores the amount of items to display per page.
ItemsPerPage int 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 Pages int
} }
// NewPager creates a new Pager // NewPager creates a new Pager.
func NewPager(ctx echo.Context, itemsPerPage int) Pager { func NewPager(ctx echo.Context, itemsPerPage int) Pager {
p := Pager{ p := Pager{
ItemsPerPage: itemsPerPage, ItemsPerPage: itemsPerPage,
@ -38,7 +33,7 @@ func NewPager(ctx echo.Context, itemsPerPage int) Pager {
Page: 1, Page: 1,
} }
if page := ctx.QueryParam(PageQueryKey); page != "" { if page := ctx.QueryParam(QueryKey); page != "" {
if pageInt, err := strconv.Atoi(page); err == nil { if pageInt, err := strconv.Atoi(page); err == nil {
if pageInt > 0 { if pageInt > 0 {
p.Page = pageInt 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 // 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 return p.Page == 1
} }
// IsEnd determines if the pager is at the end of the pages // 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 return p.Page >= p.Pages
} }
// GetOffset determines the offset of the results in order to get the items for // GetOffset determines the offset of the results in order to get the items for
// the current page // the current page
func (p Pager) GetOffset() int { func (p *Pager) GetOffset() int {
if p.Page == 0 { if p.Page == 0 {
p.Page = 1 p.Page = 1
} }

View file

@ -1,4 +1,4 @@
package page package pager
import ( import (
"fmt" "fmt"
@ -19,11 +19,11 @@ func TestNewPager(t *testing.T) {
assert.Equal(t, 0, pgr.Items) assert.Equal(t, 0, pgr.Items)
assert.Equal(t, 1, pgr.Pages) 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) pgr = NewPager(ctx, 10)
assert.Equal(t, 2, pgr.Page) 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) pgr = NewPager(ctx, 10)
assert.Equal(t, 1, pgr.Page) assert.Equal(t, 1, pgr.Page)
} }

View file

@ -24,7 +24,7 @@ type Redirect struct {
func New(ctx echo.Context) *Redirect { func New(ctx echo.Context) *Redirect {
return &Redirect{ return &Redirect{
ctx: ctx, ctx: ctx,
status: http.StatusFound, status: http.StatusTemporaryRedirect,
} }
} }

25
pkg/routenames/names.go Normal file
View 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"
)

View file

@ -16,51 +16,47 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent"
"github.com/mikestefanello/pagoda/pkg/funcmap"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
// Require by ent // Required by ent.
_ "github.com/mikestefanello/pagoda/ent/runtime" _ "github.com/mikestefanello/pagoda/ent/runtime"
) )
// Container contains all services used by the application and provides an easy way to handle dependency // 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 { type Container struct {
// Validator stores a validator // Validator stores a validator
Validator *Validator Validator *Validator
// Web stores the web framework // Web stores the web framework.
Web *echo.Echo Web *echo.Echo
// Config stores the application configuration // Config stores the application configuration.
Config *config.Config Config *config.Config
// Cache contains the cache client // Cache contains the cache client.
Cache *CacheClient Cache *CacheClient
// Database stores the connection to the database // Database stores the connection to the database.
Database *sql.DB Database *sql.DB
// Files stores the file system. // Files stores the file system.
Files afero.Fs Files afero.Fs
// ORM stores a client to the ORM // ORM stores a client to the ORM.
ORM *ent.Client ORM *ent.Client
// Mail stores an email sending client // Mail stores an email sending client.
Mail *MailClient Mail *MailClient
// Auth stores an authentication client // Auth stores an authentication client.
Auth *AuthClient Auth *AuthClient
// TemplateRenderer stores a service to easily render and cache templates // Tasks stores the task client.
TemplateRenderer *TemplateRenderer
// Tasks stores the task client
Tasks *backlite.Client Tasks *backlite.Client
} }
// NewContainer creates and initializes a new Container // NewContainer creates and initializes a new Container.
func NewContainer() *Container { func NewContainer() *Container {
c := new(Container) c := new(Container)
c.initConfig() c.initConfig()
@ -71,7 +67,6 @@ func NewContainer() *Container {
c.initFiles() c.initFiles()
c.initORM() c.initORM()
c.initAuth() c.initAuth()
c.initTemplateRenderer()
c.initMail() c.initMail()
c.initTasks() c.initTasks()
return c return c
@ -107,7 +102,7 @@ func (c *Container) Shutdown() error {
return nil return nil
} }
// initConfig initializes configuration // initConfig initializes configuration.
func (c *Container) initConfig() { func (c *Container) initConfig() {
cfg, err := config.GetConfig() cfg, err := config.GetConfig()
if err != nil { if err != nil {
@ -115,7 +110,7 @@ func (c *Container) initConfig() {
} }
c.Config = &cfg c.Config = &cfg
// Configure logging // Configure logging.
switch cfg.App.Environment { switch cfg.App.Environment {
case config.EnvProduction: case config.EnvProduction:
slog.SetLogLoggerLevel(slog.LevelInfo) slog.SetLogLoggerLevel(slog.LevelInfo)
@ -124,19 +119,19 @@ func (c *Container) initConfig() {
} }
} }
// initValidator initializes the validator // initValidator initializes the validator.
func (c *Container) initValidator() { func (c *Container) initValidator() {
c.Validator = NewValidator() c.Validator = NewValidator()
} }
// initWeb initializes the web framework // initWeb initializes the web framework.
func (c *Container) initWeb() { func (c *Container) initWeb() {
c.Web = echo.New() c.Web = echo.New()
c.Web.HideBanner = true c.Web.HideBanner = true
c.Web.Validator = c.Validator c.Web.Validator = c.Validator
} }
// initCache initializes the cache // initCache initializes the cache.
func (c *Container) initCache() { func (c *Container) initCache() {
store, err := newInMemoryCache(c.Config.Cache.Capacity) store, err := newInMemoryCache(c.Config.Cache.Capacity)
if err != nil { if err != nil {
@ -146,7 +141,7 @@ func (c *Container) initCache() {
c.Cache = NewCacheClient(store) c.Cache = NewCacheClient(store)
} }
// initDatabase initializes the database // initDatabase initializes the database.
func (c *Container) initDatabase() { func (c *Container) initDatabase() {
var err error var err error
var connection string var connection string
@ -180,7 +175,7 @@ func (c *Container) initFiles() {
c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory) c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory)
} }
// initORM initializes the ORM // initORM initializes the ORM.
func (c *Container) initORM() { func (c *Container) initORM() {
drv := entsql.OpenDB(c.Config.Database.Driver, c.Database) drv := entsql.OpenDB(c.Config.Database.Driver, c.Database)
c.ORM = ent.NewClient(ent.Driver(drv)) 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() { func (c *Container) initAuth() {
c.Auth = NewAuthClient(c.Config, c.ORM) c.Auth = NewAuthClient(c.Config, c.ORM)
} }
// initTemplateRenderer initializes the template renderer // initMail initialize the mail client.
func (c *Container) initTemplateRenderer() {
c.TemplateRenderer = NewTemplateRenderer(c.Config, c.Cache, funcmap.NewFuncMap(c.Web))
}
// initMail initialize the mail client
func (c *Container) initMail() { func (c *Container) initMail() {
var err error var err error
c.Mail, err = NewMailClient(c.Config, c.TemplateRenderer) c.Mail, err = NewMailClient(c.Config)
if err != nil { if err != nil {
panic(fmt.Sprintf("failed to create mail client: %v", err)) panic(fmt.Sprintf("failed to create mail client: %v", err))
} }
} }
// initTasks initializes the task client // initTasks initializes the task client.
func (c *Container) initTasks() { func (c *Container) initTasks() {
var err error var err error
// You could use a separate database for tasks, if you'd like. but using one // 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{ c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database, DB: c.Database,
Logger: log.Default(), 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) { func openDB(driver, connection string) (*sql.DB, error) {
// Helper to automatically create the directories that the specified sqlite file // Helper to automatically create the directories that the specified sqlite file
// should reside in, if one // should reside in, if one.
if driver == "sqlite3" { if driver == "sqlite3" {
d := strings.Split(connection, "/") d := strings.Split(connection, "/")

View file

@ -16,6 +16,5 @@ func TestNewContainer(t *testing.T) {
assert.NotNil(t, c.ORM) assert.NotNil(t, c.ORM)
assert.NotNil(t, c.Mail) assert.NotNil(t, c.Mail)
assert.NotNil(t, c.Auth) assert.NotNil(t, c.Auth)
assert.NotNil(t, c.TemplateRenderer)
assert.NotNil(t, c.Tasks) assert.NotNil(t, c.Tasks)
} }

View file

@ -1,11 +1,12 @@
package services package services
import ( import (
"bytes"
"errors" "errors"
"fmt"
"github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"maragu.dev/gomponents"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@ -14,36 +15,31 @@ type (
// MailClient provides a client for sending email // MailClient provides a client for sending email
// This is purposely not completed because there are many different methods and services // 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 // 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 { MailClient struct {
// config stores application configuration // config stores application configuration.
config *config.Config 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 { mail struct {
client *MailClient client *MailClient
from string from string
to string to string
subject string subject string
body string body string
template string component gomponents.Node
templateData any
} }
) )
// NewMailClient creates a new MailClient // NewMailClient creates a new MailClient.
func NewMailClient(cfg *config.Config, templates *TemplateRenderer) (*MailClient, error) { func NewMailClient(cfg *config.Config) (*MailClient, error) {
return &MailClient{ return &MailClient{
config: cfg, config: cfg,
templates: templates,
}, nil }, nil
} }
// Compose creates a new email // Compose creates a new email.
func (m *MailClient) Compose() *mail { func (m *MailClient) Compose() *mail {
return &mail{ return &mail{
client: m, 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 { func (m *MailClient) skipSend() bool {
return m.config.App.Environment != config.EnvProduction 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 { func (m *MailClient) send(email *mail, ctx echo.Context) error {
switch { switch {
case email.to == "": case email.to == "":
return errors.New("email cannot be sent without a to address") return errors.New("email cannot be sent without a to address")
case email.body == "" && email.template == "": case email.body == "" && email.component == nil:
return errors.New("email cannot be sent without a body or template") return errors.New("email cannot be sent without a body or component to render")
} }
// Check if a template was supplied // Check if a component was supplied.
if email.template != "" { if email.component != nil {
// Parse and execute template // Render the component and use as the body.
buf, err := m.templates. // TODO pool the buffers?
Parse(). buf := bytes.NewBuffer(nil)
Group("mail"). if err := email.component.Render(buf); err != nil {
Key(email.template).
Base(email.template).
Files(fmt.Sprintf("emails/%s", email.template)).
Execute(email.templateData)
if err != nil {
return err return err
} }
email.body = buf.String() email.body = buf.String()
} }
// Check if mail sending should be skipped // Check if mail sending should be skipped.
if m.skipSend() { if m.skipSend() {
log.Ctx(ctx).Debug("skipping email delivery", log.Ctx(ctx).Debug("skipping email delivery",
"to", email.to, "to", email.to,
@ -91,52 +81,47 @@ func (m *MailClient) send(email *mail, ctx echo.Context) error {
return nil 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 return nil
} }
// From sets the email from address // From sets the email from address.
func (m *mail) From(from string) *mail { func (m *mail) From(from string) *mail {
m.from = from m.from = from
return m 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 { func (m *mail) To(to string) *mail {
m.to = to m.to = to
return m 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 { func (m *mail) Subject(subject string) *mail {
m.subject = subject m.subject = subject
return m return m
} }
// Body sets the body of the email // Body sets the body of the email.
// This is not required and will be ignored if a template via Template() // This is not required and will be ignored if a component is set via Component().
func (m *mail) Body(body string) *mail { func (m *mail) Body(body string) *mail {
m.body = body m.body = body
return m return m
} }
// Template sets the template to be used to produce the body of the email // Component sets a renderable component to use as the body of the email.
// The template name should only include the filename without the extension or directory. func (m *mail) Component(component gomponents.Node) *mail {
// The template must reside within the emails sub-directory. m.component = component
// The funcmap will be automatically added to the template.
// Use TemplateData() to supply the data that will be passed in to the template.
func (m *mail) Template(template string) *mail {
m.template = template
return m return m
} }
// TemplateData sets the data that will be passed to the template specified when calling Template() // Send attempts to send the email.
func (m *mail) TemplateData(data any) *mail {
m.templateData = data
return m
}
// Send attempts to send the email
func (m *mail) Send(ctx echo.Context) error { func (m *mail) Send(ctx echo.Context) error {
return m.client.send(m, ctx) return m.client.send(m, ctx)
} }

View file

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

View file

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

View file

@ -2,14 +2,16 @@ package tasks
import ( import (
"context" "context"
"github.com/mikestefanello/backlite"
"time" "time"
"github.com/mikestefanello/backlite"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/log"
"github.com/mikestefanello/pagoda/pkg/services" "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 // 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. // that your queue processor needs to process the task.
type ExampleTask struct { 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. // The service container is provided so the subscriber can have access to the app dependencies.
// All queues must be registered in the Register() function. // 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. // 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, "message", task.Message,
) )
log.Default().Info("This can access the container for dependencies", log.Default().Info("This can access the container for dependencies",
"echo", c.Web.Reverse("home"), "echo", c.Web.Reverse(routenames.Home),
) )
return nil return nil
}) })

View file

@ -4,7 +4,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/services" "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) { func Register(c *services.Container) {
c.Tasks.Register(NewExampleTaskQueue(c)) c.Tasks.Register(NewExampleTaskQueue(c))
} }

64
pkg/ui/cache/cache.go vendored Normal file
View 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
View 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)
}

View 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
View 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
View 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, ", ")))),
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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),
)
}

View 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
View 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
View 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),
)
}

View 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
View 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
View 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
View 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
View 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
View 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),
),
),
),
)
}

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -1,42 +0,0 @@
{{define "metatags"}}
<title>{{ .AppName }}{{ if .Title }} | {{ .Title }}{{ end }}</title>
<link rel="icon" href="{{file "favicon.png"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
{{- if .Metatags.Description}}
<meta name="description" content="{{.Metatags.Description}}">
{{- end}}
{{- if .Metatags.Keywords}}
<meta name="keywords" content="{{.Metatags.Keywords | join ", "}}">
{{- end}}
{{end}}
{{define "css"}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
{{end}}
{{define "js"}}
<script src="https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
{{end}}
{{define "footer"}}
{{- if .CSRF}}
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
if (evt.detail.verb !== "get") {
evt.detail.parameters['csrf'] = '{{.CSRF}}';
}
})
</script>
{{end}}
<script>
document.body.addEventListener('htmx:beforeSwap', function(evt) {
if (evt.detail.xhr.status >= 400){
evt.detail.shouldSwap = true;
evt.detail.target = htmx.find("body");
}
});
</script>
{{end}}

View file

@ -1,9 +0,0 @@
{{define "csrf"}}
<input type="hidden" name="csrf" value="{{.CSRF}}"/>
{{end}}
{{define "field-errors"}}
{{- range .}}
<p class="help is-danger">{{.}}</p>
{{- end}}
{{end}}

View file

@ -1,21 +0,0 @@
{{define "messages"}}
{{- range (.GetMessages "success")}}
{{template "message" dict "Type" "success" "Text" .}}
{{- end}}
{{- range (.GetMessages "info")}}
{{template "message" dict "Type" "info" "Text" .}}
{{- end}}
{{- range (.GetMessages "warning")}}
{{template "message" dict "Type" "warning" "Text" .}}
{{- end}}
{{- range (.GetMessages "danger")}}
{{template "message" dict "Type" "danger" "Text" .}}
{{- end}}
{{end}}
{{define "message"}}
<div class="notification is-light is-{{.Type}}" x-data="{show: true}" x-show="show">
<button class="delete" @click="show = false"></button>
{{.Text}}
</div>
{{end}}

View file

@ -1 +0,0 @@
Test email template. See services/mail.go to provide your implementation.

View file

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
{{template "metatags" .}}
{{template "css" .}}
{{template "js" .}}
</head>
<body>
<section class="hero is-info is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-half">
{{- if .Title}}
<h1 class="title">{{.Title}}</h1>
{{- end}}
<div class="box">
{{template "messages" .}}
{{template "content" .}}
<div class="content is-small has-text-centered" hx-boost="true">
<a href="{{url "login"}}">Login</a> &#9676;
<a href="{{url "register"}}">Create an account</a> &#9676;
<a href="{{url "forgot_password"}}">Forgot password?</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{{template "footer" .}}
</body>
</html>

View file

@ -1 +0,0 @@
{{template "content" .}}

View file

@ -1,93 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
{{template "metatags" .}}
{{template "css" .}}
{{template "js" .}}
</head>
<body>
<nav class="navbar is-dark">
<div class="container">
<div class="navbar-brand" hx-boost="true">
<a href="{{url "home"}}" class="navbar-item">{{.AppName}}</a>
</div>
<div id="navbarMenu" class="navbar-menu">
<div class="navbar-end">
{{template "search" .}}
</div>
</div>
</div>
</nav>
<div class="container mt-5">
<div class="columns">
<div class="column is-2">
<aside class="menu" hx-boost="true">
<p class="menu-label">General</p>
<ul class="menu-list">
<li>{{link (url "home") "Dashboard" .Path}}</li>
<li>{{link (url "about") "About" .Path}}</li>
<li>{{link (url "contact") "Contact" .Path}}</li>
<li>{{link (url "cache") "Cache" .Path}}</li>
<li>{{link (url "task") "Task" .Path}}</li>
<li>{{link (url "files") "Files" .Path}}</li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
{{- if .IsAuth}}
<li>{{link (url "logout") "Logout" .Path}}</li>
{{- else}}
<li>{{link (url "login") "Login" .Path}}</li>
<li>{{link (url "register") "Register" .Path}}</li>
<li>{{link (url "forgot_password") "Forgot password" .Path}}</li>
{{- end}}
</ul>
</aside>
</div>
<div class="column is-10">
<div class="box">
{{- if .Title}}
<h1 class="title">{{.Title}}</h1>
{{- end}}
{{template "messages" .}}
{{template "content" .}}
</div>
</div>
</div>
</div>
{{template "footer" .}}
</body>
</html>
{{define "search"}}
<div class="search mr-2 mt-1" x-data="{modal:false}">
<input class="input" type="search" placeholder="Search..." @click="modal = true; $nextTick(() => $refs.input.focus());"/>
<div class="modal" :class="modal ? 'is-active' : ''" x-show="modal == true">
<div class="modal-background"></div>
<div class="modal-content" @click.outside="modal = false;">
<div class="box">
<h2 class="subtitle">Search</h2>
<p class="control">
<input
hx-get="{{url "search"}}"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
name="query"
class="input"
type="search"
placeholder="Search..."
x-ref="input"
/>
</p>
<div class="block"></div>
<div id="results"></div>
</div>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
</div>
{{end}}

View file

@ -1,41 +0,0 @@
{{define "content"}}
{{- if .Data.FrontendTabs}}
<p class="subtitle mt-5">Frontend</p>
<p class="mb-4">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.</p>
{{template "tabs" .Data.FrontendTabs}}
<div class="mb-4"></div>
{{- end}}
{{- if .Data.BackendTabs}}
<p class="subtitle mt-5">Backend</p>
<p class="mb-4">The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.</p>
{{template "tabs" .Data.BackendTabs}}
<div class="mb-4"></div>
{{end}}
{{- if .Data.ShowCacheWarning}}
<article class="message is-warning mt-6">
<div class="message-header">
<p>Warning</p>
</div>
<div class="message-body">
This route has caching enabled so hot-reloading in the local environment will not work.
</div>
</article>
{{- end}}
{{end}}
{{define "tabs"}}
<div x-data="{tab: 0}">
<div class="tabs">
<ul>
{{- range $index, $tab := .}}
<li :class="{'is-active': tab === {{$index}}}" @click="tab = {{$index}}"><a>{{.Title}}</a></li>
{{- end}}
</ul>
</div>
{{- range $index, $tab := .}}
<div x-show="tab == {{$index}}"><p> &rarr; {{.Body}}</p></div>
{{- end}}
</div>
{{end}}

View file

@ -1,36 +0,0 @@
{{define "content"}}
<form id="task" method="post" hx-post="{{url "cache.submit"}}">
<article class="message">
<div class="message-header">
<p>Test the cache</p>
</div>
<div class="message-body">
This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.
HTMX makes it easy to re-render the cached value after the form is submitted.
</div>
</article>
<label for="value" class="label">Value in cache: </label>
{{if .Data}}
<span class="tag is-success">{{.Data}}</span>
{{- else}}
<i>(empty)</i>
{{- end}}
<br/><br/>
<div class="field">
<label for="value" class="label">Value</label>
<div class="control">
<input id="value" name="value" class="input" value="{{.Form.Value}}"/>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Update cache</button>
</div>
</div>
{{template "csrf" .}}
</form>
{{end}}

View file

@ -1,70 +0,0 @@
{{define "content"}}
{{- if not (eq .HTMX.Request.Target "contact")}}
<article class="message is-link">
<div class="message-body">
<p>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>
<p>Only the form below will update async upon submission.</p>
</div>
</article>
{{- end}}
{{template "form" .}}
{{end}}
{{define "form"}}
{{- if .Form.IsDone}}
<article class="message is-large is-success">
<div class="message-header">
<p>Thank you!</p>
</div>
<div class="message-body">
No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled.
</div>
</article>
{{- else}}
<form id="contact" method="post" hx-post="{{url "contact.submit"}}">
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<input id="email" name="email" type="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
</div>
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
</div>
<div class="control field">
<label class="label">Department</label>
<div class="radios">
<label class="radio">
<input type="radio" name="department" value="sales" {{if eq .Form.Department "sales"}}checked{{end}}/>
Sales
</label>
<label class="radio">
<input type="radio" name="department" value="marketing" {{if eq .Form.Department "marketing"}}checked{{end}}/>
Marketing
</label>
<label class="radio">
<input type="radio" name="department" value="hr" {{if eq .Form.Department "hr"}}checked{{end}}/>
HR
</label>
</div>
{{template "field-errors" (.Form.GetFieldErrors "Department")}}
</div>
<div class="field">
<label for="message" class="label">Message</label>
<div class="control">
<textarea id="message" name="message" class="textarea {{.Form.GetFieldStatusClass "Message"}}">{{.Form.Message}}</textarea>
</div>
{{template "field-errors" (.Form.GetFieldErrors "Message")}}
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Submit</button>
</div>
</div>
{{template "csrf" .}}
</form>
{{- end}}
{{end}}

View file

@ -1,11 +0,0 @@
{{define "content"}}
{{if ge .StatusCode 500}}
<p>Please try again.</p>
{{else if or (eq .StatusCode 403) (eq .StatusCode 401)}}
<p>You are not authorized to view the requested page.</p>
{{else if eq .StatusCode 404}}
<p>Click {{link (url "home") "here" .Path}} to return home</p>
{{else}}
<p>Something went wrong</p>
{{end}}
{{end}}

View file

@ -1,51 +0,0 @@
{{define "content"}}
<article class="message is-link">
<div class="message-body">
<p>This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.</p>
</div>
</article>
<form id="files" method="post" action="{{url "files.submit"}}" enctype="multipart/form-data">
<div class="field file">
<label class="file-label">
<input class="file-input" type="file" name="file" />
<span class="file-cta">
<span class="file-label">Choose a file… </span>
</span>
</label>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link">Upload</button>
</div>
</div>
{{template "csrf" .}}
</form>
<hr/>
<h3 class="title">Uploaded files</h3>
<article class="message is-warning">
<div class="message-body">
<p>Below are all files in the configured upload directory.</p>
</div>
</article>
<table class="table">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Modified on</th>
</tr>
</thead>
<tbody>
{{- range .Data}}
<tr>
<td>{{.Name}}</td>
<td>{{.Size}}</td>
<td>{{.Modified}}</td>
</tr>
{{- end}}
</tbody>
</table>
{{end}}

View file

@ -1,23 +0,0 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{url "forgot_password.submit"}}">
<div class="content">
<p>Enter your email address and we'll email you a link that allows you to reset your password.</p>
</div>
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<input id="email" type="email" name="email" class="input {{.Form.Submission.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}}
</div>
</div>
<div class="field is-grouped">
<p class="control">
<button class="button is-primary">Reset password</button>
</p>
<p class="control">
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}
</form>
{{end}}

View file

@ -1,82 +0,0 @@
{{define "content"}}
{{- if not (eq .HTMX.Request.Target "posts")}}
{{template "top-content" .}}
{{- end}}
{{template "posts" .}}
{{- if not (eq .HTMX.Request.Target "posts")}}
{{template "file-msg" .}}
{{- end}}
{{end}}
{{define "top-content"}}
<section class="hero is-info welcome is-small">
<div class="hero-body">
<div class="container">
<h1 class="title">
Hello{{if .IsAuth}}, {{.AuthUser.Name}}{{end}}
</h1>
<h2 class="subtitle">{{if .IsAuth}}Welcome back!{{else}}Please login in to your account.{{end}}</h2>
</div>
</div>
</section>
<section class="section">
<h1 class="title">Recent posts</h1>
<h2 class="subtitle">
Below is an example of both paging and AJAX fetching using HTMX
</h2>
</section>
{{end}}
{{define "posts"}}
<div id="posts">
{{- range .Data}}
<article class="media">
<figure class="media-left">
<p class="image is-64x64">
<img src="{{file "gopher.png"}}" alt="Gopher"/>
</p>
</figure>
<div class="media-content">
<div class="content">
<p>
<strong>{{.Title}}</strong>
<br>
{{.Body}}
</p>
</div>
</div>
</article>
{{- end}}
<div class="field is-grouped is-grouped-centered">
{{- if not $.Pager.IsBeginning}}
<p class="control">
<button class="button is-primary" hx-swap="outerHTML" hx-get="/?page={{sub $.Pager.Page 1}}" hx-target="#posts">Previous page</button>
</p>
{{- end}}
{{- if not $.Pager.IsEnd}}
<p class="control">
<button class="button is-primary" hx-swap="outerHTML" hx-get="/?page={{add $.Pager.Page 1}}" hx-target="#posts">Next page</button>
</p>
{{- end}}
</div>
</div>
{{end}}
{{define "file-msg"}}
<div class="block"></div>
<article class="message is-small is-warning" x-data="{show: true}" x-show="show">
<div class="message-header">
<p>Serving files</p>
<button class="delete is-small" aria-label="delete" @click="show = false"></button>
</div>
<div class="message-body">
In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted.
Static files also contain cache-control headers which are configured via middleware.
You can also use AlpineJS to dismiss this message.
</div>
</article>
{{end}}

View file

@ -1,28 +0,0 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{url "login.submit"}}">
{{template "messages" .}}
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<input id="email" type="email" name="email" class="input {{.Form.Submission.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Email")}}
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input id="password" type="password" name="password" placeholder="*******" class="input {{.Form.Submission.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.Submission.GetFieldErrors "Password")}}
</div>
</div>
<div class="field is-grouped">
<p class="control">
<button class="button is-primary">Log in</button>
</p>
<p class="control">
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}
</form>
{{end}}

View file

@ -1,41 +0,0 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{url "register.submit"}}">
<div class="field">
<label for="name" class="label">Name</label>
<div class="control">
<input type="text" id="name" name="name" class="input {{.Form.GetFieldStatusClass "Name"}}" value="{{.Form.Name}}">
{{template "field-errors" (.Form.GetFieldErrors "Name")}}
</div>
</div>
<div class="field">
<label for="email" class="label">Email address</label>
<div class="control">
<input type="email" id="email" name="email" class="input {{.Form.GetFieldStatusClass "Email"}}" value="{{.Form.Email}}">
{{template "field-errors" (.Form.GetFieldErrors "Email")}}
</div>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.GetFieldErrors "Password")}}
</div>
</div>
<div class="field">
<label for="password-confirm" class="label">Confirm password</label>
<div class="control">
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}}
</div>
</div>
<div class="field is-grouped">
<p class="control">
<button class="button is-primary">Register</button>
</p>
<p class="control">
<a href="{{url "home"}}" class="button is-light">Cancel</a>
</p>
</div>
{{template "csrf" .}}
</form>
{{end}}

View file

@ -1,24 +0,0 @@
{{define "content"}}
<form method="post" hx-boost="true" action="{{.Path}}">
<div class="field">
<label for="password" class="label">Password</label>
<div class="control">
<input type="password" id="password" name="password" placeholder="*******" class="input {{.Form.GetFieldStatusClass "Password"}}">
{{template "field-errors" (.Form.GetFieldErrors "Password")}}
</div>
</div>
<div class="field">
<label for="password-confirm" class="label">Confirm password</label>
<div class="control">
<input type="password" id="password-confirm" name="password-confirm" placeholder="*******" class="input {{.Form.GetFieldStatusClass "ConfirmPassword"}}">
{{template "field-errors" (.Form.GetFieldErrors "ConfirmPassword")}}
</div>
</div>
<div class="field is-grouped">
<p class="control">
<button class="button is-primary">Update password</button>
</p>
</div>
{{template "csrf" .}}
</form>
{{end}}

Some files were not shown because too many files have changed in this diff Show more