Added a basic homepage
This commit is contained in:
parent
d40640a648
commit
12fd3c04ca
113 changed files with 414 additions and 506 deletions
69
internal/ui/cache/cache.go
vendored
Normal file
69
internal/ui/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/log"
|
||||
"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 {
|
||||
log.Default().Error("failed to cache ui node",
|
||||
"error", err,
|
||||
"key", key,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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
internal/ui/cache/cache_test.go
vendored
Normal file
57
internal/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)
|
||||
}
|
||||
66
internal/ui/components/alerts.go
Normal file
66
internal/ui/components/alerts.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/camzawacki/personal-site/internal/msg"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/ui/icons"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func FlashMessages(r *ui.Request) Node {
|
||||
var g Group
|
||||
var color Color
|
||||
|
||||
for _, typ := range []msg.Type{
|
||||
msg.TypeSuccess,
|
||||
msg.TypeInfo,
|
||||
msg.TypeWarning,
|
||||
msg.TypeError,
|
||||
} {
|
||||
for _, str := range msg.Get(r.Context, typ) {
|
||||
switch typ {
|
||||
case msg.TypeSuccess:
|
||||
color = ColorSuccess
|
||||
case msg.TypeInfo:
|
||||
color = ColorInfo
|
||||
case msg.TypeWarning:
|
||||
color = ColorWarning
|
||||
case msg.TypeError:
|
||||
color = ColorError
|
||||
}
|
||||
|
||||
g = append(g, Alert(color, str))
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func Alert(color Color, text string) Node {
|
||||
var class string
|
||||
|
||||
switch color {
|
||||
case ColorSuccess:
|
||||
class = "alert-success"
|
||||
case ColorInfo:
|
||||
class = "alert-info"
|
||||
case ColorWarning:
|
||||
class = "alert-warning"
|
||||
case ColorError:
|
||||
class = "alert-error"
|
||||
}
|
||||
|
||||
return Div(
|
||||
Role("alert"),
|
||||
Class("alert mb-2 "+class),
|
||||
Attr("x-data", "{show: true}"),
|
||||
Attr("x-show", "show"),
|
||||
Span(
|
||||
Attr("@click", "show = false"),
|
||||
Class("cursor-pointer"),
|
||||
icons.XCircle(),
|
||||
),
|
||||
Span(Text(text)),
|
||||
)
|
||||
}
|
||||
121
internal/ui/components/data.go
Normal file
121
internal/ui/components/data.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type (
|
||||
CardParams struct {
|
||||
Title string
|
||||
Body Group
|
||||
Footer Group
|
||||
Color Color
|
||||
Size Size
|
||||
}
|
||||
|
||||
Stat struct {
|
||||
Title string
|
||||
Value string
|
||||
Description string
|
||||
Icon Node
|
||||
}
|
||||
)
|
||||
|
||||
func Badge(color Color, text string) Node {
|
||||
var class string
|
||||
|
||||
switch color {
|
||||
case ColorSuccess:
|
||||
class = "badge-success"
|
||||
case ColorWarning:
|
||||
class = "badge-warning"
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("badge "+class),
|
||||
Text(text),
|
||||
)
|
||||
}
|
||||
|
||||
func Divider(text string) Node {
|
||||
return Div(
|
||||
Class("divider"),
|
||||
Text(text),
|
||||
)
|
||||
}
|
||||
|
||||
func Card(params CardParams) Node {
|
||||
var colorClass, sizeClass string
|
||||
|
||||
switch params.Color {
|
||||
case ColorSuccess:
|
||||
colorClass = "bg-success text-success-content"
|
||||
case ColorPrimary:
|
||||
colorClass = "bg-primary text-primary-content"
|
||||
case ColorAccent:
|
||||
colorClass = "bg-accent text-accent-content"
|
||||
case ColorNeutral:
|
||||
colorClass = "bg-neutral text-neutral-content"
|
||||
case ColorWarning:
|
||||
colorClass = "bg-warning text-warning-content"
|
||||
case ColorInfo:
|
||||
colorClass = "bg-info text-info-content"
|
||||
}
|
||||
|
||||
switch params.Size {
|
||||
case SizeSmall:
|
||||
sizeClass = "card-sm"
|
||||
case SizeMedium:
|
||||
sizeClass = "card-md"
|
||||
case SizeLarge:
|
||||
sizeClass = "card-lg"
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("cards mb-2 "+colorClass+" "+sizeClass),
|
||||
Div(
|
||||
Class("card-body"),
|
||||
If(len(params.Title) > 0, Span(
|
||||
Class("card-title"),
|
||||
Text(params.Title),
|
||||
)),
|
||||
params.Body,
|
||||
If(params.Footer != nil, Div(
|
||||
Class("card-actions justify-end"),
|
||||
params.Footer,
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Stats(stats ...Stat) Node {
|
||||
g := make(Group, 0, len(stats))
|
||||
for _, stat := range stats {
|
||||
g = append(g, Div(
|
||||
Class("stat"),
|
||||
Iff(stat.Icon != nil, func() Node {
|
||||
return Div(
|
||||
Class("stat-figure text-secondary"),
|
||||
stat.Icon,
|
||||
)
|
||||
}),
|
||||
Div(
|
||||
Class("stat-title"),
|
||||
Text(stat.Title),
|
||||
),
|
||||
Div(
|
||||
Class("stat-value"),
|
||||
Text(stat.Value),
|
||||
),
|
||||
Div(
|
||||
Class("stat-desc"),
|
||||
Text(stat.Description),
|
||||
),
|
||||
))
|
||||
}
|
||||
return Div(
|
||||
Class("stats shadow"),
|
||||
g,
|
||||
)
|
||||
}
|
||||
268
internal/ui/components/form.go
Normal file
268
internal/ui/components/form.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/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
|
||||
}
|
||||
|
||||
FileFieldParams struct {
|
||||
Name string
|
||||
Label string
|
||||
Help string
|
||||
}
|
||||
|
||||
OptionsParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
Label string
|
||||
Value string
|
||||
Options []Choice
|
||||
Help string
|
||||
}
|
||||
|
||||
Choice struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
TextareaFieldParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
Label string
|
||||
Value string
|
||||
Help string
|
||||
}
|
||||
|
||||
CheckboxParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
Name string
|
||||
Label string
|
||||
Checked bool
|
||||
}
|
||||
)
|
||||
|
||||
func ControlGroup(controls ...Node) Node {
|
||||
return Div(
|
||||
Class("mt-2 flex gap-2"),
|
||||
Group(controls),
|
||||
)
|
||||
}
|
||||
|
||||
func TextareaField(el TextareaFieldParams) Node {
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Textarea(
|
||||
Class("textarea h-24 w-2/3 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Text(el.Value),
|
||||
),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func Radios(el OptionsParams) Node {
|
||||
buttons := make(Group, len(el.Options))
|
||||
for i, opt := range el.Options {
|
||||
id := "radio-" + el.Name + "-" + opt.Value
|
||||
buttons[i] = Div(
|
||||
Class("mb-2"),
|
||||
Input(
|
||||
ID(id),
|
||||
Type("radio"),
|
||||
Name(el.Name),
|
||||
Value(opt.Value),
|
||||
Class("radio mr-1 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
If(el.Value == opt.Value, Checked()),
|
||||
),
|
||||
Label(
|
||||
Text(opt.Label),
|
||||
For(id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
buttons,
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func SelectList(el OptionsParams) Node {
|
||||
buttons := make(Group, len(el.Options))
|
||||
for i, opt := range el.Options {
|
||||
buttons[i] = Option(
|
||||
Text(opt.Label),
|
||||
Value(opt.Value),
|
||||
If(opt.Value == el.Value, Attr("selected")),
|
||||
)
|
||||
}
|
||||
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Select(
|
||||
Class("select "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Name(el.Name),
|
||||
buttons,
|
||||
),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func Checkbox(el CheckboxParams) Node {
|
||||
return Div(
|
||||
Label(
|
||||
Class("label"),
|
||||
Input(
|
||||
Class("checkbox"),
|
||||
Type("checkbox"),
|
||||
Name(el.Name),
|
||||
If(el.Checked, Checked()),
|
||||
Value("true"),
|
||||
),
|
||||
Text(" "+el.Label),
|
||||
),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func InputField(el InputFieldParams) Node {
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Input(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Type(el.InputType),
|
||||
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Value(el.Value),
|
||||
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
||||
),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func Help(text string) Node {
|
||||
return If(len(text) > 0, Div(
|
||||
Class("label"),
|
||||
Text(text),
|
||||
))
|
||||
}
|
||||
|
||||
func Fieldset(label string, els ...Node) Node {
|
||||
return FieldSet(
|
||||
Class("fieldset"),
|
||||
If(len(label) > 0, Legend(
|
||||
Class("fieldset-legend"),
|
||||
Text(label),
|
||||
)),
|
||||
Group(els),
|
||||
)
|
||||
}
|
||||
|
||||
func FileField(el FileFieldParams) Node {
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Input(
|
||||
Type("file"),
|
||||
Class("file-input"),
|
||||
Name(el.Name),
|
||||
),
|
||||
Help(el.Help),
|
||||
)
|
||||
}
|
||||
|
||||
func formFieldStatusClass(fm form.Form, formField string) string {
|
||||
switch {
|
||||
case fm == nil:
|
||||
return ""
|
||||
case !fm.IsSubmitted():
|
||||
return ""
|
||||
case fm.FieldHasErrors(formField):
|
||||
return "input-error"
|
||||
default:
|
||||
return "input-success"
|
||||
}
|
||||
}
|
||||
|
||||
func formFieldErrors(fm form.Form, field string) Node {
|
||||
if fm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := fm.GetFieldErrors(field)
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
g := make(Group, len(errs))
|
||||
for i, err := range errs {
|
||||
g[i] = Div(
|
||||
Class("text-error"),
|
||||
Text(err),
|
||||
)
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func CSRF(r *ui.Request) Node {
|
||||
return Input(
|
||||
Type("hidden"),
|
||||
Name("csrf"),
|
||||
Value(r.CSRF),
|
||||
)
|
||||
}
|
||||
|
||||
func FormButton(color Color, label string) Node {
|
||||
return Button(
|
||||
Class("btn "+buttonColor(color)),
|
||||
Text(label),
|
||||
)
|
||||
}
|
||||
|
||||
func ButtonLink(color Color, href, label string) Node {
|
||||
return A(
|
||||
Href(href),
|
||||
Class("btn "+buttonColor(color)),
|
||||
Text(label),
|
||||
)
|
||||
}
|
||||
|
||||
func buttonColor(color Color) string {
|
||||
// Only colors being used are included so unused styles are not compiled.
|
||||
switch color {
|
||||
case ColorPrimary:
|
||||
return "btn-primary"
|
||||
case ColorInfo:
|
||||
return "btn-info"
|
||||
case ColorAccent:
|
||||
return "btn-accent"
|
||||
case ColorError:
|
||||
return "btn-error"
|
||||
case ColorLink:
|
||||
return "btn-link"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
35
internal/ui/components/head.go
Normal file
35
internal/ui/components/head.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func JS() Node {
|
||||
return Group{
|
||||
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"), Defer()),
|
||||
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
|
||||
}
|
||||
}
|
||||
|
||||
func CSS() Node {
|
||||
return Link(
|
||||
Href(ui.StaticFile("main.css")),
|
||||
Rel("stylesheet"),
|
||||
Type("text/css"),
|
||||
)
|
||||
}
|
||||
|
||||
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.StaticFile("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, ", ")))),
|
||||
}
|
||||
}
|
||||
39
internal/ui/components/htmx.go
Normal file
39
internal/ui/components/htmx.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func HtmxListeners(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';
|
||||
}
|
||||
})
|
||||
`
|
||||
|
||||
return Group{
|
||||
Script(Raw(htmxErr)),
|
||||
Iff(len(r.CSRF) > 0, func() Node {
|
||||
return Script(Raw(fmt.Sprintf(htmxCSRF, r.CSRF)))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func HxBoost() Node {
|
||||
return Attr("hx-boost", "true")
|
||||
}
|
||||
91
internal/ui/components/nav.go
Normal file
91
internal/ui/components/nav.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/pager"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/components"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func NavLink(r *ui.Request, title, routeName string, disabled bool, routeParams ...any) Node {
|
||||
href := r.Path(routeName, routeParams...)
|
||||
var link Node
|
||||
if disabled {
|
||||
link = Span(
|
||||
Class("text-xl text-base-content/40"),
|
||||
Text(title),
|
||||
)
|
||||
} else {
|
||||
link = A(
|
||||
Class("text-xl hover:underline cursor-pointer"),
|
||||
Href(href),
|
||||
Text(title),
|
||||
)
|
||||
}
|
||||
return link
|
||||
|
||||
}
|
||||
|
||||
func MenuLink(r *ui.Request, icon Node, title, routeName string, routeParams ...any) Node {
|
||||
href := r.Path(routeName, routeParams...)
|
||||
|
||||
return Li(
|
||||
Class("ml-2"),
|
||||
A(
|
||||
Href(href),
|
||||
icon,
|
||||
Text(title),
|
||||
Classes{
|
||||
"menu-active": href == r.CurrentPath,
|
||||
"p-2": true,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Pager(page int, path string, hasNext bool, hxTarget string) Node {
|
||||
href := func(page int) string {
|
||||
return fmt.Sprintf("%s?%s=%d",
|
||||
path,
|
||||
pager.QueryKey,
|
||||
page,
|
||||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("join"),
|
||||
A(
|
||||
Class("join-item btn"),
|
||||
Text("«"),
|
||||
If(page <= 1, Disabled()),
|
||||
Href(href(page-1)),
|
||||
Iff(len(hxTarget) > 0, func() Node {
|
||||
return Group{
|
||||
Attr("hx-get", href(page-1)),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-target", hxTarget),
|
||||
}
|
||||
}),
|
||||
),
|
||||
Button(
|
||||
Class("join-item btn"),
|
||||
Textf("Page %d", page),
|
||||
),
|
||||
A(
|
||||
Class("join-item btn"),
|
||||
Text("»"),
|
||||
If(!hasNext, Disabled()),
|
||||
Href(href(page+1)),
|
||||
Iff(len(hxTarget) > 0, func() Node {
|
||||
return Group{
|
||||
Attr("hx-get", href(page+1)),
|
||||
Attr("hx-swap", "outerHTML"),
|
||||
Attr("hx-target", hxTarget),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
27
internal/ui/components/styles.go
Normal file
27
internal/ui/components/styles.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package components
|
||||
|
||||
type (
|
||||
Color int
|
||||
Size int
|
||||
)
|
||||
|
||||
const (
|
||||
ColorNone Color = iota
|
||||
ColorNeutral
|
||||
ColorPrimary
|
||||
ColorSecondary
|
||||
ColorAccent
|
||||
ColorInfo
|
||||
ColorSuccess
|
||||
ColorWarning
|
||||
ColorError
|
||||
ColorLink
|
||||
)
|
||||
|
||||
const (
|
||||
SizeExtraSmall Size = iota
|
||||
SizeSmall
|
||||
SizeMedium
|
||||
SizeLarge
|
||||
SizeExtraLarge
|
||||
)
|
||||
38
internal/ui/components/tabs.go
Normal file
38
internal/ui/components/tabs.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type Tab struct {
|
||||
Title, Body string
|
||||
}
|
||||
|
||||
func Tabs(tabs []Tab) Node {
|
||||
g := make(Group, 0, len(tabs)*2)
|
||||
id := fmt.Sprintf("tabs-%d", rand.Int())
|
||||
|
||||
for i, tab := range tabs {
|
||||
g = append(g,
|
||||
Input(
|
||||
Type("radio"),
|
||||
Name(id),
|
||||
Class("tab"),
|
||||
Aria("label", tab.Title),
|
||||
If(i == 0, Checked()),
|
||||
),
|
||||
Div(
|
||||
Class("tab-content bg-base-100 border-base-300 p-6"),
|
||||
Raw(tab.Body),
|
||||
))
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("tabs tabs-lift"),
|
||||
g,
|
||||
)
|
||||
}
|
||||
22
internal/ui/emails/auth.go
Normal file
22
internal/ui/emails/auth.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package emails
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/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)),
|
||||
}
|
||||
}
|
||||
124
internal/ui/forms/admin_entity.go
Normal file
124
internal/ui/forms/admin_entity.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/camzawacki/personal-site/ent/admin"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func AdminEntity(r *ui.Request, entityType admin.EntityType, values url.Values) Node {
|
||||
// TODO inline validation?
|
||||
isNew := values == nil
|
||||
nodes := make(Group, 0)
|
||||
|
||||
getValue := func(name string) string {
|
||||
// Values in the submitted form take precedence.
|
||||
if value := r.Context.FormValue(name); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
// Fallback to the entity's values, if being edited.
|
||||
if values != nil && len(values[name]) > 0 {
|
||||
return values[name][0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Attempt to add form elements for all editable entity fields.
|
||||
for _, f := range entityType.GetSchema() {
|
||||
// TODO cardinality?
|
||||
if !isNew && f.Immutable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch f.Type {
|
||||
case field.TypeString:
|
||||
p := InputFieldParams{
|
||||
Name: f.Name,
|
||||
InputType: "text",
|
||||
Label: admin.FieldLabel(f.Name),
|
||||
Value: getValue(f.Name),
|
||||
}
|
||||
|
||||
if f.Sensitive {
|
||||
p.InputType = "password"
|
||||
if !isNew {
|
||||
p.Placeholder = "*****"
|
||||
p.Help = "SENSITIVE: This field will only be updated if a value is provided."
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, InputField(p))
|
||||
|
||||
case field.TypeTime:
|
||||
nodes = append(nodes, InputField(InputFieldParams{
|
||||
Name: f.Name,
|
||||
InputType: "datetime-local",
|
||||
Label: admin.FieldLabel(f.Name),
|
||||
Value: getValue(f.Name),
|
||||
}))
|
||||
|
||||
case field.TypeInt, field.TypeInt8, field.TypeInt16, field.TypeInt32, field.TypeInt64,
|
||||
field.TypeUint, field.TypeUint8, field.TypeUint16, field.TypeUint32, field.TypeUint64,
|
||||
field.TypeFloat32, field.TypeFloat64:
|
||||
nodes = append(nodes, InputField(InputFieldParams{
|
||||
Name: f.Name,
|
||||
InputType: "number",
|
||||
Label: admin.FieldLabel(f.Name),
|
||||
Value: getValue(f.Name),
|
||||
}))
|
||||
|
||||
case field.TypeBool:
|
||||
nodes = append(nodes, Checkbox(CheckboxParams{
|
||||
Name: f.Name,
|
||||
Label: admin.FieldLabel(f.Name),
|
||||
Checked: getValue(f.Name) == "true",
|
||||
}))
|
||||
|
||||
case field.TypeEnum:
|
||||
options := make([]Choice, 0, len(f.Enums)+1)
|
||||
if f.Optional {
|
||||
options = append(options, Choice{
|
||||
Label: "-",
|
||||
Value: "",
|
||||
})
|
||||
}
|
||||
for _, enum := range f.Enums {
|
||||
options = append(options, Choice{
|
||||
Label: enum,
|
||||
Value: enum,
|
||||
})
|
||||
}
|
||||
nodes = append(nodes, SelectList(OptionsParams{
|
||||
Name: f.Name,
|
||||
Label: admin.FieldLabel(f.Name),
|
||||
Value: getValue(f.Name),
|
||||
Options: options,
|
||||
}))
|
||||
|
||||
default:
|
||||
nodes = append(nodes, P(Textf("%s not supported", f.Name)))
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
Method(http.MethodPost),
|
||||
nodes,
|
||||
ControlGroup(
|
||||
FormButton(ColorPrimary, "Submit"),
|
||||
ButtonLink(
|
||||
ColorNone,
|
||||
r.Path(routenames.AdminEntityList(entityType.GetName())),
|
||||
"Cancel",
|
||||
),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
30
internal/ui/forms/admin_entity_delete.go
Normal file
30
internal/ui/forms/admin_entity_delete.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/ent/admin"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func AdminEntityDelete(r *ui.Request, entityType admin.EntityType) Node {
|
||||
return Form(
|
||||
Method(http.MethodPost),
|
||||
P(
|
||||
Textf("Are you sure you want to delete this %s?", entityType.GetName()),
|
||||
),
|
||||
ControlGroup(
|
||||
FormButton(ColorError, "Delete"),
|
||||
ButtonLink(
|
||||
ColorNone,
|
||||
r.Path(routenames.AdminEntityList(entityType.GetName())),
|
||||
"Cancel",
|
||||
),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
54
internal/ui/forms/cache.go
Normal file
54
internal/ui/forms/cache.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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)),
|
||||
Card(CardParams{
|
||||
Title: "Test the cache",
|
||||
Body: Group{
|
||||
Span(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.")),
|
||||
Span(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
|
||||
},
|
||||
Color: ColorInfo,
|
||||
Size: SizeMedium,
|
||||
}),
|
||||
Label(
|
||||
For("value"),
|
||||
Class("value"),
|
||||
Text("Value in cache: "),
|
||||
),
|
||||
If(f.CurrentValue != "", Badge(ColorSuccess, f.CurrentValue)),
|
||||
If(f.CurrentValue == "", Badge(ColorWarning, "empty")),
|
||||
InputField(InputFieldParams{
|
||||
Form: f,
|
||||
FormField: "Value",
|
||||
Name: "value",
|
||||
InputType: "text",
|
||||
Label: "Value",
|
||||
Value: f.Value,
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton(ColorPrimary, "Update cache"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
58
internal/ui/forms/contact.go
Normal file
58
internal/ui/forms/contact.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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(OptionsParams{
|
||||
Form: f,
|
||||
FormField: "Department",
|
||||
Name: "department",
|
||||
Label: "Department",
|
||||
Value: f.Department,
|
||||
Options: []Choice{
|
||||
{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(ColorPrimary, "Submit"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
31
internal/ui/forms/file.go
Normal file
31
internal/ui/forms/file.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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(FileFieldParams{
|
||||
Name: "file",
|
||||
Label: "Test file",
|
||||
Help: "Pick a file to upload.",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton(ColorPrimary, "Upload"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
39
internal/ui/forms/forgot_password.go
Normal file
39
internal/ui/forms/forgot_password.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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(ColorPrimary, "Reset password"),
|
||||
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
64
internal/ui/forms/login.go
Normal file
64
internal/ui/forms/login.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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: "******",
|
||||
}),
|
||||
Div(
|
||||
Class("text-right text-primary mt-2"),
|
||||
A(
|
||||
Href(r.Path(routenames.ForgotPassword)),
|
||||
Text("Forgot password?"),
|
||||
),
|
||||
),
|
||||
ControlGroup(
|
||||
FormButton(ColorPrimary, "Login"),
|
||||
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
Div(
|
||||
Class("text-center text-base-content/50 mt-4"),
|
||||
Text("Don't have an account? "),
|
||||
A(
|
||||
Href(r.Path(routenames.Register)),
|
||||
Text("Register"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
74
internal/ui/forms/register.go
Normal file
74
internal/ui/forms/register.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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: "ConfirmPassword",
|
||||
Name: "password-confirm",
|
||||
InputType: "password",
|
||||
Label: "Confirm password",
|
||||
Placeholder: "******",
|
||||
}),
|
||||
ControlGroup(
|
||||
FormButton(ColorPrimary, "Register"),
|
||||
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||
),
|
||||
CSRF(r),
|
||||
Div(
|
||||
Class("text-center text-base-content/50 mt-4"),
|
||||
Text("Already have an account? "),
|
||||
A(
|
||||
Href(r.Path(routenames.Login)),
|
||||
Text("Login"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
46
internal/ui/forms/reset_password.go
Normal file
46
internal/ui/forms/reset_password.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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(ColorPrimary, "Update password"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
49
internal/ui/forms/task.go
Normal file
49
internal/ui/forms/task.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package forms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/form"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/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(ColorPrimary, "Add task to queue"),
|
||||
),
|
||||
CSRF(r),
|
||||
)
|
||||
}
|
||||
208
internal/ui/icons/icons.go
Normal file
208
internal/ui/icons/icons.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package icons
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/ui/cache"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func CircleStack() Node {
|
||||
return icon("CircleStack",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Eyes() Node {
|
||||
return icon("Eyes",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"),
|
||||
),
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func UserCircle() Node {
|
||||
return icon("UserCircle",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Globe() Node {
|
||||
return icon("Globe",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Home() Node {
|
||||
return icon("Home",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Info() Node {
|
||||
return icon("Info",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Mail() Node {
|
||||
return icon("Mail",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Archive() Node {
|
||||
return icon("Archive",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func PencilSquare() Node {
|
||||
return icon("PencilSquare",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Document() Node {
|
||||
return icon("Document",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Exit() Node {
|
||||
return icon("Exit",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Enter() Node {
|
||||
return icon("Enter",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func UserPlus() Node {
|
||||
return icon("UserPlus",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func QuestionCircle() Node {
|
||||
return icon("QuestionCircle",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func XCircle() Node {
|
||||
return icon("XCircle",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func MagnifyingGlass() Node {
|
||||
return icon("MagnifyingGlass",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func LockClosed() Node {
|
||||
return icon("LockClosed",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Star() Node {
|
||||
return icon("Star",
|
||||
El("path",
|
||||
Attr("stroke-linecap", "round"),
|
||||
Attr("stroke-linejoin", "round"),
|
||||
Attr("d", "M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func icon(id string, els ...Node) Node {
|
||||
return cache.SetIfNotExists(fmt.Sprintf("icon.%s", id), func() Node {
|
||||
return SVG(
|
||||
Attr("xmlns", "http://www.w3.org/2000/svg"),
|
||||
Attr("fill", "none"),
|
||||
Attr("viewBox", "0 0 24 24"),
|
||||
Attr("stroke-width", "1.5"),
|
||||
Attr("stroke", "currentColor"),
|
||||
Class("w-5 h-5"),
|
||||
Group(els),
|
||||
)
|
||||
})
|
||||
}
|
||||
40
internal/ui/layouts/auth.go
Normal file
40
internal/ui/layouts/auth.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Auth(r *ui.Request, content Node) Node {
|
||||
return Doctype(
|
||||
HTML(
|
||||
Lang("en"),
|
||||
Data("theme", "dark"),
|
||||
Head(
|
||||
Metatags(r),
|
||||
CSS(),
|
||||
JS(),
|
||||
),
|
||||
Body(
|
||||
Div(
|
||||
Class("hero flex items-center justify-center min-h-screen"),
|
||||
Div(
|
||||
Class("flex-col hero-content"),
|
||||
Div(
|
||||
Class("card shadow-md bg-base-200 w-96"),
|
||||
Div(
|
||||
Class("card-body"),
|
||||
If(len(r.Title) > 0, H1(Class("text-2xl font-bold"), Text(r.Title))),
|
||||
FlashMessages(r),
|
||||
content,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
HtmxListeners(r),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
42
internal/ui/layouts/primary.go
Normal file
42
internal/ui/layouts/primary.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func Primary(r *ui.Request, content Node) Node {
|
||||
return Doctype(
|
||||
HTML(
|
||||
Lang("en"),
|
||||
Data("theme", "light"),
|
||||
Head(
|
||||
Metatags(r),
|
||||
CSS(),
|
||||
JS(),
|
||||
),
|
||||
Body(
|
||||
Nav(
|
||||
Class("navbar bg-base-100 border-b border-gray-200 p-5 justify-center"),
|
||||
Div(
|
||||
Class("flex items-center"),
|
||||
NavLink(r, "Cam Zalewaki", routenames.Home, false),
|
||||
Span(Class("divider divider-horizontal")),
|
||||
NavLink(r, "Writing", routenames.About, true),
|
||||
Span(Class("divider divider-horizontal")),
|
||||
NavLink(r, "Projects", routenames.About, true),
|
||||
Span(Class("divider divider-horizontal")),
|
||||
NavLink(r, "Misc", routenames.About, true),
|
||||
Span(Class("divider divider-horizontal")),
|
||||
NavLink(r, "About", routenames.About, true),
|
||||
),
|
||||
),
|
||||
content,
|
||||
HtmxListeners(r),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
internal/ui/models/file.go
Normal file
22
internal/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)),
|
||||
)
|
||||
}
|
||||
67
internal/ui/models/post.go
Normal file
67
internal/ui/models/post.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/camzawacki/personal-site/internal/pager"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type (
|
||||
Posts struct {
|
||||
Posts []Post
|
||||
Pager pager.Pager
|
||||
}
|
||||
|
||||
Post struct {
|
||||
ID int
|
||||
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"),
|
||||
Ul(
|
||||
Class("list bg-base-100 rounded-box shadow-md not-prose"),
|
||||
g,
|
||||
),
|
||||
Div(Class("mb-4")),
|
||||
Pager(p.Pager.Page, path, !p.Pager.IsEnd(), "#posts"),
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Post) Render() Node {
|
||||
return Li(
|
||||
Class("list-row"),
|
||||
Div(
|
||||
Class("text-4xl font-thin opacity-30 tabular-nums"),
|
||||
Text(fmt.Sprintf("%02d", p.ID)),
|
||||
),
|
||||
Div(
|
||||
Img(
|
||||
Class("size-10 rounded-box"),
|
||||
Src(ui.StaticFile("gopher.png")),
|
||||
Alt("Gopher"),
|
||||
),
|
||||
),
|
||||
Div(
|
||||
Class("list-col-grow"),
|
||||
Div(
|
||||
Text(p.Title),
|
||||
),
|
||||
Div(
|
||||
Class("text-xs font-semibold opacity-60"),
|
||||
Text(p.Body),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
21
internal/ui/models/search_result.go
Normal file
21
internal/ui/models/search_result.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (s *SearchResult) Render() Node {
|
||||
return Li(
|
||||
Class("list-row"),
|
||||
A(
|
||||
Href(s.URL),
|
||||
Text(s.Title),
|
||||
),
|
||||
)
|
||||
}
|
||||
61
internal/ui/pages/about.go
Normal file
61
internal/ui/pages/about.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/ui/cache"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
"github.com/camzawacki/personal-site/internal/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{
|
||||
H2(Text("Frontend")),
|
||||
P(Text("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.")),
|
||||
Tabs(
|
||||
[]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: "DaisyUI",
|
||||
Body: "DaisyUI is the Tailwind CSS plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript requirements. Visit <a href=\"https://daisyui.com/\">daisyui.com</a> to learn more.",
|
||||
},
|
||||
},
|
||||
),
|
||||
H2(Text("Backend")),
|
||||
P(Text("The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.")),
|
||||
Tabs(
|
||||
[]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.",
|
||||
},
|
||||
{
|
||||
Title: "Gomponents",
|
||||
Body: "HTML components written in pure Go. They render to HTML 5, and make it easy for you to build reusable components. Visit <a href=\"https://gomponents.com/\">gomponents.com</a> to learn more.",
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return r.Render(layouts.Primary, tabs)
|
||||
}
|
||||
115
internal/ui/pages/admin_entity.go
Normal file
115
internal/ui/pages/admin_entity.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/ent/admin"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/ui/layouts"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func AdminEntityDelete(ctx echo.Context, entityType admin.EntityType) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = fmt.Sprintf("Delete %s", entityType.GetName())
|
||||
|
||||
return r.Render(
|
||||
layouts.Primary,
|
||||
forms.AdminEntityDelete(r, entityType),
|
||||
)
|
||||
}
|
||||
|
||||
func AdminEntityInput(ctx echo.Context, entityType admin.EntityType, values url.Values) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
if values == nil {
|
||||
r.Title = fmt.Sprintf("Add %s", entityType.GetName())
|
||||
} else {
|
||||
r.Title = fmt.Sprintf("Edit %s", entityType.GetName())
|
||||
}
|
||||
|
||||
return r.Render(
|
||||
layouts.Primary,
|
||||
forms.AdminEntity(r, entityType, values),
|
||||
)
|
||||
}
|
||||
|
||||
func AdminEntityList(
|
||||
ctx echo.Context,
|
||||
entityType admin.EntityType,
|
||||
entityList *admin.EntityList,
|
||||
) error {
|
||||
r := ui.NewRequest(ctx)
|
||||
r.Title = entityType.GetName()
|
||||
|
||||
genHeader := func() Node {
|
||||
g := make(Group, 0, len(entityList.Columns)+2)
|
||||
g = append(g, Th(Text("ID")))
|
||||
for _, h := range entityList.Columns {
|
||||
g = append(g, Th(Text(h)))
|
||||
}
|
||||
g = append(g, Th())
|
||||
return g
|
||||
}
|
||||
|
||||
genRow := func(row admin.EntityValues) Node {
|
||||
g := make(Group, 0, len(row.Values)+3)
|
||||
g = append(g, Th(Text(fmt.Sprint(row.ID))))
|
||||
for _, h := range row.Values {
|
||||
g = append(g, Td(Text(h)))
|
||||
}
|
||||
g = append(g,
|
||||
Td(
|
||||
ButtonLink(
|
||||
ColorInfo,
|
||||
r.Path(routenames.AdminEntityEdit(entityType.GetName()), row.ID),
|
||||
"Edit",
|
||||
),
|
||||
Span(Class("mr-2")),
|
||||
ButtonLink(
|
||||
ColorError,
|
||||
r.Path(routenames.AdminEntityDelete(entityType.GetName()), row.ID),
|
||||
"Delete",
|
||||
),
|
||||
),
|
||||
)
|
||||
return g
|
||||
}
|
||||
|
||||
genRows := func() Node {
|
||||
g := make(Group, 0, len(entityList.Entities))
|
||||
for _, row := range entityList.Entities {
|
||||
g = append(g, Tr(genRow(row)))
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, Group{
|
||||
Div(
|
||||
Class("form-control mb-2"),
|
||||
ButtonLink(
|
||||
ColorAccent,
|
||||
r.Path(routenames.AdminEntityAdd(entityType.GetName())),
|
||||
fmt.Sprintf("Add %s", entityType.GetName()),
|
||||
),
|
||||
),
|
||||
Table(
|
||||
Class("table table-zebra mb-2"),
|
||||
THead(
|
||||
Tr(genHeader()),
|
||||
),
|
||||
TBody(genRows()),
|
||||
),
|
||||
Pager(
|
||||
entityList.Page,
|
||||
r.Path(routenames.AdminEntityAdd(entityType.GetName())),
|
||||
entityList.HasNextPage,
|
||||
"",
|
||||
),
|
||||
})
|
||||
}
|
||||
46
internal/ui/pages/auth.go
Normal file
46
internal/ui/pages/auth.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/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
internal/ui/pages/cache.go
Normal file
15
internal/ui/pages/cache.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/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))
|
||||
}
|
||||
46
internal/ui/pages/contact.go
Normal file
46
internal/ui/pages/contact.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/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 := Group{
|
||||
Iff(r.Htmx.Target != "contact", func() Node {
|
||||
return Card(CardParams{
|
||||
Title: "Card component",
|
||||
Body: Group{
|
||||
Span(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.")),
|
||||
Span(Text("Only the form below will update async upon submission.")),
|
||||
},
|
||||
Color: ColorWarning,
|
||||
Size: SizeMedium,
|
||||
})
|
||||
}),
|
||||
Iff(form.IsDone(), func() Node {
|
||||
return Card(CardParams{
|
||||
Title: "Thank you!",
|
||||
Body: Group{
|
||||
Span(Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled.")),
|
||||
},
|
||||
Color: ColorSuccess,
|
||||
Size: SizeLarge,
|
||||
})
|
||||
}),
|
||||
Iff(!form.IsDone(), func() Node {
|
||||
return form.Render(r)
|
||||
}),
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
38
internal/ui/pages/error.go
Normal file
38
internal/ui/pages/error.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/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
internal/ui/pages/file.go
Normal file
53
internal/ui/pages/file.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/ui/layouts"
|
||||
"github.com/camzawacki/personal-site/internal/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{
|
||||
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.")),
|
||||
Divider(""),
|
||||
forms.File{}.Render(r),
|
||||
Divider(""),
|
||||
H3(
|
||||
Class("title"),
|
||||
Text("Uploaded files"),
|
||||
),
|
||||
Card(CardParams{
|
||||
Body: Group{Text("Below are all files in the configured upload directory.")},
|
||||
Color: ColorWarning,
|
||||
Size: SizeMedium,
|
||||
}),
|
||||
Table(
|
||||
Class("table"),
|
||||
THead(
|
||||
Tr(
|
||||
Th(Text("Filename")),
|
||||
Th(Text("Size")),
|
||||
Th(Text("Modified on")),
|
||||
),
|
||||
),
|
||||
TBody(
|
||||
fileList,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, n)
|
||||
}
|
||||
63
internal/ui/pages/home.go
Normal file
63
internal/ui/pages/home.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
// "github.com/camzawacki/personal-site/internal/routenames"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
// . "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
// "github.com/camzawacki/personal-site/internal/ui/icons"
|
||||
"github.com/camzawacki/personal-site/internal/ui/layouts"
|
||||
"github.com/camzawacki/personal-site/internal/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 my homepage."
|
||||
r.Metatags.Keywords = []string{"Software", "Coding", "Projects", "Homepage"}
|
||||
|
||||
img := Div(
|
||||
Class("w-full h-full flex justify-center"),
|
||||
Div(
|
||||
Class("bg-blue-100 size-92 object-contain overflow-hidden rounded-4xl"),
|
||||
Img(
|
||||
Src(ui.StaticFile("me2.webp")),
|
||||
),
|
||||
),
|
||||
)
|
||||
// tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
|
||||
|
||||
banner := Div(
|
||||
Class("w-full py-4 bg-red-100 text-center text-lg"),
|
||||
Text("This website is currently under construction. For an older version, see "),
|
||||
A(
|
||||
Class("underline"),
|
||||
Href("https://camzawacki.com"),
|
||||
Text("camzawacki.com"),
|
||||
),
|
||||
)
|
||||
|
||||
education := Div(
|
||||
Class("prose-xl"),
|
||||
H2(Text("Education")),
|
||||
Ul(Class("list-disc pl-3"),
|
||||
Li(Text("PhD Electrical Engineering")),
|
||||
Li(Text("MS Robotics")),
|
||||
Li(Text("BS Mechanical Engineering & Computer Science")),
|
||||
),
|
||||
)
|
||||
|
||||
content := Div(
|
||||
Class("flex flex-col p-5 mx-10 gap-2"),
|
||||
img,
|
||||
Div(Class("w-full divider")),
|
||||
banner,
|
||||
Div(
|
||||
Class("mx-auto w-160"),
|
||||
education,
|
||||
),
|
||||
)
|
||||
|
||||
return r.Render(layouts.Primary, content)
|
||||
}
|
||||
20
internal/ui/pages/search.go
Normal file
20
internal/ui/pages/search.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
"github.com/camzawacki/personal-site/internal/ui/layouts"
|
||||
"github.com/camzawacki/personal-site/internal/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)
|
||||
}
|
||||
41
internal/ui/pages/task.go
Normal file
41
internal/ui/pages/task.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/internal/ui"
|
||||
. "github.com/camzawacki/personal-site/internal/ui/components"
|
||||
"github.com/camzawacki/personal-site/internal/ui/forms"
|
||||
"github.com/camzawacki/personal-site/internal/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 := Group{
|
||||
Iff(r.Htmx.Target != "task", func() Node {
|
||||
return 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(Raw("See <i>pkg/tasks</i> and the README for more information.")),
|
||||
}
|
||||
}),
|
||||
form.Render(r),
|
||||
Iff(r.Htmx.Target != "task", func() Node {
|
||||
var text string
|
||||
if r.IsAdmin {
|
||||
text = "View all queued tasks by clicking on the Tasks link in the sidebar."
|
||||
} else {
|
||||
text = "Log in as an admin in order to access the task and queue monitoring UI."
|
||||
}
|
||||
return Group{
|
||||
Div(Class("mt-5")),
|
||||
Alert(ColorWarning, text),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
return r.Render(layouts.Primary, g)
|
||||
}
|
||||
114
internal/ui/request.go
Normal file
114
internal/ui/request.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/ent"
|
||||
"github.com/camzawacki/personal-site/internal/context"
|
||||
"github.com/camzawacki/personal-site/internal/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
|
||||
|
||||
// IsAdmin stores whether the user is an admin.
|
||||
IsAdmin 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 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)
|
||||
p.IsAdmin = p.AuthUser.Admin
|
||||
}
|
||||
|
||||
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
internal/ui/request_test.go
Normal file
93
internal/ui/request_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/ent"
|
||||
"github.com/camzawacki/personal-site/internal/context"
|
||||
"github.com/camzawacki/personal-site/internal/htmx"
|
||||
"github.com/camzawacki/personal-site/internal/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())
|
||||
})
|
||||
}
|
||||
21
internal/ui/ui.go
Normal file
21
internal/ui/ui.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// cacheBuster stores the current time as a cache buster for static files.
|
||||
cacheBuster = fmt.Sprint(time.Now().Unix())
|
||||
)
|
||||
|
||||
// PublicFile generates a relative URL to a public file.
|
||||
func PublicFile(filepath string) string {
|
||||
return fmt.Sprintf("/%s/%s", "files", filepath)
|
||||
}
|
||||
|
||||
// StaticFile generates a relative URL to a static file including a cache-buster query parameter.
|
||||
func StaticFile(filepath string) string {
|
||||
return fmt.Sprintf("/%s/%s?v=%s", "static", filepath, cacheBuster)
|
||||
}
|
||||
22
internal/ui/ui_test.go
Normal file
22
internal/ui/ui_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPublicFile(t *testing.T) {
|
||||
path := "abc.txt"
|
||||
got := PublicFile(path)
|
||||
expected := fmt.Sprintf("/%s/%s", "files", path)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestStaticFile(t *testing.T) {
|
||||
path := "abc.txt"
|
||||
got := StaticFile(path)
|
||||
expected := fmt.Sprintf("/%s/%s?v=%s", "static", path, cacheBuster)
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue