Migrate from templates to Gomponents (#103)
This commit is contained in:
parent
0bf9ab7189
commit
051d032038
104 changed files with 2768 additions and 2824 deletions
64
pkg/ui/cache/cache.go
vendored
Normal file
64
pkg/ui/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
|
||||
"maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
var (
|
||||
// cache stores a cache of assembled components by key.
|
||||
cache = make(map[string]gomponents.Node)
|
||||
|
||||
// mu handles concurrent access to the cache.
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Set sets a given renderable node in the cache with a given key.
|
||||
// You should only cache nodes that are entirely static.
|
||||
// This will panic if the node fails to render.
|
||||
//
|
||||
// To optimize performance, the node will be rendered and converted to a Raw component so the assembly and rendering
|
||||
// of the entire, nested node only has to execute once. This can eliminate countless function calls and string building.
|
||||
// It's worth noting that this performance optimization is, in most cases, entirely unnecessary, but it's easy to do
|
||||
// and realize some performance gains. In my very limited testing, gomponents actually outperformed Go templates in
|
||||
// many areas; but not all. The results were still very close and my limited testing is in no way definitive.
|
||||
//
|
||||
// In most applications, these slight differences in nanoseconds and bytes allocated will almost never matter or even
|
||||
// be noticeable, but it's good to be aware of them; and it's fun to address them. In looking at the example layouts
|
||||
// provided, I noticed that a lot of nested function calls and string building was happening on every single page load
|
||||
// just to re-render static HTML such as the navbar and the search form/modal. Benchmarks quickly revealed that caching
|
||||
// those high-level nodes made a significant difference in speed and memory allocations. Going further, I thought that
|
||||
// with the entire node cached, you still have to render the entire nested structure each time it's used, so that is why
|
||||
// this will render them upfront, then cache. If my few examples have a handful of static nodes, I assume most full
|
||||
// applications will have many, so maybe this is useful.
|
||||
func Set(key string, node gomponents.Node) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := node.Render(buf); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
cache[key] = gomponents.Raw(buf.String())
|
||||
}
|
||||
|
||||
// Get returns the node cached under the provided key, if one exists.
|
||||
func Get(key string) gomponents.Node {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return cache[key]
|
||||
}
|
||||
|
||||
// SetIfNotExists will return the cached Node for the key, if it exists, otherwise it will use the provided callback
|
||||
// function to generate the node and cache it.
|
||||
func SetIfNotExists(key string, gen func() gomponents.Node) gomponents.Node {
|
||||
if n := Get(key); n != nil {
|
||||
return n
|
||||
}
|
||||
|
||||
n := gen()
|
||||
Set(key, n)
|
||||
return n
|
||||
}
|
||||
57
pkg/ui/cache/cache_test.go
vendored
Normal file
57
pkg/ui/cache/cache_test.go
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func TestCache_GetSet(t *testing.T) {
|
||||
key := "test"
|
||||
assert.Nil(t, Get(key))
|
||||
|
||||
node := Div(Text("hello"))
|
||||
Set(key, node)
|
||||
|
||||
got := Get(key)
|
||||
require.NotNil(t, got)
|
||||
|
||||
// Check it was converted to a Raw component.
|
||||
_, ok := got.(NodeFunc)
|
||||
require.True(t, ok)
|
||||
|
||||
// Both nodes should render the same string.
|
||||
buf1, buf2 := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
|
||||
require.NoError(t, node.Render(buf1))
|
||||
require.NoError(t, got.Render(buf2))
|
||||
assert.Equal(t, buf1.String(), buf2.String())
|
||||
}
|
||||
|
||||
func TestCache_SetIfNotExists(t *testing.T) {
|
||||
key := "test2"
|
||||
called := 0
|
||||
callback := func() Node {
|
||||
called++
|
||||
return Div(Text("hello"))
|
||||
}
|
||||
|
||||
assertRender := func(n Node) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
require.NoError(t, n.Render(buf))
|
||||
assert.Equal(t, `<div>hello</div>`, buf.String())
|
||||
}
|
||||
|
||||
got := SetIfNotExists(key, callback)
|
||||
assert.Equal(t, 1, called)
|
||||
require.NotNil(t, got)
|
||||
assertRender(got)
|
||||
|
||||
got = SetIfNotExists(key, callback)
|
||||
assert.Equal(t, 1, called)
|
||||
require.NotNil(t, got)
|
||||
assertRender(got)
|
||||
}
|
||||
64
pkg/ui/components/alerts.go
Normal file
64
pkg/ui/components/alerts.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func FlashMessages(r *ui.Request) Node {
|
||||
var g Group
|
||||
for _, typ := range []msg.Type{
|
||||
msg.TypeSuccess,
|
||||
msg.TypeInfo,
|
||||
msg.TypeWarning,
|
||||
msg.TypeDanger,
|
||||
} {
|
||||
for _, str := range msg.Get(r.Context, typ) {
|
||||
g = append(g, Notification(typ, str))
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func Notification(typ msg.Type, text string) Node {
|
||||
var class string
|
||||
|
||||
switch typ {
|
||||
case msg.TypeSuccess:
|
||||
class = "success"
|
||||
case msg.TypeInfo:
|
||||
class = "info"
|
||||
case msg.TypeWarning:
|
||||
class = "warning"
|
||||
case msg.TypeDanger:
|
||||
class = "danger"
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("notification is-"+class),
|
||||
Attr("x-data", "{show: true}"),
|
||||
Attr("x-show", "show"),
|
||||
Button(
|
||||
Class("delete"),
|
||||
Attr("@click", "show = false"),
|
||||
),
|
||||
Text(text),
|
||||
)
|
||||
}
|
||||
|
||||
func Message(class, header string, body Node) Node {
|
||||
return Article(
|
||||
Class("message "+class),
|
||||
If(header != "", Div(
|
||||
Class("message-header"),
|
||||
P(Text(header)),
|
||||
)),
|
||||
Div(
|
||||
Class("message-body"),
|
||||
body,
|
||||
),
|
||||
)
|
||||
}
|
||||
203
pkg/ui/components/form.go
Normal file
203
pkg/ui/components/form.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type (
|
||||
InputFieldParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
InputType string
|
||||
Label string
|
||||
Value string
|
||||
Placeholder string
|
||||
Help string
|
||||
}
|
||||
|
||||
RadiosParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
Label string
|
||||
Value string
|
||||
Options []Radio
|
||||
}
|
||||
|
||||
Radio struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
TextareaFieldParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
Label string
|
||||
Value string
|
||||
Help string
|
||||
}
|
||||
)
|
||||
|
||||
func ControlGroup(controls ...Node) Node {
|
||||
g := make(Group, len(controls))
|
||||
for i, control := range controls {
|
||||
g[i] = Div(
|
||||
Class("control"),
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("field is-grouped"),
|
||||
g,
|
||||
)
|
||||
}
|
||||
|
||||
func TextareaField(el TextareaFieldParams) Node {
|
||||
return Div(
|
||||
Class("field"),
|
||||
Label(
|
||||
For("name"),
|
||||
Class("label"),
|
||||
Text(el.Label),
|
||||
),
|
||||
Div(
|
||||
Class("control"),
|
||||
Textarea(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Text(el.Value),
|
||||
),
|
||||
),
|
||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func Radios(el RadiosParams) Node {
|
||||
buttons := make(Group, len(el.Options))
|
||||
for i, opt := range el.Options {
|
||||
buttons[i] = Label(
|
||||
Class("radio"),
|
||||
Input(
|
||||
Type("radio"),
|
||||
Name(el.Name),
|
||||
Value(opt.Value),
|
||||
If(el.Value == opt.Value, Checked()),
|
||||
),
|
||||
Text(" "+opt.Label),
|
||||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("control field"),
|
||||
Label(Class("label"), Text(el.Label)),
|
||||
Div(
|
||||
Class("radios"),
|
||||
buttons,
|
||||
),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func InputField(el InputFieldParams) Node {
|
||||
return Div(
|
||||
Class("field"),
|
||||
Label(
|
||||
Class("label"),
|
||||
For(el.Name),
|
||||
Text(el.Label),
|
||||
),
|
||||
Div(
|
||||
Class("control"),
|
||||
Input(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Type(el.InputType),
|
||||
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
||||
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Value(el.Value),
|
||||
),
|
||||
),
|
||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func FileField(name, label string) Node {
|
||||
return Div(
|
||||
Class("field file"),
|
||||
Label(
|
||||
Class("file-label"),
|
||||
Input(
|
||||
Class("file-input"),
|
||||
Type("file"),
|
||||
Name(name),
|
||||
),
|
||||
Span(
|
||||
Class("file-cta"),
|
||||
Span(
|
||||
Class("file-label"),
|
||||
Text(label),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func formFieldStatusClass(fm form.Form, formField string) string {
|
||||
switch {
|
||||
case !fm.IsSubmitted():
|
||||
return ""
|
||||
case fm.FieldHasErrors(formField):
|
||||
return "is-danger"
|
||||
default:
|
||||
return "is-success"
|
||||
}
|
||||
}
|
||||
|
||||
func formFieldErrors(fm form.Form, field string) Node {
|
||||
errs := fm.GetFieldErrors(field)
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
g := make(Group, len(errs))
|
||||
for i, err := range errs {
|
||||
g[i] = P(
|
||||
Class("help is-danger"),
|
||||
Text(err),
|
||||
)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func CSRF(r *ui.Request) Node {
|
||||
return Input(
|
||||
Type("hidden"),
|
||||
Name("csrf"),
|
||||
Value(r.CSRF),
|
||||
)
|
||||
}
|
||||
|
||||
func FormButton(class, label string) Node {
|
||||
return Button(
|
||||
Class("button "+class),
|
||||
Text(label),
|
||||
)
|
||||
}
|
||||
|
||||
func ButtonLink(href, class, label string) Node {
|
||||
return A(
|
||||
Href(href),
|
||||
Class("button "+class),
|
||||
Text(label),
|
||||
)
|
||||
}
|
||||
60
pkg/ui/components/head.go
Normal file
60
pkg/ui/components/head.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func JS(r *ui.Request) Node {
|
||||
const htmxErr = `
|
||||
document.body.addEventListener('htmx:beforeSwap', function(evt) {
|
||||
if (evt.detail.xhr.status >= 400){
|
||||
evt.detail.shouldSwap = true;
|
||||
evt.detail.target = htmx.find("body");
|
||||
}
|
||||
});
|
||||
`
|
||||
|
||||
const htmxCSRF = `
|
||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||
if (evt.detail.verb !== "get") {
|
||||
evt.detail.parameters['csrf'] = '%s';
|
||||
}
|
||||
})
|
||||
`
|
||||
|
||||
var csrf Node
|
||||
|
||||
if len(r.CSRF) > 0 {
|
||||
csrf = Script(Raw(fmt.Sprintf(htmxCSRF, r.CSRF)))
|
||||
}
|
||||
|
||||
return Group{
|
||||
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")),
|
||||
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
|
||||
Script(Raw(htmxErr)),
|
||||
csrf,
|
||||
}
|
||||
}
|
||||
|
||||
func CSS() Node {
|
||||
return Link(
|
||||
Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
|
||||
Rel("stylesheet"),
|
||||
)
|
||||
}
|
||||
|
||||
func Metatags(r *ui.Request) Node {
|
||||
return Group{
|
||||
Meta(Charset("utf-8")),
|
||||
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
|
||||
Link(Rel("icon"), Href(ui.File("favicon.png"))),
|
||||
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
|
||||
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
|
||||
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
|
||||
}
|
||||
}
|
||||
9
pkg/ui/components/htmx.go
Normal file
9
pkg/ui/components/htmx.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
func HxBoost() Node {
|
||||
return Attr("hx-boost", "true")
|
||||
}
|
||||
19
pkg/ui/components/nav.go
Normal file
19
pkg/ui/components/nav.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node {
|
||||
href := r.Path(routeName, routeParams...)
|
||||
|
||||
return Li(
|
||||
A(
|
||||
Href(href),
|
||||
Text(title),
|
||||
If(href == r.CurrentPath, Class("is-active")),
|
||||
),
|
||||
)
|
||||
}
|
||||
56
pkg/ui/components/tabs.go
Normal file
56
pkg/ui/components/tabs.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Tab struct {
|
||||
Title, Body string
|
||||
}
|
||||
|
||||
func Tabs(heading, description string, items []Tab) Node {
|
||||
renderTitles := func() Node {
|
||||
g := make(Group, len(items))
|
||||
for i, item := range items {
|
||||
g[i] = Li(
|
||||
Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)),
|
||||
Attr("@click", fmt.Sprintf("tab = %d", i)),
|
||||
A(Text(item.Title)),
|
||||
)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
renderBodies := func() Node {
|
||||
g := make(Group, len(items))
|
||||
for i, item := range items {
|
||||
g[i] = Div(
|
||||
Attr("x-show", fmt.Sprintf("tab == %d", i)),
|
||||
P(Raw(" "+item.Body)),
|
||||
)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
return Div(
|
||||
P(
|
||||
Class("subtitle mt-5"),
|
||||
Text(heading),
|
||||
),
|
||||
P(
|
||||
Class("mb-4"),
|
||||
Text(description),
|
||||
),
|
||||
Div(
|
||||
Attr("x-data", "{tab: 0}"),
|
||||
Div(
|
||||
Class("tabs"),
|
||||
Ul(renderTitles()),
|
||||
),
|
||||
renderBodies(),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
pkg/ui/emails/auth.go
Normal file
22
pkg/ui/emails/auth.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package emails
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func ConfirmEmailAddress(ctx echo.Context, username, token string) Node {
|
||||
url := ui.NewRequest(ctx).
|
||||
Url(routenames.VerifyEmail, token)
|
||||
|
||||
return Group{
|
||||
Strong(Textf("Hello %s,", username)),
|
||||
Br(),
|
||||
P(Text("Please click on the following link to confirm your email address:")),
|
||||
Br(),
|
||||
A(Href(url), Text(url)),
|
||||
}
|
||||
}
|
||||
53
pkg/ui/forms/cache.go
Normal file
53
pkg/ui/forms/cache.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
CurrentValue string
|
||||
Value string `form:"value"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *Cache) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("cache"),
|
||||
Method(http.MethodPost),
|
||||
Attr("hx-post", r.Path(routenames.CacheSubmit)),
|
||||
Message(
|
||||
"is-info",
|
||||
"Test the cache",
|
||||
Group{
|
||||
P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
|
||||
P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
|
||||
},
|
||||
),
|
||||
Label(
|
||||
For("value"),
|
||||
Class("value"),
|
||||
Text("Value in cache: "),
|
||||
),
|
||||
If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))),
|
||||
If(f.CurrentValue == "", I(Text("(empty)"))),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Value",
|
||||
Name: "value",
|
||||
InputType: "text",
|
||||
Label: "Value",
|
||||
Value: f.Value,
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-link", "Update cache"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
58
pkg/ui/forms/contact.go
Normal file
58
pkg/ui/forms/contact.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Department string `form:"department" validate:"required,oneof=sales marketing hr"`
|
||||
Message string `form:"message" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *Contact) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("contact"),
|
||||
Method(http.MethodPost),
|
||||
Attr("hx-post", r.Path(routenames.ContactSubmit)),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Email",
|
||||
Name: "email",
|
||||
InputType: "email",
|
||||
Label: "Email address",
|
||||
Value: f.Email,
|
||||
}),
|
||||
Radios(RadiosParams{
|
||||
Form: f,
|
||||
FormField: "Department",
|
||||
Name: "department",
|
||||
Label: "Department",
|
||||
Value: f.Department,
|
||||
Options: []Radio{
|
||||
{Value: "sales", Label: "Sales"},
|
||||
{Value: "marketing", Label: "Marketing"},
|
||||
{Value: "hr", Label: "HR"},
|
||||
},
|
||||
}),
|
||||
TextareaField(TextareaFieldParams{
|
||||
Form: f,
|
||||
FormField: "Message",
|
||||
Name: "message",
|
||||
Label: "Message",
|
||||
Value: f.Message,
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-link", "Submit"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
27
pkg/ui/forms/file.go
Normal file
27
pkg/ui/forms/file.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type File struct{}
|
||||
|
||||
func (f File) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("files"),
|
||||
Method(http.MethodPost),
|
||||
Action(r.Path(routenames.FilesSubmit)),
|
||||
EncType("multipart/form-data"),
|
||||
FileField("file", "Choose a file.. "),
|
||||
ControlGroup(
|
||||
FormButton("is-link", "Upload"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
39
pkg/ui/forms/forgot_password.go
Normal file
39
pkg/ui/forms/forgot_password.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type ForgotPassword struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *ForgotPassword) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("forgot-password"),
|
||||
Method(http.MethodPost),
|
||||
HxBoost(),
|
||||
Action(r.Path(routenames.ForgotPasswordSubmit)),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Email",
|
||||
Name: "email",
|
||||
InputType: "email",
|
||||
Label: "Email address",
|
||||
Value: f.Email,
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-primary", "Reset password"),
|
||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
49
pkg/ui/forms/login.go
Normal file
49
pkg/ui/forms/login.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Login struct {
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *Login) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("login"),
|
||||
Method(http.MethodPost),
|
||||
HxBoost(),
|
||||
Action(r.Path(routenames.LoginSubmit)),
|
||||
FlashMessages(r),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Email",
|
||||
Name: "email",
|
||||
InputType: "email",
|
||||
Label: "Email address",
|
||||
Value: f.Email,
|
||||
}),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Password",
|
||||
Name: "password",
|
||||
InputType: "password",
|
||||
Label: "Password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-link", "Login"),
|
||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
66
pkg/ui/forms/register.go
Normal file
66
pkg/ui/forms/register.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Register struct {
|
||||
Name string `form:"name" validate:"required"`
|
||||
Email string `form:"email" validate:"required,email"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *Register) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("register"),
|
||||
Method(http.MethodPost),
|
||||
HxBoost(),
|
||||
Action(r.Path(routenames.RegisterSubmit)),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Name",
|
||||
Name: "name",
|
||||
InputType: "text",
|
||||
Label: "Name",
|
||||
Value: f.Name,
|
||||
}),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Email",
|
||||
Name: "email",
|
||||
InputType: "email",
|
||||
Label: "Email address",
|
||||
Value: f.Email,
|
||||
}),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Password",
|
||||
Name: "password",
|
||||
InputType: "password",
|
||||
Label: "Password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "PasswordConfirm",
|
||||
Name: "password-confirm",
|
||||
InputType: "password",
|
||||
Label: "Confirm password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-primary", "Register"),
|
||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
46
pkg/ui/forms/reset_password.go
Normal file
46
pkg/ui/forms/reset_password.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type ResetPassword struct {
|
||||
Password string `form:"password" validate:"required"`
|
||||
ConfirmPassword string `form:"password-confirm" validate:"required,eqfield=Password"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *ResetPassword) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("reset-password"),
|
||||
Method(http.MethodPost),
|
||||
HxBoost(),
|
||||
Action(r.CurrentPath),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Password",
|
||||
Name: "password",
|
||||
InputType: "password",
|
||||
Label: "Password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "PasswordConfirm",
|
||||
Name: "password-confirm",
|
||||
InputType: "password",
|
||||
Label: "Confirm password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-primary", "Update password"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
49
pkg/ui/forms/task.go
Normal file
49
pkg/ui/forms/task.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/form"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
Delay int `form:"delay" validate:"gte=0"`
|
||||
Message string `form:"message" validate:"required"`
|
||||
form.Submission
|
||||
}
|
||||
|
||||
func (f *Task) Render(r *ui.Request) Node {
|
||||
return Form(
|
||||
ID("task"),
|
||||
Method(http.MethodPost),
|
||||
Attr("hx-post", r.Path(routenames.TaskSubmit)),
|
||||
FlashMessages(r),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Delay",
|
||||
Name: "delay",
|
||||
InputType: "number",
|
||||
Label: "Delay (in seconds)",
|
||||
Help: "How long to wait until the task is executed",
|
||||
Value: fmt.Sprint(f.Delay),
|
||||
}),
|
||||
TextareaField(TextareaFieldParams{
|
||||
Form: f,
|
||||
FormField: "Message",
|
||||
Name: "message",
|
||||
Label: "Message",
|
||||
Value: f.Message,
|
||||
Help: "The message the task will output to the log",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton("is-link", "Add task to queue"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
64
pkg/ui/layouts/auth.go
Normal file
64
pkg/ui/layouts/auth.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Auth(r *ui.Request, content Node) Node {
|
||||
return Doctype(
|
||||
HTML(
|
||||
Lang("en"),
|
||||
Head(
|
||||
Metatags(r),
|
||||
CSS(),
|
||||
JS(r),
|
||||
),
|
||||
Body(
|
||||
Section(
|
||||
Class("hero is-fullheight"),
|
||||
Div(
|
||||
Class("hero-body"),
|
||||
Div(
|
||||
Class("container"),
|
||||
Div(
|
||||
Class("columns is-centered"),
|
||||
Div(
|
||||
Class("column is-half"),
|
||||
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
|
||||
Div(
|
||||
Class("notification"),
|
||||
FlashMessages(r),
|
||||
content,
|
||||
authNavBar(r),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func authNavBar(r *ui.Request) Node {
|
||||
return cache.SetIfNotExists("authNavBar", func() Node {
|
||||
return Nav(
|
||||
Class("navbar"),
|
||||
Div(
|
||||
Class("navbar-menu"),
|
||||
Div(
|
||||
Class("navbar-start"),
|
||||
A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")),
|
||||
A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")),
|
||||
A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
158
pkg/ui/layouts/primary.go
Normal file
158
pkg/ui/layouts/primary.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Primary(r *ui.Request, content Node) Node {
|
||||
return Doctype(
|
||||
HTML(
|
||||
Lang("en"),
|
||||
Head(
|
||||
Metatags(r),
|
||||
CSS(),
|
||||
JS(r),
|
||||
),
|
||||
Body(
|
||||
headerNavBar(r),
|
||||
Div(
|
||||
Class("container mt-5"),
|
||||
Div(
|
||||
Class("columns"),
|
||||
Div(
|
||||
Class("column is-2"),
|
||||
sidebarMenu(r),
|
||||
),
|
||||
Div(
|
||||
Class("column is-10"),
|
||||
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
|
||||
FlashMessages(r),
|
||||
content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func headerNavBar(r *ui.Request) Node {
|
||||
return cache.SetIfNotExists("layout.headerNavBar", func() Node {
|
||||
return Nav(
|
||||
Class("navbar is-dark"),
|
||||
Div(
|
||||
Class("container"),
|
||||
Div(
|
||||
Class("navbar-brand"),
|
||||
HxBoost(),
|
||||
A(
|
||||
Href(r.Path(routenames.Home)),
|
||||
Class("navbar-item"),
|
||||
Text("Pagoda"),
|
||||
),
|
||||
),
|
||||
Div(
|
||||
ID("navbarMenu"),
|
||||
Class("navbar-menu"),
|
||||
Div(
|
||||
Class("navbar-end"),
|
||||
search(r),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func search(r *ui.Request) Node {
|
||||
return cache.SetIfNotExists("layout.search", func() Node {
|
||||
return Div(
|
||||
Class("search mr-2 mt-1"),
|
||||
Attr("x-data", "{modal:false}"),
|
||||
Input(
|
||||
Class("input"),
|
||||
Type("search"),
|
||||
Placeholder("Search..."),
|
||||
Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"),
|
||||
),
|
||||
Div(
|
||||
Class("modal"),
|
||||
Attr(":class", "modal ? 'is-active' : ''"),
|
||||
Attr("x-show", "modal == true"),
|
||||
Div(
|
||||
Class("modal-background"),
|
||||
),
|
||||
Div(
|
||||
Class("modal-content"),
|
||||
Attr("@click.outside", "modal = false;"),
|
||||
Div(
|
||||
Class("box"),
|
||||
H2(
|
||||
Class("subtitle"),
|
||||
Text("Search"),
|
||||
),
|
||||
P(
|
||||
Class("control"),
|
||||
Input(
|
||||
Attr("hx-get", r.Path(routenames.Search)),
|
||||
Attr("hx-trigger", "keyup changed delay:500ms"),
|
||||
Attr("hx-target", "#results"),
|
||||
Name("query"),
|
||||
Class("input"),
|
||||
Type("search"),
|
||||
Placeholder("Search..."),
|
||||
Attr("x-ref", "input"),
|
||||
),
|
||||
),
|
||||
Div(
|
||||
Class("block"),
|
||||
),
|
||||
Div(
|
||||
ID("results"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Button(
|
||||
Class("modal-close is-large"),
|
||||
Aria("label", "close"),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func sidebarMenu(r *ui.Request) Node {
|
||||
return Aside(
|
||||
Class("menu"),
|
||||
HxBoost(),
|
||||
P(
|
||||
Class("menu-label"),
|
||||
Text("General"),
|
||||
),
|
||||
Ul(
|
||||
Class("menu-list"),
|
||||
MenuLink(r, "Dashboard", routenames.Home),
|
||||
MenuLink(r, "About", routenames.About),
|
||||
MenuLink(r, "Contact", routenames.Contact),
|
||||
MenuLink(r, "Cache", routenames.Cache),
|
||||
MenuLink(r, "Task", routenames.Task),
|
||||
MenuLink(r, "Files", routenames.Files),
|
||||
),
|
||||
P(
|
||||
Class("menu-label"),
|
||||
Text("Account"),
|
||||
),
|
||||
Ul(
|
||||
Class("menu-list"),
|
||||
If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)),
|
||||
If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)),
|
||||
If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
|
||||
If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
pkg/ui/models/file.go
Normal file
22
pkg/ui/models/file.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Name string
|
||||
Size int64
|
||||
Modified string
|
||||
}
|
||||
|
||||
func (f *File) Render() Node {
|
||||
return Tr(
|
||||
Td(Text(f.Name)),
|
||||
Td(Text(fmt.Sprint(f.Size))),
|
||||
Td(Text(f.Modified)),
|
||||
)
|
||||
}
|
||||
85
pkg/ui/models/post.go
Normal file
85
pkg/ui/models/post.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type (
|
||||
Posts struct {
|
||||
Posts []Post
|
||||
Pager pager.Pager
|
||||
}
|
||||
|
||||
Post struct {
|
||||
Title, Body string
|
||||
}
|
||||
)
|
||||
|
||||
func (p *Posts) Render(path string) Node {
|
||||
g := make(Group, len(p.Posts))
|
||||
for i, post := range p.Posts {
|
||||
g[i] = post.Render()
|
||||
}
|
||||
|
||||
return Div(
|
||||
ID("posts"),
|
||||
g,
|
||||
Div(
|
||||
Class("field is-grouped is-grouped-centered"),
|
||||
If(!p.Pager.IsBeginning(), P(
|
||||
Class("control"),
|
||||
Button(
|
||||
Class("button is-primary"),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
|
||||
Attr("hx-target", "#posts"),
|
||||
Text("Previous page"),
|
||||
),
|
||||
)),
|
||||
If(!p.Pager.IsEnd(), P(
|
||||
Class("control"),
|
||||
Button(
|
||||
Class("button is-primary"),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
|
||||
Attr("hx-target", "#posts"),
|
||||
Text("Next page"),
|
||||
),
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Post) Render() Node {
|
||||
return Article(
|
||||
Class("media"),
|
||||
Figure(
|
||||
Class("media-left"),
|
||||
P(
|
||||
Class("image is-64x64"),
|
||||
Img(
|
||||
Src(ui.File("gopher.png")),
|
||||
Alt("Gopher"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Div(
|
||||
Class("media-content"),
|
||||
Div(
|
||||
Class("content"),
|
||||
P(
|
||||
Strong(
|
||||
Text(p.Title),
|
||||
),
|
||||
Br(),
|
||||
Text(p.Body),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
19
pkg/ui/models/search_result.go
Normal file
19
pkg/ui/models/search_result.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (s *SearchResult) Render() Node {
|
||||
return A(
|
||||
Class("panel-block"),
|
||||
Href(s.URL),
|
||||
Text(s.Title),
|
||||
)
|
||||
}
|
||||
58
pkg/ui/pages/about.go
Normal file
58
pkg/ui/pages/about.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func About(ctx echo.Context) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "About"
|
||||
r.Metatags.Description = "Learn a little about what's included in Pagoda."
|
||||
|
||||
// The tabs are static so we can render and cache them.
|
||||
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
|
||||
return Group{
|
||||
Tabs(
|
||||
"Frontend",
|
||||
"The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.",
|
||||
[]Tab{
|
||||
{
|
||||
Title: "HTMX",
|
||||
Body: "Completes HTML as a hypertext by providing attributes to AJAXify anything and much more. Visit <a href=\"https://htmx.org/\">htmx.org</a> to learn more.",
|
||||
},
|
||||
{
|
||||
Title: "Alpine.js",
|
||||
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
|
||||
},
|
||||
{
|
||||
Title: "Bulma",
|
||||
Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href=\"https://bulma.io/\">bulma.io</a> to learn more.",
|
||||
},
|
||||
},
|
||||
),
|
||||
Div(Class("mb-4")),
|
||||
Tabs(
|
||||
"Backend",
|
||||
"The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.",
|
||||
[]Tab{
|
||||
{
|
||||
Title: "Echo",
|
||||
Body: "High performance, extensible, minimalist Go web framework. Visit <a href=\"https://echo.labstack.com/\">echo.labstack.com</a> to learn more.",
|
||||
},
|
||||
{
|
||||
Title: "Ent",
|
||||
Body: "Simple, yet powerful ORM for modeling and querying data. Visit <a href=\"https://entgo.io/\">entgo.io</a> to learn more.",
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return r.Render(layouts.Primary, tabs)
|
||||
}
|
||||
46
pkg/ui/pages/auth.go
Normal file
46
pkg/ui/pages/auth.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Login(ctx echo.Context, form *forms.Login) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Login"
|
||||
|
||||
return r.Render(layouts.Auth, form.Render(r))
|
||||
}
|
||||
|
||||
func Register(ctx echo.Context, form *forms.Register) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Register"
|
||||
|
||||
return r.Render(layouts.Auth, form.Render(r))
|
||||
}
|
||||
|
||||
func ForgotPassword(ctx echo.Context, form *forms.ForgotPassword) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Forgot password"
|
||||
|
||||
g := Group{
|
||||
Div(
|
||||
Class("content"),
|
||||
P(Text("Enter your email address and we'll email you a link that allows you to reset your password.")),
|
||||
),
|
||||
form.Render(r),
|
||||
}
|
||||
|
||||
return r.Render(layouts.Auth, g)
|
||||
}
|
||||
|
||||
func ResetPassword(ctx echo.Context, form *forms.ResetPassword) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Reset your password"
|
||||
|
||||
return r.Render(layouts.Auth, form.Render(r))
|
||||
}
|
||||
15
pkg/ui/pages/cache.go
Normal file
15
pkg/ui/pages/cache.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
)
|
||||
|
||||
func UpdateCache(ctx echo.Context, form *forms.Cache) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Set a cache entry"
|
||||
|
||||
return r.Render(layouts.Primary, form.Render(r))
|
||||
}
|
||||
41
pkg/ui/pages/contact.go
Normal file
41
pkg/ui/pages/contact.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func ContactUs(ctx echo.Context, form *forms.Contact) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Contact us"
|
||||
r.Metatags.Description = "Get in touch with us."
|
||||
|
||||
g := make(Group, 0)
|
||||
|
||||
if r.Htmx.Target != "contact" {
|
||||
g = append(g, components.Message(
|
||||
"is-link",
|
||||
"",
|
||||
Group{
|
||||
P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
|
||||
P(Text("Only the form below will update async upon submission.")),
|
||||
}))
|
||||
}
|
||||
|
||||
if form.IsDone() {
|
||||
g = append(g, components.Message(
|
||||
"is-large is-success",
|
||||
"Thank you!",
|
||||
Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."),
|
||||
))
|
||||
} else {
|
||||
g = append(g, form.Render(r))
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
38
pkg/ui/pages/error.go
Normal file
38
pkg/ui/pages/error.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Error(ctx echo.Context, code int) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = http.StatusText(code)
|
||||
var body Node
|
||||
|
||||
switch code {
|
||||
case http.StatusInternalServerError:
|
||||
body = Text("Please try again.")
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
body = Text("You are not authorized to view the requested page.")
|
||||
case http.StatusNotFound:
|
||||
body = Group{
|
||||
Text("Click "),
|
||||
A(
|
||||
Href(r.Path(routenames.Home)),
|
||||
Text("here"),
|
||||
),
|
||||
Text(" to go return home."),
|
||||
}
|
||||
default:
|
||||
body = Text("Something went wrong.")
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, P(body))
|
||||
}
|
||||
53
pkg/ui/pages/file.go
Normal file
53
pkg/ui/pages/file.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func UploadFile(ctx echo.Context, files []*models.File) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Upload a file"
|
||||
|
||||
fileList := make(Group, len(files))
|
||||
for i, file := range files {
|
||||
fileList[i] = file.Render()
|
||||
}
|
||||
|
||||
n := Group{
|
||||
Message(
|
||||
"is-link",
|
||||
"",
|
||||
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
|
||||
),
|
||||
Hr(),
|
||||
forms.File{}.Render(r),
|
||||
Hr(),
|
||||
H3(
|
||||
Class("title"),
|
||||
Text("Uploaded files"),
|
||||
),
|
||||
Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))),
|
||||
Table(
|
||||
Class("table"),
|
||||
THead(
|
||||
Tr(
|
||||
Th(Text("Filename")),
|
||||
Th(Text("Size")),
|
||||
Th(Text("Modified on")),
|
||||
),
|
||||
),
|
||||
TBody(
|
||||
fileList,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, n)
|
||||
}
|
||||
69
pkg/ui/pages/home.go
Normal file
69
pkg/ui/pages/home.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Home(ctx echo.Context, posts *models.Posts) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Metatags.Description = "This is the home page."
|
||||
r.Metatags.Keywords = []string{"Software", "Coding", "Go"}
|
||||
|
||||
g := make(Group, 0)
|
||||
|
||||
if r.Htmx.Target != "posts" {
|
||||
var hello string
|
||||
if r.IsAuth {
|
||||
hello = fmt.Sprintf("Hello, %s", r.AuthUser.Name)
|
||||
} else {
|
||||
hello = "Hello"
|
||||
}
|
||||
|
||||
g = append(g,
|
||||
Section(
|
||||
Class("hero is-info welcome is-small mb-5"),
|
||||
Div(
|
||||
Class("hero-body"),
|
||||
Div(
|
||||
Class("container"),
|
||||
H1(
|
||||
Class("title"),
|
||||
Text(hello),
|
||||
),
|
||||
H2(
|
||||
Class("subtitle"),
|
||||
If(!r.IsAuth, Text("Please login in to your account.")),
|
||||
If(r.IsAuth, Text("Welcome back!")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
H2(Class("title"), Text("Recent posts")),
|
||||
H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
|
||||
)
|
||||
}
|
||||
|
||||
g = append(g, posts.Render(r.Path(routenames.Home)))
|
||||
|
||||
if r.Htmx.Target != "posts" {
|
||||
g = append(g, Message(
|
||||
"is-small is-warning mt-5",
|
||||
"Serving files",
|
||||
Group{
|
||||
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
|
||||
Text("Static files also contain cache-control headers which are configured via middleware."),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
20
pkg/ui/pages/search.go
Normal file
20
pkg/ui/pages/search.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||
. "maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
func SearchResults(ctx echo.Context, results []*models.SearchResult) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
|
||||
g := make(Group, len(results))
|
||||
for i, result := range results {
|
||||
g[i] = result.Render()
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
33
pkg/ui/pages/task.go
Normal file
33
pkg/ui/pages/task.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func AddTask(ctx echo.Context, form *forms.Task) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = "Create a task"
|
||||
r.Metatags.Description = "Test creating a task to see how it works."
|
||||
|
||||
g := make(Group, 0)
|
||||
|
||||
if r.Htmx.Target != "task" {
|
||||
g = append(g, components.Message(
|
||||
"is-link",
|
||||
"",
|
||||
Group{
|
||||
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
|
||||
P(Text("See pkg/tasks and the README for more information.")),
|
||||
}))
|
||||
}
|
||||
|
||||
g = append(g, form.Render(r))
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
110
pkg/ui/request.go
Normal file
110
pkg/ui/request.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/ent"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/htmx"
|
||||
"maragu.dev/gomponents"
|
||||
)
|
||||
|
||||
type (
|
||||
// Request encapsulates information about the incoming request in order to provide your ui with important and
|
||||
// useful information needed for rendering.
|
||||
Request struct {
|
||||
// Title stores the title of the page.
|
||||
Title string
|
||||
|
||||
// Context stores the request context.
|
||||
Context echo.Context
|
||||
|
||||
// CurrentPath stores the path of the current request.
|
||||
CurrentPath string
|
||||
|
||||
// IsHome stores whether the requested page is the home page.
|
||||
IsHome bool
|
||||
|
||||
// IsAuth stores whether the user is authenticated.
|
||||
IsAuth bool
|
||||
|
||||
// AuthUser stores the authenticated user.
|
||||
AuthUser *ent.User
|
||||
|
||||
// Metatags stores metatag values.
|
||||
Metatags struct {
|
||||
// Description stores the description metatag value.
|
||||
Description string
|
||||
|
||||
// Keywords stores the keywords metatag values.
|
||||
Keywords []string
|
||||
}
|
||||
|
||||
// CSRF stores the CSRF token for the given request.
|
||||
// This will only be populated if the CSRF middleware is in effect for the given request.
|
||||
// If this is populated, all forms must include this value otherwise the requests will be rejected.
|
||||
CSRF string
|
||||
|
||||
// Htmx stores information provided by HTMX about this request.
|
||||
Htmx *htmx.Request
|
||||
|
||||
// Config stores the application configuration.
|
||||
// This will only be populated if the Config middleware is installed in the router.
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// LayoutFunc is a callback function intended to render your page node within a given layout.
|
||||
// This is handled as a callback in order to automatically support HTMX requests so that you can respond
|
||||
// with only the page content and not the entire layout.
|
||||
// See Request.Render().
|
||||
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
|
||||
)
|
||||
|
||||
// NewRequest generates a new Request using the Echo context of a given HTTP request.
|
||||
func NewRequest(ctx echo.Context) *Request {
|
||||
p := &Request{
|
||||
Context: ctx,
|
||||
CurrentPath: ctx.Request().URL.Path,
|
||||
Htmx: htmx.GetRequest(ctx),
|
||||
}
|
||||
|
||||
p.IsHome = p.CurrentPath == "/"
|
||||
|
||||
if csrf := ctx.Get(context.CSRFKey); csrf != nil {
|
||||
p.CSRF = csrf.(string)
|
||||
}
|
||||
|
||||
if u := ctx.Get(context.AuthenticatedUserKey); u != nil {
|
||||
p.IsAuth = true
|
||||
p.AuthUser = u.(*ent.User)
|
||||
}
|
||||
|
||||
if cfg := ctx.Get(context.ConfigKey); cfg != nil {
|
||||
p.Config = cfg.(*config.Config)
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Path generates a URL path for a given route name and optional route parameters.
|
||||
// This will only work if you've supplied names for each of your routes. It's optional to use and helps avoids
|
||||
// having duplicate, hard-coded paths and parameters all over your application.
|
||||
func (r *Request) Path(routeName string, routeParams ...any) string {
|
||||
return r.Context.Echo().Reverse(routeName, routeParams...)
|
||||
}
|
||||
|
||||
// Url generates an absolute URL for a given route name and optional route parameters.
|
||||
func (r *Request) Url(routeName string, routeParams ...any) string {
|
||||
return r.Config.App.Host + r.Path(routeName, routeParams...)
|
||||
}
|
||||
|
||||
// Render renders a given node, optionally within a given layout based on the HTMX request headers.
|
||||
// If the request is being made by HTMX and is not boosted, this will automatically only render the node without
|
||||
// the layout, to support partial rendering.
|
||||
func (r *Request) Render(layout LayoutFunc, node gomponents.Node) error {
|
||||
if r.Htmx.Enabled && !r.Htmx.Boosted {
|
||||
return node.Render(r.Context.Response().Writer)
|
||||
}
|
||||
|
||||
return layout(r, node).Render(r.Context.Response().Writer)
|
||||
}
|
||||
93
pkg/ui/request_test.go
Normal file
93
pkg/ui/request_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/mikestefanello/pagoda/ent"
|
||||
"github.com/mikestefanello/pagoda/pkg/context"
|
||||
"github.com/mikestefanello/pagoda/pkg/htmx"
|
||||
"github.com/mikestefanello/pagoda/pkg/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"maragu.dev/gomponents"
|
||||
"maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func TestNewRequest(t *testing.T) {
|
||||
e := echo.New()
|
||||
ctx, _ := tests.NewContext(e, "/")
|
||||
r := NewRequest(ctx)
|
||||
assert.Same(t, ctx, r.Context)
|
||||
assert.Equal(t, "/", r.CurrentPath)
|
||||
assert.True(t, r.IsHome)
|
||||
assert.False(t, r.IsAuth)
|
||||
assert.Nil(t, r.AuthUser)
|
||||
assert.Empty(t, r.CSRF)
|
||||
assert.Nil(t, r.Config)
|
||||
assert.Same(t, htmx.GetRequest(ctx), r.Htmx)
|
||||
|
||||
ctx, _ = tests.NewContext(e, "/abc")
|
||||
usr := &ent.User{
|
||||
ID: 1,
|
||||
}
|
||||
ctx.Set(context.AuthenticatedUserKey, usr)
|
||||
ctx.Set(context.CSRFKey, "12345")
|
||||
ctx.Set(context.ConfigKey, &config.Config{
|
||||
App: config.AppConfig{
|
||||
Name: "testing",
|
||||
},
|
||||
})
|
||||
r = NewRequest(ctx)
|
||||
assert.Equal(t, "/abc", r.CurrentPath)
|
||||
assert.False(t, r.IsHome)
|
||||
assert.True(t, r.IsAuth)
|
||||
assert.Equal(t, usr, r.AuthUser)
|
||||
assert.Equal(t, "12345", r.CSRF)
|
||||
assert.Equal(t, "testing", r.Config.App.Name)
|
||||
}
|
||||
|
||||
func TestRequest_UrlPath(t *testing.T) {
|
||||
e := echo.New()
|
||||
e.GET("/abc/:id", func(c echo.Context) error { return nil }).Name = "test"
|
||||
ctx, _ := tests.NewContext(e, "/")
|
||||
r := NewRequest(ctx)
|
||||
r.Config = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Host: "http://localhost",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "http://localhost/abc/123", r.Url("test", 123))
|
||||
assert.Equal(t, "/abc/123", r.Path("test", 123))
|
||||
}
|
||||
|
||||
func TestRequest_Render(t *testing.T) {
|
||||
e := echo.New()
|
||||
layout := func(r *Request, n gomponents.Node) gomponents.Node {
|
||||
return html.Div(html.Class("test"), n)
|
||||
}
|
||||
node := html.P(gomponents.Text("hello"))
|
||||
|
||||
t.Run("no htmx", func(t *testing.T) {
|
||||
ctx, rec := tests.NewContext(e, "/")
|
||||
r := NewRequest(ctx)
|
||||
r.Htmx = &htmx.Request{}
|
||||
err := r.Render(layout, node)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `<div class="test"><p>hello</p></div>`, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("htmx", func(t *testing.T) {
|
||||
ctx, rec := tests.NewContext(e, "/")
|
||||
r := NewRequest(ctx)
|
||||
r.Htmx = &htmx.Request{
|
||||
Enabled: true,
|
||||
Boosted: false,
|
||||
}
|
||||
err := r.Render(layout, node)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `<p>hello</p>`, rec.Body.String())
|
||||
})
|
||||
}
|
||||
18
pkg/ui/ui.go
Normal file
18
pkg/ui/ui.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// cacheBuster stores the current time as a cache buster for static files.
|
||||
cacheBuster = fmt.Sprint(time.Now().Unix())
|
||||
)
|
||||
|
||||
// File generates a relative URL to a static file including a cache-buster query parameter.
|
||||
func File(filepath string) string {
|
||||
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
|
||||
}
|
||||
16
pkg/ui/ui_test.go
Normal file
16
pkg/ui/ui_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mikestefanello/pagoda/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
path := "abc.txt"
|
||||
got := File(path)
|
||||
expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue