Compare commits
13 Commits
de5a1505a9
...
main
Author | SHA1 | Date | |
---|---|---|---|
c68d82c385 | |||
9b057ae87e | |||
48dd3433a7 | |||
e3e37a6db8 | |||
1cf2971bdc | |||
ef719f74da | |||
792707cd70 | |||
0343581099 | |||
6d72f5ad9b | |||
1e3ee29802 | |||
b7db6a57c0 | |||
9356062b54 | |||
59740c499c |
82
README.md
82
README.md
@ -80,7 +80,7 @@
|
|||||||
* [Flush tags](#flush-tags)
|
* [Flush tags](#flush-tags)
|
||||||
* [Tasks](#tasks)
|
* [Tasks](#tasks)
|
||||||
* [Queues](#queues)
|
* [Queues](#queues)
|
||||||
* [Runner](#runner)
|
* [Dispatcher](#dispatcher)
|
||||||
* [Cron](#cron)
|
* [Cron](#cron)
|
||||||
* [Static files](#static-files)
|
* [Static files](#static-files)
|
||||||
* [Cache control headers](#cache-control-headers)
|
* [Cache control headers](#cache-control-headers)
|
||||||
@ -90,6 +90,7 @@
|
|||||||
* [Logging](#logging)
|
* [Logging](#logging)
|
||||||
* [Roadmap](#roadmap)
|
* [Roadmap](#roadmap)
|
||||||
* [Credits](#credits)
|
* [Credits](#credits)
|
||||||
|
* [Similar Projects](#similar-projects)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
@ -965,77 +966,27 @@ As shown in the previous examples, cache tags were provided because they can be
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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. [Goqite](https://github.com/maragudk/goqite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously.
|
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.
|
||||||
|
|
||||||
To make things even easier, a custom client (`TaskClient`) is provided as a _Service_ on the `Container` which exposes a simple interface with [goqite](https://github.com/maragudk/goqite) that supports type-safe tasks and queues.
|
To make things easy, the _Backlite_ client (`TaskClient`) is provided as a _Service_ on the `Container` which allows you to register queues and add tasks.
|
||||||
|
|
||||||
### Queues
|
### Queues
|
||||||
|
|
||||||
A full example of a queue implementation can be found in `pkg/tasks` with an interactive form to create a task and add to the queue at `/task` (see `pkg/handlers/task.go`).
|
A full example of a queue implementation can be found in `pkg/tasks` with an interactive form to create a task and add to the queue at `/task` (see `pkg/handlers/task.go`). Also refer to the [Backlite](https://github.com/mikestefanello/backlite) documentation for reference and examples.
|
||||||
|
|
||||||
A queue starts by declaring a `Task` _type_, which is the object that gets placed in to a queue and eventually passed to a queue subscriber (a callback function to process the task). A `Task` must implement the `Name()` method which returns a unique name for the task. For example:
|
See `pkg/tasks/register.go` for a simple way to register all of your queues and to easily pass the `Container` to them so the queue processor callbacks have access to all of your app's dependencies.
|
||||||
|
|
||||||
|
### Dispatcher
|
||||||
|
|
||||||
|
The _task dispatcher_ is what manages the worker pool used for executing tasks in the background. It monitors incoming and scheduled tasks and handles sending them to the pool for execution by the queue's processor callback. This must be started in order for this to happen. In `cmd/web/main.go`, the _task dispatcher_ is automatically started when the app starts via:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type MyTask struct {
|
c.Tasks.Start(ctx)
|
||||||
Text string
|
|
||||||
Num int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t MyTask) Name() string {
|
|
||||||
return "my_task"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, create the queue for `MyTask` tasks:
|
The app [configuration](#configuration) contains values to configure the client and dispatcher including how many goroutines to use, when to release stuck tasks back into the queue, and how often to cleanup retained tasks in the database.
|
||||||
|
|
||||||
```go
|
When the app is shutdown, the dispatcher is given 10 seconds to wait for any in-progress tasks to finish execution. This can be changed in `cmd/web/main.go`.
|
||||||
q := services.NewQueue[MyTask](func(ctx context.Context, task MyTask) error {
|
|
||||||
// This is where you process the task
|
|
||||||
fmt.Println("Processed %s task!", task.Text)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
And finally, register the queue with the `TaskClient`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
c.Tasks.Register(q)
|
|
||||||
```
|
|
||||||
|
|
||||||
See `pkg/tasks/register.go` for a simple way to register all of your queues and to easily pass the `Container` to them so the queue subscriber callbacks have access to all of your app's dependencies.
|
|
||||||
|
|
||||||
Now you can easily add a task to the queue using the `TaskClient`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
task := MyTask{Text: "Hello world!", Num: 10}
|
|
||||||
|
|
||||||
err := c.Tasks.
|
|
||||||
New(task).
|
|
||||||
Save()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Options
|
|
||||||
|
|
||||||
Tasks can be created and queued with various chained options:
|
|
||||||
|
|
||||||
```go
|
|
||||||
err := c.Tasks.
|
|
||||||
New(task).
|
|
||||||
Wait(30 * time.Second). // Wait 30 seconds before passing the task to the subscriber
|
|
||||||
At(time.Date(...)). // Wait until a given date before passing the task to the subscriber
|
|
||||||
Tx(tx). // Include the queueing of this task in a database transaction
|
|
||||||
Save()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runner
|
|
||||||
|
|
||||||
The _task runner_ is what manages periodically polling the database for available queued tasks to process and passing them to the queue's subscriber callback. This must be started in order for this to happen. In `cmd/web/main.go`, the _task runner_ is started by using the `TaskClient`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
go c.Tasks.StartRunner(ctx)
|
|
||||||
```
|
|
||||||
|
|
||||||
The app [configuration](#configuration) contains values to configure the runner including how often to poll the database for tasks, the maximum amount of retries for a given task, and the amount of tasks that can be processed concurrently.
|
|
||||||
|
|
||||||
## Cron
|
## Cron
|
||||||
|
|
||||||
@ -1171,12 +1122,12 @@ Future work includes but is not limited to:
|
|||||||
Thank you to all of the following amazing projects for making this possible.
|
Thank you to all of the following amazing projects for making this possible.
|
||||||
|
|
||||||
- [alpinejs](https://github.com/alpinejs/alpine)
|
- [alpinejs](https://github.com/alpinejs/alpine)
|
||||||
|
- [backlite](https://github.com/mikestefanello/backlite)
|
||||||
- [bulma](https://github.com/jgthms/bulma)
|
- [bulma](https://github.com/jgthms/bulma)
|
||||||
- [echo](https://github.com/labstack/echo)
|
- [echo](https://github.com/labstack/echo)
|
||||||
- [golang-migrate](https://github.com/golang-migrate/migrate)
|
- [golang-migrate](https://github.com/golang-migrate/migrate)
|
||||||
- [go](https://go.dev/)
|
- [go](https://go.dev/)
|
||||||
- [go-sqlite3](https://github.com/mattn/go-sqlite3)
|
- [go-sqlite3](https://github.com/mattn/go-sqlite3)
|
||||||
- [goqite](https://github.com/maragudk/goqite)
|
|
||||||
- [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)
|
||||||
@ -1187,3 +1138,8 @@ Thank you to all of the following amazing projects for making this possible.
|
|||||||
- [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)
|
||||||
- [viper](https://github.com/spf13/viper)
|
- [viper](https://github.com/spf13/viper)
|
||||||
|
|
||||||
|
## Similar Projects
|
||||||
|
|
||||||
|
- [go-templ-htmx-templ](https://github.com/HoneySinghDev/go-templ-htmx-template)
|
||||||
|
- [go-webly](https://github.com/gowebly/gowebly)
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/handlers"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/handlers"
|
||||||
@ -60,18 +61,31 @@ func main() {
|
|||||||
tasks.Register(c)
|
tasks.Register(c)
|
||||||
|
|
||||||
// Start the task runner to execute queued tasks
|
// Start the task runner to execute queued tasks
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
c.Tasks.Start(context.Background())
|
||||||
go c.Tasks.StartRunner(ctx)
|
|
||||||
|
|
||||||
// Wait for interrupt signal to gracefully shut down the server with a timeout of 10 seconds.
|
// Wait for interrupt signal to gracefully shut down the server with a timeout of 10 seconds.
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, os.Interrupt)
|
signal.Notify(quit, os.Interrupt)
|
||||||
signal.Notify(quit, os.Kill)
|
signal.Notify(quit, os.Kill)
|
||||||
<-quit
|
<-quit
|
||||||
cancel()
|
// Shutdown both the task runner and web server
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
c.Tasks.Stop(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
if err := c.Web.Shutdown(ctx); err != nil {
|
if err := c.Web.Shutdown(ctx); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
@ -31,9 +31,9 @@ storage:
|
|||||||
migrationsDir: db/migrations
|
migrationsDir: db/migrations
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
pollInterval: "1s"
|
|
||||||
maxRetries: 10
|
|
||||||
goroutines: 1
|
goroutines: 1
|
||||||
|
releaseAfter: "15m"
|
||||||
|
cleanupInterval: "1h"
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
hostname: "localhost"
|
hostname: "localhost"
|
||||||
|
@ -111,9 +111,9 @@ type (
|
|||||||
|
|
||||||
// TasksConfig stores the tasks configuration
|
// TasksConfig stores the tasks configuration
|
||||||
TasksConfig struct {
|
TasksConfig struct {
|
||||||
PollInterval time.Duration
|
|
||||||
MaxRetries int
|
|
||||||
Goroutines int
|
Goroutines int
|
||||||
|
ReleaseAfter time.Duration
|
||||||
|
CleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailConfig stores the mail configuration
|
// MailConfig stores the mail configuration
|
||||||
@ -152,6 +152,7 @@ func GetConfig() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
usedConfigFilePath := viper.GetViper().ConfigFileUsed()
|
usedConfigFilePath := viper.GetViper().ConfigFileUsed()
|
||||||
|
|
||||||
configFileDir := filepath.Dir(usedConfigFilePath)
|
configFileDir := filepath.Dir(usedConfigFilePath)
|
||||||
if !filepath.IsAbs(c.Storage.DatabaseFile) {
|
if !filepath.IsAbs(c.Storage.DatabaseFile) {
|
||||||
c.Storage.DatabaseFile = filepath.Join(configFileDir, c.Storage.DatabaseFile)
|
c.Storage.DatabaseFile = filepath.Join(configFileDir, c.Storage.DatabaseFile)
|
||||||
|
5
go.mod
5
go.mod
@ -1,8 +1,8 @@
|
|||||||
module git.grosinger.net/tgrosinger/saasitone
|
module git.grosinger.net/tgrosinger/saasitone
|
||||||
|
|
||||||
go 1.22
|
go 1.22.4
|
||||||
|
|
||||||
toolchain go1.22.1
|
toolchain go1.22.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
entgo.io/ent v0.13.1
|
entgo.io/ent v0.13.1
|
||||||
@ -54,6 +54,7 @@ require (
|
|||||||
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/mattn/go-runewidth v0.0.9 // indirect
|
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||||
|
github.com/mikestefanello/backlite v0.1.0 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // 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
|
||||||
|
2
go.sum
2
go.sum
@ -116,6 +116,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
|
|||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/maypok86/otter v1.2.1 h1:xyvMW+t0vE1sKt/++GTkznLitEl7D/msqXkAbLwiC1M=
|
github.com/maypok86/otter v1.2.1 h1:xyvMW+t0vE1sKt/++GTkznLitEl7D/msqXkAbLwiC1M=
|
||||||
github.com/maypok86/otter v1.2.1/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
|
github.com/maypok86/otter v1.2.1/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
|
||||||
|
github.com/mikestefanello/backlite v0.1.0 h1:bIiZJXPZB8V5PXWvDmkTepY015w3gJdeRrP3QrEV4Ls=
|
||||||
|
github.com/mikestefanello/backlite v0.1.0/go.mod h1:/vj8LPZWG/xqK/3uHaqOtu5JRLDEWqeyJKWTAlADTV0=
|
||||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
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/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=
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ import (
|
|||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -79,17 +77,12 @@ func (h *Auth) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
|
func (h *Auth) ForgotPasswordPage(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageForgotPassword
|
|
||||||
p.Title = "Forgot password"
|
p.Title = "Forgot password"
|
||||||
|
p.LayoutComponent = layouts.Auth
|
||||||
|
|
||||||
f := form.Get[pages.ForgotPasswordForm](ctx)
|
f := form.Get[pages.ForgotPasswordForm](ctx)
|
||||||
component := pages.ForgotPassword(p, f)
|
component := pages.ForgotPassword(p, f)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Auth(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,17 +140,12 @@ 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)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageLogin
|
|
||||||
p.Title = "Log in"
|
p.Title = "Log in"
|
||||||
|
p.LayoutComponent = layouts.Auth
|
||||||
|
|
||||||
f := form.Get[pages.LoginForm](ctx)
|
f := form.Get[pages.LoginForm](ctx)
|
||||||
component := pages.Login(p, f)
|
component := pages.Login(p, f)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Auth(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,17 +209,12 @@ func (h *Auth) Logout(ctx echo.Context) error {
|
|||||||
|
|
||||||
func (h *Auth) RegisterPage(ctx echo.Context) error {
|
func (h *Auth) RegisterPage(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageRegister
|
|
||||||
p.Title = "Register"
|
p.Title = "Register"
|
||||||
|
p.LayoutComponent = layouts.Auth
|
||||||
|
|
||||||
f := form.Get[pages.RegisterForm](ctx)
|
f := form.Get[pages.RegisterForm](ctx)
|
||||||
component := pages.Register(p, f)
|
component := pages.Register(p, f)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Auth(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,17 +314,12 @@ func (h *Auth) sendVerificationEmail(ctx echo.Context, usr sqlc.User) {
|
|||||||
|
|
||||||
func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
|
func (h *Auth) ResetPasswordPage(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageResetPassword
|
|
||||||
p.Title = "Reset password"
|
p.Title = "Reset password"
|
||||||
|
p.LayoutComponent = layouts.Auth
|
||||||
|
|
||||||
f := form.Get[pages.ResetPasswordForm](ctx)
|
f := form.Get[pages.ResetPasswordForm](ctx)
|
||||||
component := pages.ResetPassword(p, f)
|
component := pages.ResetPassword(p, f)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Auth(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,7 +9,8 @@ import (
|
|||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -22,11 +23,6 @@ type (
|
|||||||
cache *services.CacheClient
|
cache *services.CacheClient
|
||||||
*services.TemplateRenderer
|
*services.TemplateRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheForm struct {
|
|
||||||
Value string `form:"value"`
|
|
||||||
form.Submission
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -46,10 +42,8 @@ func (h *Cache) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Cache) Page(ctx echo.Context) error {
|
func (h *Cache) Page(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Layout = templates.LayoutMain
|
|
||||||
p.Name = templates.PageCache
|
|
||||||
p.Title = "Set a cache entry"
|
p.Title = "Set a cache entry"
|
||||||
p.Form = form.Get[cacheForm](ctx)
|
p.LayoutComponent = layouts.Main
|
||||||
|
|
||||||
// Fetch the value from the cache
|
// Fetch the value from the cache
|
||||||
value, err := h.cache.
|
value, err := h.cache.
|
||||||
@ -57,20 +51,26 @@ func (h *Cache) Page(ctx echo.Context) error {
|
|||||||
Key("page_cache_example").
|
Key("page_cache_example").
|
||||||
Fetch(ctx.Request().Context())
|
Fetch(ctx.Request().Context())
|
||||||
|
|
||||||
|
var valueStrPtr *string = nil
|
||||||
|
|
||||||
// Store the value in the page, so it can be rendered, if found
|
// Store the value in the page, so it can be rendered, if found
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
p.Data = value.(string)
|
valueStr := value.(string)
|
||||||
|
valueStrPtr = &valueStr
|
||||||
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)
|
f := form.Get[pages.CacheForm](ctx)
|
||||||
|
component := pages.Cache(p, f, valueStrPtr)
|
||||||
|
|
||||||
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Cache) Submit(ctx echo.Context) error {
|
func (h *Cache) Submit(ctx echo.Context) error {
|
||||||
var input cacheForm
|
var input pages.CacheForm
|
||||||
|
|
||||||
if err := form.Submit(ctx, &input); err != nil {
|
if err := form.Submit(ctx, &input); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
@ -12,7 +11,6 @@ import (
|
|||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -44,17 +42,12 @@ func (h *Contact) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Contact) Page(ctx echo.Context) error {
|
func (h *Contact) Page(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageContact
|
|
||||||
p.Title = "Contact us"
|
p.Title = "Contact us"
|
||||||
|
p.LayoutComponent = layouts.Main
|
||||||
|
|
||||||
f := form.Get[pages.ContactForm](ctx)
|
f := form.Get[pages.ContactForm](ctx)
|
||||||
component := pages.Contact(p, f)
|
component := pages.Contact(p, f)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Main(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/context"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/context"
|
||||||
@ -12,7 +11,6 @@ import (
|
|||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
@ -41,17 +39,13 @@ func (e *Error) Page(err error, ctx echo.Context) {
|
|||||||
|
|
||||||
// Render the error page
|
// Render the error page
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageError
|
|
||||||
p.Title = http.StatusText(code)
|
p.Title = http.StatusText(code)
|
||||||
p.StatusCode = code
|
p.StatusCode = code
|
||||||
|
p.LayoutComponent = layouts.Main
|
||||||
p.HTMX.Request.Enabled = false
|
p.HTMX.Request.Enabled = false
|
||||||
|
|
||||||
component := pages.Error(p)
|
component := pages.Error(p)
|
||||||
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Main(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = e.RenderPageTempl(ctx, p, component); err != nil {
|
if err = e.RenderPageTempl(ctx, p, component); err != nil {
|
||||||
log.Ctx(ctx).Error("failed to render error page",
|
log.Ctx(ctx).Error("failed to render error page",
|
||||||
"error", err,
|
"error", err,
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/a-h/templ"
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -40,26 +38,21 @@ func (h *Pages) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Pages) Home(ctx echo.Context) error {
|
func (h *Pages) Home(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageHome
|
|
||||||
p.Metatags.Description = "Welcome to the homepage."
|
p.Metatags.Description = "Welcome to the homepage."
|
||||||
p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
|
p.Metatags.Keywords = []string{"Go", "MVC", "Web", "Software"}
|
||||||
p.Pager = page.NewPager(ctx, 4)
|
p.Pager = page.NewPager(ctx, 4)
|
||||||
|
p.LayoutComponent = layouts.Main
|
||||||
|
|
||||||
data := h.Post.FetchAll(&p.Pager)
|
data := h.Post.FetchAll(&p.Pager)
|
||||||
component := pages.Home(p, data)
|
component := pages.Home(p, data)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Main(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Pages) About(ctx echo.Context) error {
|
func (h *Pages) About(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = templates.PageAbout
|
|
||||||
p.Title = "About"
|
p.Title = "About"
|
||||||
|
p.LayoutComponent = layouts.Main
|
||||||
|
|
||||||
// This page will be cached!
|
// This page will be cached!
|
||||||
p.Cache.Enabled = true
|
p.Cache.Enabled = true
|
||||||
@ -96,11 +89,5 @@ func (h *Pages) About(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
component := pages.About(p, data)
|
component := pages.About(p, data)
|
||||||
|
|
||||||
// TODO: This can be reused
|
|
||||||
p.LayoutComponent = func(content templ.Component) templ.Component {
|
|
||||||
return layouts.Main(p, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.RenderPageTempl(ctx, p, component)
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,8 @@ import (
|
|||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
const routeNameSearch = "search"
|
const routeNameSearch = "search"
|
||||||
@ -17,11 +18,6 @@ type (
|
|||||||
Search struct {
|
Search struct {
|
||||||
*services.TemplateRenderer
|
*services.TemplateRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
searchResult struct {
|
|
||||||
Title string
|
|
||||||
URL string
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -39,23 +35,22 @@ func (h *Search) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Search) Page(ctx echo.Context) error {
|
func (h *Search) Page(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Layout = templates.LayoutMain
|
p.LayoutComponent = layouts.Main
|
||||||
p.Name = templates.PageSearch
|
|
||||||
|
|
||||||
// Fake search results
|
// Fake search results
|
||||||
var results []searchResult
|
var results []pages.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, pages.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)
|
component := pages.Search(p, results)
|
||||||
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,15 @@ import (
|
|||||||
|
|
||||||
"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/backlite"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/msg"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/msg"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/tasks"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/tasks"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -22,15 +24,9 @@ const (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
Task struct {
|
Task struct {
|
||||||
tasks *services.TaskClient
|
tasks *backlite.Client
|
||||||
*services.TemplateRenderer
|
*services.TemplateRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
taskForm struct {
|
|
||||||
Delay int `form:"delay" validate:"gte=0"`
|
|
||||||
Message string `form:"message" validate:"required"`
|
|
||||||
form.Submission
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -50,16 +46,16 @@ func (h *Task) Routes(g *echo.Group) {
|
|||||||
|
|
||||||
func (h *Task) Page(ctx echo.Context) error {
|
func (h *Task) Page(ctx echo.Context) error {
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Layout = templates.LayoutMain
|
|
||||||
p.Name = templates.PageTask
|
|
||||||
p.Title = "Create a task"
|
p.Title = "Create a task"
|
||||||
p.Form = form.Get[taskForm](ctx)
|
p.LayoutComponent = layouts.Main
|
||||||
|
|
||||||
return h.RenderPage(ctx, p)
|
f := form.Get[pages.TaskForm](ctx)
|
||||||
|
component := pages.Task(p, f)
|
||||||
|
return h.RenderPageTempl(ctx, p, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Task) Submit(ctx echo.Context) error {
|
func (h *Task) Submit(ctx echo.Context) error {
|
||||||
var input taskForm
|
var input pages.TaskForm
|
||||||
|
|
||||||
err := form.Submit(ctx, &input)
|
err := form.Submit(ctx, &input)
|
||||||
|
|
||||||
@ -72,7 +68,8 @@ func (h *Task) Submit(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert the task
|
// Insert the task
|
||||||
err = h.tasks.New(tasks.ExampleTask{
|
err = h.tasks.
|
||||||
|
Add(tasks.ExampleTask{
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
}).
|
}).
|
||||||
Wait(time.Duration(input.Delay) * time.Second).
|
Wait(time.Duration(input.Delay) * time.Second).
|
||||||
|
@ -10,21 +10,21 @@ import (
|
|||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/tests"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/tests"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServeCachedPage(t *testing.T) {
|
func TestServeCachedPage(t *testing.T) {
|
||||||
// Cache a page
|
// Cache a page
|
||||||
ctx, rec := tests.NewContext(c.Web, "/cache")
|
ctx, rec := tests.NewContext(c.Web, "/cache")
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Layout = templates.LayoutHTMX
|
|
||||||
p.Name = templates.PageHome
|
|
||||||
p.Cache.Enabled = true
|
p.Cache.Enabled = true
|
||||||
p.Cache.Expiration = time.Minute
|
p.Cache.Expiration = time.Minute
|
||||||
p.StatusCode = http.StatusCreated
|
p.StatusCode = http.StatusCreated
|
||||||
p.Headers["a"] = "b"
|
p.Headers["a"] = "b"
|
||||||
p.Headers["c"] = "d"
|
p.Headers["c"] = "d"
|
||||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
p.LayoutComponent = layouts.HTMX
|
||||||
|
err := c.TemplateRenderer.RenderPageTempl(ctx, p, pages.Cache(p, &pages.CacheForm{}, nil))
|
||||||
output := rec.Body.Bytes()
|
output := rec.Body.Bytes()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package services
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@ -13,7 +13,7 @@ type Post struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DBPostClient struct {
|
type DBPostClient struct {
|
||||||
db *sql.DB
|
DB *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchAll is an mock example of fetching posts to illustrate how paging works
|
// FetchAll is an mock example of fetching posts to illustrate how paging works
|
@ -1,9 +1,9 @@
|
|||||||
package services
|
package models
|
||||||
|
|
||||||
import "database/sql"
|
import "database/sql"
|
||||||
|
|
||||||
// UserClient is a struct that can be used to create custom methods for
|
// UserClient is a struct that can be used to create custom methods for
|
||||||
// interacting with users in the database.
|
// interacting with users in the database.
|
||||||
type DBUserClient struct {
|
type DBUserClient struct {
|
||||||
db *sql.DB
|
DB *sql.DB
|
||||||
}
|
}
|
@ -12,7 +12,6 @@ import (
|
|||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/htmx"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/htmx"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/models/sqlc"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models/sqlc"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/msg"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/msg"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Page consists of all data that will be used to render a page response for a given route.
|
// Page consists of all data that will be used to render a page response for a given route.
|
||||||
@ -42,28 +41,7 @@ type Page struct {
|
|||||||
// ToURL is a function to convert a route name and optional route parameters to a URL
|
// ToURL is a function to convert a route name and optional route parameters to a URL
|
||||||
ToURL func(name string, params ...interface{}) string
|
ToURL func(name string, params ...interface{}) string
|
||||||
|
|
||||||
// Data stores whatever additional data that needs to be passed to the templates.
|
LayoutComponent func(p Page, content templ.Component) templ.Component
|
||||||
// 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
|
|
||||||
|
|
||||||
LayoutComponent func(content templ.Component) templ.Component
|
|
||||||
|
|
||||||
// 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 stores whether the requested page is the home page or not
|
||||||
IsHome bool
|
IsHome bool
|
||||||
|
@ -6,9 +6,11 @@ import (
|
|||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/mikestefanello/backlite"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/config"
|
"git.grosinger.net/tgrosinger/saasitone/config"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@ -39,7 +41,7 @@ type Container struct {
|
|||||||
TemplateRenderer *TemplateRenderer
|
TemplateRenderer *TemplateRenderer
|
||||||
|
|
||||||
// Tasks stores the task client
|
// Tasks stores the task client
|
||||||
Tasks *TaskClient
|
Tasks *backlite.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContainer creates and initializes a new Container
|
// NewContainer creates and initializes a new Container
|
||||||
@ -139,10 +141,21 @@ func (c *Container) initMail() {
|
|||||||
// 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 = NewTaskClient(c.Config.Tasks, c.DB.DB())
|
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
|
||||||
|
DB: c.DB.DB(),
|
||||||
|
Logger: log.Default(),
|
||||||
|
NumWorkers: c.Config.Tasks.Goroutines,
|
||||||
|
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
|
||||||
|
CleanupInterval: c.Config.Tasks.CleanupInterval,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("failed to create task client: %v", err))
|
panic(fmt.Sprintf("failed to create task client: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = c.Tasks.Install(); err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to install task schema: %v", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4/source/file"
|
"github.com/golang-migrate/migrate/v4/source/file"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/config"
|
"git.grosinger.net/tgrosinger/saasitone/config"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/models/sqlc"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models/sqlc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,8 +18,8 @@ type DBClient struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
C *sqlc.Queries
|
C *sqlc.Queries
|
||||||
|
|
||||||
User *DBUserClient
|
User *models.DBUserClient
|
||||||
Post *DBPostClient
|
Post *models.DBPostClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDBClient(cfg *config.Config) (*DBClient, error) {
|
func NewDBClient(cfg *config.Config) (*DBClient, error) {
|
||||||
@ -43,8 +44,8 @@ func NewDBClient(cfg *config.Config) (*DBClient, error) {
|
|||||||
db: db,
|
db: db,
|
||||||
C: sqlc.New(db),
|
C: sqlc.New(db),
|
||||||
}
|
}
|
||||||
client.User = &DBUserClient{db: db}
|
client.User = &models.DBUserClient{DB: db}
|
||||||
client.Post = &DBPostClient{db: db}
|
client.Post = &models.DBPostClient{DB: db}
|
||||||
|
|
||||||
migrationsDirPath := cfg.Storage.MigrationsDir
|
migrationsDirPath := cfg.Storage.MigrationsDir
|
||||||
logger.Info("Loading schema migrations",
|
logger.Info("Loading schema migrations",
|
||||||
|
@ -1,205 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/gob"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/maragudk/goqite"
|
|
||||||
"github.com/maragudk/goqite/jobs"
|
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/config"
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
// TaskClient is that client that allows you to queue or schedule task execution.
|
|
||||||
// Under the hood we create only a single queue using goqite for all tasks because we do not want more than one
|
|
||||||
// runner to process the tasks. The TaskClient wrapper provides abstractions for separate, type-safe queues.
|
|
||||||
TaskClient struct {
|
|
||||||
queue *goqite.Queue
|
|
||||||
runner *jobs.Runner
|
|
||||||
buffers sync.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task is a job that can be added to a queue and later passed to and executed by a QueueSubscriber.
|
|
||||||
// See pkg/tasks for an example of how this can be used with a queue.
|
|
||||||
Task interface {
|
|
||||||
Name() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskSaveOp handles task save operations
|
|
||||||
TaskSaveOp struct {
|
|
||||||
client *TaskClient
|
|
||||||
task Task
|
|
||||||
tx *sql.Tx
|
|
||||||
at *time.Time
|
|
||||||
wait *time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue is a queue that a Task can be pushed to for execution.
|
|
||||||
// While this can be implemented directly, it's recommended to use NewQueue() which uses generics in
|
|
||||||
// order to provide type-safe queues and queue subscriber callbacks for task execution.
|
|
||||||
Queue interface {
|
|
||||||
// Name returns the name of the task this queue processes
|
|
||||||
Name() string
|
|
||||||
|
|
||||||
// Receive receives the Task payload to be processed
|
|
||||||
Receive(ctx context.Context, payload []byte) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// queue provides a type-safe implementation of Queue
|
|
||||||
queue[T Task] struct {
|
|
||||||
name string
|
|
||||||
subscriber QueueSubscriber[T]
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueueSubscriber is a generic subscriber callback for a given queue to process Tasks
|
|
||||||
QueueSubscriber[T Task] func(context.Context, T) error
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewTaskClient creates a new task client
|
|
||||||
func NewTaskClient(cfg config.TasksConfig, db *sql.DB) (*TaskClient, error) {
|
|
||||||
// Install the schema
|
|
||||||
if err := goqite.Setup(context.Background(), db); err != nil {
|
|
||||||
// An error is returned if we already ran this and there's no better way to check.
|
|
||||||
// You can and probably should handle this via migrations
|
|
||||||
if !strings.Contains(err.Error(), "already exists") {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t := &TaskClient{
|
|
||||||
queue: goqite.New(goqite.NewOpts{
|
|
||||||
DB: db,
|
|
||||||
Name: "tasks",
|
|
||||||
MaxReceive: cfg.MaxRetries,
|
|
||||||
}),
|
|
||||||
buffers: sync.Pool{
|
|
||||||
New: func() interface{} {
|
|
||||||
return bytes.NewBuffer(nil)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.runner = jobs.NewRunner(jobs.NewRunnerOpts{
|
|
||||||
Limit: cfg.Goroutines,
|
|
||||||
Log: log.Default(),
|
|
||||||
PollInterval: cfg.PollInterval,
|
|
||||||
Queue: t.queue,
|
|
||||||
})
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartRunner starts the scheduler service which adds scheduled tasks to the queue.
|
|
||||||
// This must be running in order to execute queued tasked.
|
|
||||||
// To stop the runner, cancel the context.
|
|
||||||
// This is a blocking call.
|
|
||||||
func (t *TaskClient) StartRunner(ctx context.Context) {
|
|
||||||
t.runner.Start(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register registers a queue so tasks can be added to it and processed
|
|
||||||
func (t *TaskClient) Register(queue Queue) {
|
|
||||||
t.runner.Register(queue.Name(), queue.Receive)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New starts a task save operation
|
|
||||||
func (t *TaskClient) New(task Task) *TaskSaveOp {
|
|
||||||
return &TaskSaveOp{
|
|
||||||
client: t,
|
|
||||||
task: task,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// At sets the exact date and time the task should be executed
|
|
||||||
func (t *TaskSaveOp) At(processAt time.Time) *TaskSaveOp {
|
|
||||||
t.Wait(time.Until(processAt))
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait instructs the task to wait a given duration before it is executed
|
|
||||||
func (t *TaskSaveOp) Wait(duration time.Duration) *TaskSaveOp {
|
|
||||||
t.wait = &duration
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tx will include the task as part of a given database transaction
|
|
||||||
func (t *TaskSaveOp) Tx(tx *sql.Tx) *TaskSaveOp {
|
|
||||||
t.tx = tx
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save saves the task, so it can be queued for execution
|
|
||||||
func (t *TaskSaveOp) Save() error {
|
|
||||||
type message struct {
|
|
||||||
Name string
|
|
||||||
Message []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode the task
|
|
||||||
taskBuf := t.client.buffers.Get().(*bytes.Buffer)
|
|
||||||
if err := gob.NewEncoder(taskBuf).Encode(t.task); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap and encode the message
|
|
||||||
// This is needed as a workaround because goqite doesn't support delays using the jobs package,
|
|
||||||
// so we format the message the way it expects but use the queue to supply the delay
|
|
||||||
msgBuf := t.client.buffers.Get().(*bytes.Buffer)
|
|
||||||
wrapper := message{Name: t.task.Name(), Message: taskBuf.Bytes()}
|
|
||||||
if err := gob.NewEncoder(msgBuf).Encode(wrapper); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := goqite.Message{
|
|
||||||
Body: msgBuf.Bytes(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.wait != nil {
|
|
||||||
msg.Delay = *t.wait
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put the buffers back in the pool for re-use
|
|
||||||
taskBuf.Reset()
|
|
||||||
msgBuf.Reset()
|
|
||||||
t.client.buffers.Put(taskBuf)
|
|
||||||
t.client.buffers.Put(msgBuf)
|
|
||||||
|
|
||||||
if t.tx == nil {
|
|
||||||
return t.client.queue.Send(context.Background(), msg)
|
|
||||||
} else {
|
|
||||||
return t.client.queue.SendTx(context.Background(), t.tx, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQueue queues a new type-safe Queue of a given Task type
|
|
||||||
func NewQueue[T Task](subscriber QueueSubscriber[T]) Queue {
|
|
||||||
var task T
|
|
||||||
|
|
||||||
q := &queue[T]{
|
|
||||||
name: task.Name(),
|
|
||||||
subscriber: subscriber,
|
|
||||||
}
|
|
||||||
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *queue[T]) Name() string {
|
|
||||||
return q.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *queue[T]) Receive(ctx context.Context, payload []byte) error {
|
|
||||||
var obj T
|
|
||||||
err := gob.NewDecoder(bytes.NewReader(payload)).Decode(&obj)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return q.subscriber(ctx, obj)
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testTask struct {
|
|
||||||
Val int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t testTask) Name() string {
|
|
||||||
return "test_task"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskClient_New(t *testing.T) {
|
|
||||||
var subCalled bool
|
|
||||||
|
|
||||||
queue := NewQueue[testTask](func(ctx context.Context, task testTask) error {
|
|
||||||
subCalled = true
|
|
||||||
assert.Equal(t, 123, task.Val)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
c.Tasks.Register(queue)
|
|
||||||
|
|
||||||
task := testTask{Val: 123}
|
|
||||||
|
|
||||||
tx := &sql.Tx{}
|
|
||||||
|
|
||||||
op := c.Tasks.
|
|
||||||
New(task).
|
|
||||||
Wait(5 * time.Second).
|
|
||||||
Tx(tx)
|
|
||||||
|
|
||||||
// Check that the task op was built correctly
|
|
||||||
assert.Equal(t, task, op.task)
|
|
||||||
assert.Equal(t, tx, op.tx)
|
|
||||||
assert.Equal(t, 5*time.Second, *op.wait)
|
|
||||||
|
|
||||||
// Remove the transaction and delay so we can process the task immediately
|
|
||||||
op.tx, op.wait = nil, nil
|
|
||||||
err := op.Save()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Start the runner
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
go c.Tasks.StartRunner(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Check for up to 5 seconds if the task executed
|
|
||||||
start := time.Now()
|
|
||||||
waitLoop:
|
|
||||||
for {
|
|
||||||
switch {
|
|
||||||
case subCalled:
|
|
||||||
break waitLoop
|
|
||||||
case time.Since(start) > (5 * time.Second):
|
|
||||||
break waitLoop
|
|
||||||
default:
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, subCalled)
|
|
||||||
}
|
|
@ -98,11 +98,6 @@ func (t *TemplateRenderer) Parse() *templateBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, content templ.Component) error {
|
func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, content templ.Component) error {
|
||||||
// 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
|
// Use the app name in configuration if a value was not set
|
||||||
if page.AppName == "" {
|
if page.AppName == "" {
|
||||||
page.AppName = t.config.App.Name
|
page.AppName = t.config.App.Name
|
||||||
@ -115,7 +110,7 @@ func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, con
|
|||||||
// Only partial content should be rendered.
|
// Only partial content should be rendered.
|
||||||
err = content.Render(ctx.Request().Context(), &buf)
|
err = content.Render(ctx.Request().Context(), &buf)
|
||||||
} else {
|
} else {
|
||||||
err = page.LayoutComponent(content).Render(ctx.Request().Context(), &buf)
|
err = page.LayoutComponent(page, content).Render(ctx.Request().Context(), &buf)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(
|
return echo.NewHTTPError(
|
||||||
@ -139,76 +134,6 @@ func (t *TemplateRenderer) RenderPageTempl(ctx echo.Context, page page.Page, con
|
|||||||
|
|
||||||
// Cache this page, if caching was enabled
|
// Cache this page, if caching was enabled
|
||||||
t.cachePage(ctx, page, &buf)
|
t.cachePage(ctx, page, &buf)
|
||||||
|
|
||||||
return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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())
|
return ctx.HTMLBlob(ctx.Response().Status, buf.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@ -13,9 +11,11 @@ import (
|
|||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/config"
|
"git.grosinger.net/tgrosinger/saasitone/config"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/htmx"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/htmx"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/tests"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/tests"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/templates"
|
"git.grosinger.net/tgrosinger/saasitone/templ/layouts"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/pages"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTemplateRenderer(t *testing.T) {
|
func TestTemplateRenderer(t *testing.T) {
|
||||||
@ -31,9 +31,8 @@ func TestTemplateRenderer(t *testing.T) {
|
|||||||
Parse().
|
Parse().
|
||||||
Group(group).
|
Group(group).
|
||||||
Key(id).
|
Key(id).
|
||||||
Base("htmx").
|
Base("test").
|
||||||
Files("layouts/htmx", "pages/error").
|
Files("emails/test").
|
||||||
Directories("components").
|
|
||||||
Store()
|
Store()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@ -43,13 +42,7 @@ func TestTemplateRenderer(t *testing.T) {
|
|||||||
|
|
||||||
// Check that all expected templates are included
|
// Check that all expected templates are included
|
||||||
expectedTemplates := make(map[string]bool)
|
expectedTemplates := make(map[string]bool)
|
||||||
expectedTemplates["htmx"+config.TemplateExt] = true
|
expectedTemplates["test"+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() {
|
for _, v := range parsed.Template.Templates() {
|
||||||
delete(expectedTemplates, v.Name())
|
delete(expectedTemplates, v.Name())
|
||||||
}
|
}
|
||||||
@ -63,20 +56,19 @@ func TestTemplateRenderer(t *testing.T) {
|
|||||||
buf, err := tpl.Execute(data)
|
buf, err := tpl.Execute(data)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, buf)
|
require.NotNil(t, buf)
|
||||||
assert.Contains(t, buf.String(), "Please try again")
|
assert.Contains(t, buf.String(), "Test email template")
|
||||||
|
|
||||||
buf, err = c.TemplateRenderer.
|
buf, err = c.TemplateRenderer.
|
||||||
Parse().
|
Parse().
|
||||||
Group(group).
|
Group(group).
|
||||||
Key(id).
|
Key(id).
|
||||||
Base("htmx").
|
Base("test").
|
||||||
Files("htmx", "pages/error").
|
Files("email/test").
|
||||||
Directories("components").
|
|
||||||
Execute(data)
|
Execute(data)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, buf)
|
require.NotNil(t, buf)
|
||||||
assert.Contains(t, buf.String(), "Please try again")
|
assert.Contains(t, buf.String(), "Test email template")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplateRenderer_RenderPage(t *testing.T) {
|
func TestTemplateRenderer_RenderPage(t *testing.T) {
|
||||||
@ -85,8 +77,6 @@ func TestTemplateRenderer_RenderPage(t *testing.T) {
|
|||||||
tests.InitSession(ctx)
|
tests.InitSession(ctx)
|
||||||
|
|
||||||
p := page.New(ctx)
|
p := page.New(ctx)
|
||||||
p.Name = "home"
|
|
||||||
p.Layout = "main"
|
|
||||||
p.Cache.Enabled = false
|
p.Cache.Enabled = false
|
||||||
p.Headers["A"] = "b"
|
p.Headers["A"] = "b"
|
||||||
p.Headers["C"] = "d"
|
p.Headers["C"] = "d"
|
||||||
@ -94,105 +84,20 @@ func TestTemplateRenderer_RenderPage(t *testing.T) {
|
|||||||
return ctx, rec, p
|
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) {
|
t.Run("htmx rendering", func(t *testing.T) {
|
||||||
ctx, _, p := setup()
|
ctx, _, p := setup()
|
||||||
|
p.LayoutComponent = layouts.Main
|
||||||
p.HTMX.Request.Enabled = true
|
p.HTMX.Request.Enabled = true
|
||||||
p.HTMX.Response = &htmx.Response{
|
p.HTMX.Response = &htmx.Response{
|
||||||
Trigger: "trigger",
|
Trigger: "trigger",
|
||||||
}
|
}
|
||||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
|
||||||
|
component := pages.Home(p, []models.Post{})
|
||||||
|
err := c.TemplateRenderer.RenderPageTempl(ctx, p, component)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check HTMX header
|
// Check HTMX header
|
||||||
assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger))
|
assert.Equal(t, "trigger", ctx.Response().Header().Get(htmx.HeaderTrigger))
|
||||||
|
|
||||||
// Check the template cache
|
|
||||||
parsed, err := c.TemplateRenderer.Load("page:htmx", string(p.Name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Check that all expected templates were parsed.
|
|
||||||
// This includes the name, htmx and all components
|
|
||||||
expectedTemplates := make(map[string]bool)
|
|
||||||
expectedTemplates[fmt.Sprintf("%s%s", p.Name, config.TemplateExt)] = true
|
|
||||||
expectedTemplates["htmx"+config.TemplateExt] = true
|
|
||||||
components, err := templates.Get().ReadDir("components")
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, f := range components {
|
|
||||||
expectedTemplates[f.Name()] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range parsed.Template.Templates() {
|
|
||||||
delete(expectedTemplates, v.Name())
|
|
||||||
}
|
|
||||||
assert.Empty(t, expectedTemplates)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("page cache", func(t *testing.T) {
|
|
||||||
ctx, rec, p := setup()
|
|
||||||
p.Cache.Enabled = true
|
|
||||||
p.Cache.Tags = []string{"tag1"}
|
|
||||||
err := c.TemplateRenderer.RenderPage(ctx, p)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Fetch from the cache
|
|
||||||
cp, err := c.TemplateRenderer.GetCachedPage(ctx, p.URL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Compare the cached page
|
|
||||||
assert.Equal(t, p.URL, cp.URL)
|
|
||||||
assert.Equal(t, p.Headers, cp.Headers)
|
|
||||||
assert.Equal(t, p.StatusCode, cp.StatusCode)
|
|
||||||
assert.Equal(t, rec.Body.Bytes(), cp.HTML)
|
|
||||||
|
|
||||||
// Clear the tag
|
|
||||||
err = c.Cache.
|
|
||||||
Flush().
|
|
||||||
Tags(p.Cache.Tags[0]).
|
|
||||||
Execute(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Refetch from the cache and expect no results
|
|
||||||
_, err = c.TemplateRenderer.GetCachedPage(ctx, p.URL)
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,29 +2,49 @@ package tasks
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mikestefanello/backlite"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/log"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/log"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExampleTask is an example implementation of services.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 subscriber needs to process the task.
|
// that your queue processor needs to process the task.
|
||||||
type ExampleTask struct {
|
type ExampleTask struct {
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name satisfies the services.Task interface by proviing a unique name for this Task type
|
// Config satisfies the backlite.Task interface by providing configuration for the queue that these items will be
|
||||||
func (t ExampleTask) Name() string {
|
func (t ExampleTask) Name() string {
|
||||||
|
// placed into for execution.
|
||||||
return "example_task"
|
return "example_task"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t ExampleTask) Config() backlite.QueueConfig {
|
||||||
|
return backlite.QueueConfig{
|
||||||
|
Name: "ExampleTask",
|
||||||
|
MaxAttempts: 3,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Backoff: 10 * time.Second,
|
||||||
|
Retention: &backlite.Retention{
|
||||||
|
Duration: 24 * time.Hour,
|
||||||
|
OnlyFailed: false,
|
||||||
|
Data: &backlite.RetainData{
|
||||||
|
OnlyFailed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func NewExampleTaskQueue(c *services.Container) services.Queue {
|
func NewExampleTaskQueue(c *services.Container) backlite.Queue {
|
||||||
return services.NewQueue[ExampleTask](func(ctx context.Context, task ExampleTask) error {
|
return backlite.NewQueue[ExampleTask](func(ctx context.Context, task ExampleTask) error {
|
||||||
log.Default().Info("Example task received",
|
log.Default().Info("Example task received",
|
||||||
"message", task.Message,
|
"message", task.Message,
|
||||||
)
|
)
|
||||||
|
9
templ/layouts/htmx.templ
Normal file
9
templ/layouts/htmx.templ
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package layouts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ HTMX(p page.Page, content templ.Component) {
|
||||||
|
@content
|
||||||
|
}
|
39
templ/layouts/htmx_templ.go
Normal file
39
templ/layouts/htmx_templ.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.2.707
|
||||||
|
package layouts
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import "context"
|
||||||
|
import "io"
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HTMX(p page.Page, content templ.Component) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||||
|
}
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
})
|
||||||
|
}
|
46
templ/pages/cache.templ
Normal file
46
templ/pages/cache.templ
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/components"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheForm struct {
|
||||||
|
Value string `form:"value"`
|
||||||
|
form.Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Cache(p page.Page, f *CacheForm, value *string) {
|
||||||
|
<form id="task" method="post" hx-post={ p.ToURL("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 value != nil {
|
||||||
|
<span class="tag is-success">{ *value }</span>
|
||||||
|
} else {
|
||||||
|
<i>(empty)</i>
|
||||||
|
}
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<div class="field">
|
||||||
|
<label for="value" class="label">Value</label>
|
||||||
|
<div class="control">
|
||||||
|
<input id="value" name="value" class="input" value={ f.Value }/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">Update cache</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.CSRF(p.CSRF)
|
||||||
|
</form>
|
||||||
|
}
|
108
templ/pages/cache_templ.go
Normal file
108
templ/pages/cache_templ.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.2.707
|
||||||
|
package pages
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import "context"
|
||||||
|
import "io"
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheForm struct {
|
||||||
|
Value string `form:"value"`
|
||||||
|
form.Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
func Cache(p page.Page, f *CacheForm, value *string) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"task\" method=\"post\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(p.ToURL("cache.submit"))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/cache.templ`, Line: 15, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><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 templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if value != nil {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span class=\"tag is-success\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(*value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/cache.templ`, Line: 27, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<i>(empty)</i>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<br><br><div class=\"field\"><label for=\"value\" class=\"label\">Value</label><div class=\"control\"><input id=\"value\" name=\"value\" class=\"input\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(f.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/cache.templ`, Line: 36, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></div></div><div class=\"field is-grouped\"><div class=\"control\"><button class=\"button is-link\">Update cache</button></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = components.CSRF(p.CSRF).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||||
|
}
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
})
|
||||||
|
}
|
@ -4,11 +4,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Home(p page.Page, posts []services.Post) {
|
templ Home(p page.Page, posts []models.Post) {
|
||||||
if (p.HTMX.Request.Target != "posts") {
|
if (p.HTMX.Request.Target != "posts") {
|
||||||
@topContent(p)
|
@topContent(p)
|
||||||
}
|
}
|
||||||
|
@ -14,11 +14,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/funcmap"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/models"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
"git.grosinger.net/tgrosinger/saasitone/pkg/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Home(p page.Page, posts []services.Post) templ.Component {
|
func Home(p page.Page, posts []models.Post) templ.Component {
|
||||||
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
if !templ_7745c5c3_IsBuffer {
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
14
templ/pages/search.templ
Normal file
14
templ/pages/search.templ
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Search(p page.Page, results []SearchResult) {
|
||||||
|
for _, result := range results {
|
||||||
|
<a class="panel-block" href={ templ.URL(result.URL) }>{ result.Title }</a>
|
||||||
|
}
|
||||||
|
}
|
66
templ/pages/search_templ.go
Normal file
66
templ/pages/search_templ.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.2.707
|
||||||
|
package pages
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import "context"
|
||||||
|
import "io"
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
import "git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Search(p page.Page, results []SearchResult) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
for _, result := range results {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"panel-block\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 templ.SafeURL = templ.URL(result.URL)
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(result.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/search.templ`, Line: 12, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||||
|
}
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
})
|
||||||
|
}
|
55
templ/pages/task.templ
Normal file
55
templ/pages/task.templ
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/components"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskForm struct {
|
||||||
|
Delay int `form:"delay" validate:"gte=0"`
|
||||||
|
Message string `form:"message" validate:"required"`
|
||||||
|
form.Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Task(p page.Page, f *TaskForm) {
|
||||||
|
if p.HTMX.Request.Target != "task" {
|
||||||
|
<article class="message is-link">
|
||||||
|
<div class="message-body">
|
||||||
|
<p>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>
|
||||||
|
<p>See pkg/tasks and the README for more information.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
@taskForm(p, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
templ taskForm(p page.Page, f *TaskForm) {
|
||||||
|
<form id="task" method="post" hx-post={ p.ToURL("task.submit") }>
|
||||||
|
@components.Messages(p)
|
||||||
|
<div class="field">
|
||||||
|
<label for="delay" class="label">Delay (in seconds)</label>
|
||||||
|
<div class="control">
|
||||||
|
<input type="number" id="delay" name="delay" class={ "input", f.Submission.GetFieldStatusClass("Delay") } value={ strconv.Itoa(f.Delay) }/>
|
||||||
|
</div>
|
||||||
|
<p class="help">How long to wait until the task is executed</p>
|
||||||
|
@components.FieldErrors(f.Submission.GetFieldErrors("Delay"))
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="message" class="label">Message</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea id="message" name="message" class={ "textarea", f.Submission.GetFieldStatusClass("Message") }>{ f.Message }</textarea>
|
||||||
|
</div>
|
||||||
|
<p class="help">The message the task will output to the log</p>
|
||||||
|
@components.FieldErrors(f.Submission.GetFieldErrors("Message"))
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link">Add task to queue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@components.CSRF(p.CSRF)
|
||||||
|
</form>
|
||||||
|
}
|
194
templ/pages/task_templ.go
Normal file
194
templ/pages/task_templ.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.2.707
|
||||||
|
package pages
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import "context"
|
||||||
|
import "io"
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/form"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/pkg/page"
|
||||||
|
"git.grosinger.net/tgrosinger/saasitone/templ/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskForm struct {
|
||||||
|
Delay int `form:"delay" validate:"gte=0"`
|
||||||
|
Message string `form:"message" validate:"required"`
|
||||||
|
form.Submission
|
||||||
|
}
|
||||||
|
|
||||||
|
func Task(p page.Page, f *TaskForm) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if p.HTMX.Request.Target != "task" {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<article class=\"message is-link\"><div class=\"message-body\"><p>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><p>See pkg/tasks and the README for more information.</p></div></article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = taskForm(p, f).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||||
|
}
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskForm(p page.Page, f *TaskForm) templ.Component {
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||||
|
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var2 == nil {
|
||||||
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<form id=\"task\" method=\"post\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(p.ToURL("task.submit"))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/task.templ`, Line: 30, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = components.Messages(p).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"field\"><label for=\"delay\" class=\"label\">Delay (in seconds)</label><div class=\"control\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 = []any{"input", f.Submission.GetFieldStatusClass("Delay")}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<input type=\"number\" id=\"delay\" name=\"delay\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/task.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(f.Delay))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/task.templ`, Line: 35, Col: 139}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></div><p class=\"help\">How long to wait until the task is executed</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = components.FieldErrors(f.Submission.GetFieldErrors("Delay")).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"field\"><label for=\"message\" class=\"label\">Message</label><div class=\"control\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 = []any{"textarea", f.Submission.GetFieldStatusClass("Message")}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<textarea id=\"message\" name=\"message\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/task.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(f.Message)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templ/pages/task.templ`, Line: 43, Col: 119}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</textarea></div><p class=\"help\">The message the task will output to the log</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = components.FieldErrors(f.Submission.GetFieldErrors("Message")).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"field is-grouped\"><div class=\"control\"><button class=\"button is-link\">Add task to queue</button></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = components.CSRF(p.CSRF).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||||
|
}
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
})
|
||||||
|
}
|
@ -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@0.9.4/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}}
|
|
@ -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}}
|
|
@ -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}}
|
|
@ -1,35 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<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> ◌
|
|
||||||
<a href="{{url "register"}}">Create an account</a> ◌
|
|
||||||
<a href="{{url "forgot_password"}}">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{template "footer" .}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1 +0,0 @@
|
|||||||
{{template "content" .}}
|
|
@ -1,92 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" style="height:100%;">
|
|
||||||
<head>
|
|
||||||
{{template "metatags" .}}
|
|
||||||
{{template "css" .}}
|
|
||||||
{{template "js" .}}
|
|
||||||
</head>
|
|
||||||
<body class="has-background-light" style="min-height:100%;">
|
|
||||||
<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>
|
|
||||||
</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.away="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}}
|
|
@ -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}}
|
|
@ -1,5 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
{{- range .Data}}
|
|
||||||
<a class="panel-block" href="{{.URL}}">{{.Title}}</a>
|
|
||||||
{{- end}}
|
|
||||||
{{end}}
|
|
@ -1,43 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
{{- if not (eq .HTMX.Request.Target "task")}}
|
|
||||||
<article class="message is-link">
|
|
||||||
<div class="message-body">
|
|
||||||
<p>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>
|
|
||||||
<p>See pkg/tasks and the README for more information.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{{- end}}
|
|
||||||
|
|
||||||
{{template "form" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "form"}}
|
|
||||||
<form id="task" method="post" hx-post="{{url "task.submit"}}">
|
|
||||||
{{template "messages" .}}
|
|
||||||
<div class="field">
|
|
||||||
<label for="delay" class="label">Delay (in seconds)</label>
|
|
||||||
<div class="control">
|
|
||||||
<input type="number" id="delay" name="delay" class="input {{.Form.GetFieldStatusClass "Delay"}}" value="{{.Form.Delay}}"/>
|
|
||||||
</div>
|
|
||||||
<p class="help">How long to wait until the task is executed</p>
|
|
||||||
{{template "field-errors" (.Form.GetFieldErrors "Delay")}}
|
|
||||||
</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>
|
|
||||||
<p class="help">The message the task will output to the log</p>
|
|
||||||
{{template "field-errors" (.Form.GetFieldErrors "Message")}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-link">Add task to queue</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{template "csrf" .}}
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
@ -9,31 +9,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
|
||||||
Layout string
|
|
||||||
Page string
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
LayoutMain Layout = "main"
|
|
||||||
LayoutAuth Layout = "auth"
|
|
||||||
LayoutHTMX Layout = "htmx"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PageAbout Page = "about"
|
|
||||||
PageCache Page = "cache"
|
|
||||||
PageContact Page = "contact"
|
|
||||||
PageError Page = "error"
|
|
||||||
PageForgotPassword Page = "forgot-password"
|
|
||||||
PageHome Page = "home"
|
|
||||||
PageLogin Page = "login"
|
|
||||||
PageRegister Page = "register"
|
|
||||||
PageResetPassword Page = "reset-password"
|
|
||||||
PageSearch Page = "search"
|
|
||||||
PageTask Page = "task"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed *
|
//go:embed *
|
||||||
var templates embed.FS
|
var templates embed.FS
|
||||||
|
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGet(t *testing.T) {
|
func TestGet(t *testing.T) {
|
||||||
_, err := Get().Open(fmt.Sprintf("pages/%s.gohtml", PageHome))
|
_, err := Get().Open("emails/test.gohtml")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetOS(t *testing.T) {
|
func TestGetOS(t *testing.T) {
|
||||||
_, err := GetOS().Open(fmt.Sprintf("pages/%s.gohtml", PageHome))
|
_, err := GetOS().Open("emails/test.gohtml")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user