Added a basic homepage

This commit is contained in:
CamZawacki 2026-05-20 16:09:54 +01:00
parent d40640a648
commit 12fd3c04ca
113 changed files with 414 additions and 506 deletions

69
internal/ui/cache/cache.go vendored Normal file
View 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
View file

@ -0,0 +1,57 @@
package cache
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func TestCache_GetSet(t *testing.T) {
key := "test"
assert.Nil(t, Get(key))
node := Div(Text("hello"))
Set(key, node)
got := Get(key)
require.NotNil(t, got)
// Check it was converted to a Raw component.
_, ok := got.(NodeFunc)
require.True(t, ok)
// Both nodes should render the same string.
buf1, buf2 := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
require.NoError(t, node.Render(buf1))
require.NoError(t, got.Render(buf2))
assert.Equal(t, buf1.String(), buf2.String())
}
func TestCache_SetIfNotExists(t *testing.T) {
key := "test2"
called := 0
callback := func() Node {
called++
return Div(Text("hello"))
}
assertRender := func(n Node) {
buf := bytes.NewBuffer(nil)
require.NoError(t, n.Render(buf))
assert.Equal(t, `<div>hello</div>`, buf.String())
}
got := SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
got = SetIfNotExists(key, callback)
assert.Equal(t, 1, called)
require.NotNil(t, got)
assertRender(got)
}

View file

@ -0,0 +1,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)),
)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,22 @@
package models
import (
"fmt"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type File struct {
Name string
Size int64
Modified string
}
func (f *File) Render() Node {
return Tr(
Td(Text(f.Name)),
Td(Text(fmt.Sprint(f.Size))),
Td(Text(f.Modified)),
)
}

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

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

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

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

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

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

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

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

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