Swap Bulma for DaisyUI (Tailwind) (#111)

This commit is contained in:
Mike Stefanello 2025-06-17 20:19:58 -04:00 committed by GitHub
parent fc5db0e95a
commit c1e9baabe6
53 changed files with 1124 additions and 632 deletions

View file

@ -128,7 +128,7 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
err := h.admin.Create(ctx, n.Name)
if err != nil {
msg.Danger(ctx, err.Error())
msg.Error(ctx, err.Error())
return h.EntityAdd(n)(ctx)
}
@ -154,7 +154,7 @@ func (h *Admin) EntityEditSubmit(n *gen.Type) echo.HandlerFunc {
id := ctx.Get(context.AdminEntityIDKey).(int)
err := h.admin.Update(ctx, n.Name, id)
if err != nil {
msg.Danger(ctx, err.Error())
msg.Error(ctx, err.Error())
return h.EntityEdit(n)(ctx)
}
@ -178,7 +178,7 @@ func (h *Admin) EntityDeleteSubmit(n *gen.Type) echo.HandlerFunc {
return func(ctx echo.Context) error {
id := ctx.Get(context.AdminEntityIDKey).(int)
if err := h.admin.Delete(ctx, n.Name, id); err != nil {
msg.Danger(ctx, err.Error())
msg.Error(ctx, err.Error())
return h.EntityDelete(n)(ctx)
}

View file

@ -134,7 +134,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
authFailed := func() error {
input.SetFieldError("Email", "")
input.SetFieldError("Password", "")
msg.Danger(ctx, "Invalid credentials. Please try again.")
msg.Error(ctx, "Invalid credentials. Please try again.")
return h.LoginPage(ctx)
}
@ -185,7 +185,7 @@ func (h *Auth) Logout(ctx echo.Context) error {
if err := h.auth.Logout(ctx); err == nil {
msg.Success(ctx, "You have been logged out successfully.")
} else {
msg.Danger(ctx, "An error occurred. Please try again.")
msg.Error(ctx, "An error occurred. Please try again.")
}
return redirect.New(ctx).
Route(routenames.Home).

View file

@ -54,7 +54,7 @@ func (h *Files) Page(ctx echo.Context) error {
func (h *Files) Submit(ctx echo.Context) error {
file, err := ctx.FormFile("file")
if err != nil {
msg.Danger(ctx, "A file is required.")
msg.Error(ctx, "A file is required.")
return h.Page(ctx)
}

View file

@ -42,6 +42,7 @@ func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
for k := range posts {
posts[k] = models.Post{
ID: k + 1,
Title: fmt.Sprintf("Post example #%d", k+1),
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
}

View file

@ -18,7 +18,7 @@ func TestPages__About(t *testing.T) {
toDoc()
// Goquery is an excellent package to use for testing HTML markup
h1 := doc.Find("h1.title")
h1 := doc.Find("h1")
assert.Len(t, h1.Nodes, 1)
assert.Equal(t, "About", h1.Text())
}

View file

@ -2,31 +2,52 @@ package handlers
import (
"net/http"
"strings"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
echomw "github.com/labstack/echo/v4/middleware"
"github.com/mikestefanello/pagoda/config"
"github.com/mikestefanello/pagoda/pkg/context"
"github.com/mikestefanello/pagoda/pkg/middleware"
"github.com/mikestefanello/pagoda/pkg/services"
files "github.com/mikestefanello/pagoda/public"
)
// BuildRouter builds the router.
func BuildRouter(c *services.Container) error {
// Static files with proper cache control.
// ui.File() should be used in ui components to append a cache key to the URL in order to break cache
// after each server restart.
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
Static(config.StaticPrefix, config.StaticDir)
// Force HTTPS, if enabled.
if c.Config.HTTP.TLS.Enabled {
c.Web.Use(echomw.HTTPSRedirect())
}
// Serve public files with cache control.
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.PublicFile)).
Static("files", "public/files")
// Serve static files.
// ui.StaticFile() should be used in ui components to append a cache key to the URL to break cache
// after each server reboot.
c.Web.Group(
"",
echomw.GzipWithConfig(echomw.GzipConfig{
Skipper: func(c echo.Context) bool {
for _, ext := range []string{
".js",
".css",
} {
if strings.HasSuffix(c.Request().URL.Path, ext) {
return false
}
}
return true
},
}),
middleware.CacheControl(c.Config.Cache.Expiration.PublicFile),
).StaticFS("static", echo.MustSubFS(files.Static, "static"))
// Non-static file route group.
g := c.Web.Group("")
// Force HTTPS, if enabled.
if c.Config.HTTP.TLS.Enabled {
g.Use(echomw.HTTPSRedirect())
}
// Create a cookie store for session data.
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
cookieStore.Options.HttpOnly = true

View file

@ -20,8 +20,8 @@ const (
// TypeWarning represents a warning message type.
TypeWarning Type = "warning"
// TypeDanger represents a danger message type.
TypeDanger Type = "danger"
// TypeError represents an error message type.
TypeError Type = "error"
)
const (
@ -44,9 +44,9 @@ func Warning(ctx echo.Context, message string) {
Set(ctx, TypeWarning, message)
}
// Danger sets a danger flash message.
func Danger(ctx echo.Context, message string) {
Set(ctx, TypeDanger, message)
// Error sets an error flash message.
func Error(ctx echo.Context, message string) {
Set(ctx, TypeError, message)
}
// Set adds a new flash message of a given type into the session storage.
@ -61,19 +61,19 @@ func Set(ctx echo.Context, typ Type, message string) {
// Get gets flash messages of a given type from the session storage.
// Errors will be logged and not returned.
func Get(ctx echo.Context, typ Type) []string {
var msgs []string
if sess, err := getSession(ctx); err == nil {
if flash := sess.Flashes(string(typ)); len(flash) > 0 {
save(ctx, sess)
msgs := make([]string, 0, len(flash))
for _, m := range flash {
msgs = append(msgs, m.(string))
}
return msgs
}
}
return msgs
return nil
}
// getSession gets the flash message session.

View file

@ -33,8 +33,8 @@ func TestMsg(t *testing.T) {
assertMsg(TypeInfo, text)
text = "ccc"
Danger(ctx, text)
assertMsg(TypeDanger, text)
Error(ctx, text)
assertMsg(TypeError, text)
text = "ddd"
Warning(ctx, text)

View file

@ -221,7 +221,7 @@ func (c *Container) initMail() {
// initTasks initializes the task client.
func (c *Container) initTasks() {
var err error
// You could use a separate database for tasks, if you'd like. but using one
// You could use a separate database for tasks, if you'd like, but using one
// makes transaction support easier.
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
DB: c.Database,

View file

@ -3,62 +3,64 @@ package components
import (
"github.com/mikestefanello/pagoda/pkg/msg"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/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.TypeDanger,
msg.TypeError,
} {
for _, str := range msg.Get(r.Context, typ) {
g = append(g, Notification(typ, str))
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 Notification(typ msg.Type, text string) Node {
func Alert(color Color, text string) Node {
var class string
switch typ {
case msg.TypeSuccess:
class = "success"
case msg.TypeInfo:
class = "info"
case msg.TypeWarning:
class = "warning"
case msg.TypeDanger:
class = "danger"
switch color {
case ColorSuccess:
class = "alert-success"
case ColorInfo:
class = "alert-info"
case ColorWarning:
class = "alert-warning"
case ColorError:
class = "alert-error"
}
return Div(
Class("notification is-"+class),
Role("alert"),
Class("alert mb-2 "+class),
Attr("x-data", "{show: true}"),
Attr("x-show", "show"),
Button(
Class("delete"),
Span(
Attr("@click", "show = false"),
Class("cursor-pointer"),
icons.XCircle(),
),
Text(text),
)
}
func Message(class, header string, body Node) Node {
return Article(
Class("message "+class),
If(header != "", Div(
Class("message-header"),
P(Text(header)),
)),
Div(
Class("message-body"),
body,
),
Span(Text(text)),
)
}

121
pkg/ui/components/data.go Normal file
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

@ -19,6 +19,12 @@ type (
Help string
}
FileFieldParams struct {
Name string
Label string
Help string
}
OptionsParams struct {
Form form.Form
FormField string
@ -26,6 +32,7 @@ type (
Label string
Value string
Options []Choice
Help string
}
Choice struct {
@ -52,38 +59,22 @@ type (
)
func ControlGroup(controls ...Node) Node {
g := make(Group, len(controls))
for i, control := range controls {
g[i] = Div(
Class("control"),
control,
)
}
return Div(
Class("field is-grouped"),
g,
Class("mt-2 flex gap-2"),
Group(controls),
)
}
func TextareaField(el TextareaFieldParams) Node {
return Div(
Class("field"),
Label(
For("name"),
Class("label"),
Text(el.Label),
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),
),
Div(
Class("control"),
Textarea(
ID(el.Name),
Name(el.Name),
Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
Text(el.Value),
),
),
If(el.Help != "", P(Class("help"), Text(el.Help))),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
@ -91,25 +82,27 @@ func TextareaField(el TextareaFieldParams) Node {
func Radios(el OptionsParams) Node {
buttons := make(Group, len(el.Options))
for i, opt := range el.Options {
buttons[i] = Label(
Class("radio"),
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()),
),
Text(" "+opt.Label),
Label(
Text(opt.Label),
For(id),
),
)
}
return Div(
Class("control field"),
Label(Class("label"), Text(el.Label)),
Div(
Class("radios"),
buttons,
),
return Fieldset(
el.Label,
buttons,
formFieldErrors(el.Form, el.FormField),
)
}
@ -124,82 +117,77 @@ func SelectList(el OptionsParams) Node {
)
}
return Div(
Class("control field"),
Label(Class("label"), Text(el.Label)),
Div(
Class("select"),
Select(
Name(el.Name),
buttons,
),
return Fieldset(
el.Label,
Select(
Class("select "+formFieldStatusClass(el.Form, el.FormField)),
buttons,
),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func Checkbox(el CheckboxParams) Node {
return Div(
Class("field"),
Div(
Class("control"),
Label(
Label(
Class("label"),
Input(
Class("checkbox"),
Input(
Type("checkbox"),
Name(el.Name),
If(el.Checked, Checked()),
Value("true"),
),
Text(" "+el.Label),
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 Div(
Class("field"),
Label(
Class("label"),
For(el.Name),
Text(el.Label),
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)),
),
Div(
Class("control"),
Input(
ID(el.Name),
Name(el.Name),
Type(el.InputType),
If(el.Placeholder != "", Placeholder(el.Placeholder)),
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
Value(el.Value),
),
),
If(el.Help != "", P(Class("help"), Text(el.Help))),
Help(el.Help),
formFieldErrors(el.Form, el.FormField),
)
}
func FileField(name, label string) Node {
return Div(
Class("field file"),
Label(
Class("file-label"),
Input(
Class("file-input"),
Type("file"),
Name(name),
),
Span(
Class("file-cta"),
Span(
Class("file-label"),
Text(label),
),
),
func 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),
)
}
@ -210,9 +198,9 @@ func formFieldStatusClass(fm form.Form, formField string) string {
case !fm.IsSubmitted():
return ""
case fm.FieldHasErrors(formField):
return "is-danger"
return "input-error"
default:
return "is-success"
return "input-success"
}
}
@ -228,8 +216,8 @@ func formFieldErrors(fm form.Form, field string) Node {
g := make(Group, len(errs))
for i, err := range errs {
g[i] = P(
Class("help is-danger"),
g[i] = Div(
Class("text-error"),
Text(err),
)
}
@ -245,17 +233,35 @@ func CSRF(r *ui.Request) Node {
)
}
func FormButton(class, label string) Node {
func FormButton(color Color, label string) Node {
return Button(
Class("button "+class),
Class("btn "+buttonColor(color)),
Text(label),
)
}
func ButtonLink(href, class, label string) Node {
func ButtonLink(color Color, href, label string) Node {
return A(
Href(href),
Class("button "+class),
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

@ -8,17 +8,18 @@ import (
. "maragu.dev/gomponents/html"
)
func JS(r *ui.Request) Node {
func JS() Node {
return Group{
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")),
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("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
Href(ui.StaticFile("main.css")),
Rel("stylesheet"),
Type("text/css"),
)
}
@ -26,7 +27,7 @@ func Metatags(r *ui.Request) Node {
return Group{
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
Link(Rel("icon"), Href(ui.File("favicon.png"))),
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

@ -1,19 +1,72 @@
package components
import (
"fmt"
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/ui"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node {
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),
If(href == r.CurrentPath, Class("is-active")),
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

@ -2,6 +2,7 @@ package components
import (
"fmt"
"math/rand"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
@ -11,46 +12,27 @@ type Tab struct {
Title, Body string
}
func Tabs(heading, description string, items []Tab) Node {
renderTitles := func() Node {
g := make(Group, len(items))
for i, item := range items {
g[i] = Li(
Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)),
Attr("@click", fmt.Sprintf("tab = %d", i)),
A(Text(item.Title)),
)
}
return g
}
func Tabs(tabs []Tab) Node {
g := make(Group, 0, len(tabs)*2)
id := fmt.Sprintf("tabs-%d", rand.Int())
renderBodies := func() Node {
g := make(Group, len(items))
for i, item := range items {
g[i] = Div(
Attr("x-show", fmt.Sprintf("tab == %d", i)),
P(Raw(" "+item.Body)),
)
}
return g
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(
P(
Class("subtitle mt-5"),
Text(heading),
),
P(
Class("mb-4"),
Text(description),
),
Div(
Attr("x-data", "{tab: 0}"),
Div(
Class("tabs"),
Ul(renderTitles()),
),
renderBodies(),
),
Class("tabs tabs-lift"),
g,
)
}

View file

@ -113,10 +113,10 @@ func AdminEntity(r *ui.Request, schema *load.Schema, values url.Values) Node {
Method(http.MethodPost),
nodes,
ControlGroup(
FormButton("is-primary", "Submit"),
FormButton(ColorPrimary, "Submit"),
ButtonLink(
ColorNone,
r.Path(routenames.AdminEntityList(schema.Name)),
"is-secondary",
"Cancel",
),
),

View file

@ -14,14 +14,13 @@ func AdminEntityDelete(r *ui.Request, entityTypeName string) Node {
return Form(
Method(http.MethodPost),
P(
Class("subtitle"),
Textf("Are you sure you want to delete this %s?", entityTypeName),
),
ControlGroup(
FormButton("is-link", "Delete"),
FormButton(ColorError, "Delete"),
ButtonLink(
ColorNone,
r.Path(routenames.AdminEntityList(entityTypeName)),
"is-secondary",
"Cancel",
),
),

View file

@ -22,21 +22,22 @@ func (f *Cache) Render(r *ui.Request) Node {
ID("cache"),
Method(http.MethodPost),
Attr("hx-post", r.Path(routenames.CacheSubmit)),
Message(
"is-info",
"Test the cache",
Group{
P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
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 != "", Span(Class("tag is-success"), Text(f.CurrentValue))),
If(f.CurrentValue == "", I(Text("(empty)"))),
If(f.CurrentValue != "", Badge(ColorSuccess, f.CurrentValue)),
If(f.CurrentValue == "", Badge(ColorWarning, "empty")),
InputField(InputFieldParams{
Form: f,
FormField: "Value",
@ -46,7 +47,7 @@ func (f *Cache) Render(r *ui.Request) Node {
Value: f.Value,
}),
ControlGroup(
FormButton("is-link", "Update cache"),
FormButton(ColorPrimary, "Update cache"),
),
CSRF(r),
)

View file

@ -51,7 +51,7 @@ func (f *Contact) Render(r *ui.Request) Node {
Value: f.Message,
}),
ControlGroup(
FormButton("is-link", "Submit"),
FormButton(ColorPrimary, "Submit"),
),
CSRF(r),
)

View file

@ -18,9 +18,13 @@ func (f File) Render(r *ui.Request) Node {
Method(http.MethodPost),
Action(r.Path(routenames.FilesSubmit)),
EncType("multipart/form-data"),
FileField("file", "Choose a file.. "),
FileField(FileFieldParams{
Name: "file",
Label: "Test file",
Help: "Pick a file to upload.",
}),
ControlGroup(
FormButton("is-link", "Upload"),
FormButton(ColorPrimary, "Upload"),
),
CSRF(r),
)

View file

@ -31,8 +31,8 @@ func (f *ForgotPassword) Render(r *ui.Request) Node {
Value: f.Email,
}),
ControlGroup(
FormButton("is-primary", "Reset password"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
FormButton(ColorPrimary, "Reset password"),
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
),
CSRF(r),
)

View file

@ -40,10 +40,25 @@ func (f *Login) Render(r *ui.Request) Node {
Label: "Password",
Placeholder: "******",
}),
Div(
Class("text-right text-primary mt-2"),
A(
Href(r.Path(routenames.ForgotPassword)),
Text("Forgot password?"),
),
),
ControlGroup(
FormButton("is-link", "Login"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
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

@ -51,16 +51,24 @@ func (f *Register) Render(r *ui.Request) Node {
}),
InputField(InputFieldParams{
Form: f,
FormField: "PasswordConfirm",
FormField: "ConfirmPassword",
Name: "password-confirm",
InputType: "password",
Label: "Confirm password",
Placeholder: "******",
}),
ControlGroup(
FormButton("is-primary", "Register"),
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
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

@ -39,7 +39,7 @@ func (f *ResetPassword) Render(r *ui.Request) Node {
Placeholder: "******",
}),
ControlGroup(
FormButton("is-primary", "Update password"),
FormButton(ColorPrimary, "Update password"),
),
CSRF(r),
)

View file

@ -42,7 +42,7 @@ func (f *Task) Render(r *ui.Request) Node {
Help: "The message the task will output to the log",
}),
ControlGroup(
FormButton("is-link", "Add task to queue"),
FormButton(ColorPrimary, "Add task to queue"),
),
CSRF(r),
)

208
pkg/ui/icons/icons.go Normal file
View file

@ -0,0 +1,208 @@
package icons
import (
"fmt"
"github.com/mikestefanello/pagoda/pkg/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

@ -1,9 +1,7 @@
package layouts
import (
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
@ -13,31 +11,24 @@ func Auth(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "light"),
Data("theme", "dark"),
Head(
Metatags(r),
CSS(),
JS(r),
JS(),
),
Body(
Section(
Class("hero is-fullheight"),
Div(
Class("hero flex items-center justify-center min-h-screen"),
Div(
Class("hero-body"),
Class("flex-col hero-content"),
Div(
Class("container"),
Class("card shadow-md bg-base-200 w-96"),
Div(
Class("columns is-centered"),
Div(
Class("column is-half"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
Div(
Class("notification"),
FlashMessages(r),
content,
authNavBar(r),
),
),
Class("card-body"),
If(len(r.Title) > 0, H1(Class("text-2xl font-bold"), Text(r.Title))),
FlashMessages(r),
content,
),
),
),
@ -47,20 +38,3 @@ func Auth(r *ui.Request, content Node) Node {
),
)
}
func authNavBar(r *ui.Request) Node {
return cache.SetIfNotExists("authNavBar", func() Node {
return Nav(
Class("navbar"),
Div(
Class("navbar-menu"),
Div(
Class("navbar-start"),
A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")),
A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")),
A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")),
),
),
)
})
}

View file

@ -6,6 +6,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/cache"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/icons"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
@ -14,118 +15,97 @@ func Primary(r *ui.Request, content Node) Node {
return Doctype(
HTML(
Lang("en"),
Data("theme", "light"),
Data("theme", "dark"),
Head(
Metatags(r),
CSS(),
JS(r),
JS(),
),
Body(
headerNavBar(r),
Div(
Class("container mt-5"),
Class("drawer lg:drawer-open"),
Input(
ID("sidebar"),
Type("checkbox"),
Class("drawer-toggle"),
),
Div(
Class("columns"),
Div(
Class("column is-2"),
sidebarMenu(r),
),
Div(
Class("column is-10"),
Div(
Class("box"),
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
FlashMessages(r),
content,
),
Class("drawer-content flex flex-col p-7 prose-base"),
If(len(r.Title) > 0, H1(Text(r.Title))),
FlashMessages(r),
content,
Label(
For("sidebar"),
Class("btn btn-primary drawer-button lg:hidden"),
Text("Open drawer"),
),
),
sidebarMenu(r),
),
searchModal(r),
HtmxListeners(r),
),
),
)
}
func headerNavBar(r *ui.Request) Node {
return cache.SetIfNotExists("layout.headerNavBar", func() Node {
return Nav(
Class("navbar is-dark"),
Div(
Class("container"),
Div(
Class("navbar-brand"),
HxBoost(),
A(
Href(r.Path(routenames.Home)),
Class("navbar-item"),
Text("Pagoda"),
),
),
Div(
ID("navbarMenu"),
Class("navbar-menu"),
Div(
Class("navbar-end"),
search(r),
),
func search() Node {
return cache.SetIfNotExists("layout.search", func() Node {
return Div(
Class("ml-2"),
Attr("x-data", ""),
Label(
Class("input"),
icons.MagnifyingGlass(),
Input(
Type("search"),
Class("grow"),
Placeholder("Search"),
Attr("@click", "search_modal.showModal();"),
),
),
)
})
}
func search(r *ui.Request) Node {
return cache.SetIfNotExists("layout.search", func() Node {
return Div(
Class("search mr-2 mt-1"),
Attr("x-data", "{modal:false}"),
Input(
Class("input"),
Type("search"),
Placeholder("Search..."),
Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"),
),
func searchModal(r *ui.Request) Node {
return cache.SetIfNotExists("layout.searchModal", func() Node {
return Dialog(
ID("search_modal"),
Class("modal"),
Div(
Class("modal"),
Attr(":class", "modal ? 'is-active' : ''"),
Attr("x-show", "modal == true"),
Div(
Class("modal-background"),
),
Div(
Class("modal-content"),
Attr("@click.outside", "modal = false;"),
Div(
Class("box"),
H2(
Class("subtitle"),
Text("Search"),
),
P(
Class("control"),
Input(
Attr("hx-get", r.Path(routenames.Search)),
Attr("hx-trigger", "keyup changed delay:500ms"),
Attr("hx-target", "#results"),
Name("query"),
Class("input"),
Type("search"),
Placeholder("Search..."),
Attr("x-ref", "input"),
),
),
Div(
Class("block"),
),
Div(
ID("results"),
),
Class("modal-box"),
Form(
Method("dialog"),
Button(
Class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
Text("✕"),
),
),
H3(
Class("text-lg font-bold mb-2"),
Text("Search"),
),
Input(
Attr("hx-get", r.Path(routenames.Search)),
Attr("hx-trigger", "keyup changed delay:500ms"),
Attr("hx-target", "#results"),
Name("query"),
Class("input w-full"),
Type("search"),
Placeholder("Search..."),
),
Ul(
ID("results"),
Class("list"),
),
),
Form(
Method("dialog"),
Class("modal-backdrop"),
Button(
Class("modal-close is-large"),
Aria("label", "close"),
Text("close"),
),
),
)
@ -133,66 +113,67 @@ func search(r *ui.Request) Node {
}
func sidebarMenu(r *ui.Request) Node {
header := func(text string) Node {
return Li(
Class("menu-title mt-3 uppercase"),
Span(Text(text)),
)
}
adminSubMenu := func() Node {
entityTypeNames := admin.GetEntityTypeNames()
entityTypeLinks := make(Group, len(entityTypeNames))
for _, n := range entityTypeNames {
entityTypeLinks = append(entityTypeLinks, MenuLink(r, n, routenames.AdminEntityList(n)))
entityTypeLinks = append(entityTypeLinks, MenuLink(r, icons.PencilSquare(), n, routenames.AdminEntityList(n)))
}
return Group{
P(
Class("menu-label"),
Text("Entities"),
),
Ul(
Class("menu-list"),
entityTypeLinks,
),
P(
Class("menu-label"),
Text("Monitoring"),
),
Ul(
Class("menu-list"),
Li(
A(
Href(r.Path(routenames.AdminTasks)),
Text("Tasks"),
Target("_blank"),
),
header("Entities"),
entityTypeLinks,
header("Monitoring"),
Li(
A(
icons.CircleStack(),
Href(r.Path(routenames.AdminTasks)),
Text("Tasks"),
Target("_blank"),
),
),
}
}
return Aside(
Class("menu"),
HxBoost(),
P(
Class("menu-label"),
Text("General"),
return Div(
Class("drawer-side"),
Label(
For("sidebar"),
Aria("label", "close sidebar"),
Class("drawer-overlay"),
),
Ul(
Class("menu-list"),
MenuLink(r, "Dashboard", routenames.Home),
MenuLink(r, "About", routenames.About),
MenuLink(r, "Contact", routenames.Contact),
MenuLink(r, "Cache", routenames.Cache),
MenuLink(r, "Task", routenames.Task),
MenuLink(r, "Files", routenames.Files),
Div(
Class("menu bg-base-200 text-base-content min-h-full w-80 p-4"),
Div(
Class("w-2/3 mx-auto mt-3 mb-10"),
Img(
Src(ui.StaticFile("logo.png")),
),
),
search(),
Ul(
HxBoost(),
header("General"),
MenuLink(r, icons.Home(), "Dashboard", routenames.Home),
MenuLink(r, icons.Info(), "About", routenames.About),
MenuLink(r, icons.Mail(), "Contact", routenames.Contact),
MenuLink(r, icons.Archive(), "Cache", routenames.Cache),
MenuLink(r, icons.CircleStack(), "Task", routenames.Task),
MenuLink(r, icons.Document(), "Files", routenames.Files),
header("Account"),
If(r.IsAuth, MenuLink(r, icons.Exit(), "Logout", routenames.Logout)),
If(!r.IsAuth, MenuLink(r, icons.Enter(), "Login", routenames.Login)),
If(!r.IsAuth, MenuLink(r, icons.UserPlus(), "Register", routenames.Register)),
If(!r.IsAuth, MenuLink(r, icons.QuestionCircle(), "Forgot password", routenames.ForgotPasswordSubmit)),
Iff(r.IsAdmin, adminSubMenu),
),
),
P(
Class("menu-label"),
Text("Account"),
),
Ul(
Class("menu-list"),
If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)),
If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)),
If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
),
Iff(r.IsAdmin, adminSubMenu),
)
}

View file

@ -5,6 +5,7 @@ import (
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
@ -16,6 +17,7 @@ type (
}
Post struct {
ID int
Title, Body string
}
)
@ -28,57 +30,37 @@ func (p *Posts) Render(path string) Node {
return Div(
ID("posts"),
g,
Div(
Class("field is-grouped is-grouped-centered"),
If(!p.Pager.IsBeginning(), P(
Class("control"),
Button(
Class("button is-primary"),
Attr("hx-swap", "outerHTML"),
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
Attr("hx-target", "#posts"),
Text("Previous page"),
),
)),
If(!p.Pager.IsEnd(), P(
Class("control"),
Button(
Class("button is-primary"),
Attr("hx-swap", "outerHTML"),
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
Attr("hx-target", "#posts"),
Text("Next page"),
),
)),
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 Article(
Class("media"),
Figure(
Class("media-left"),
P(
Class("image is-64x64"),
Img(
Src(ui.File("gopher.png")),
Alt("Gopher"),
),
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("media-content"),
Class("list-col-grow"),
Div(
Class("content"),
P(
Strong(
Text(p.Title),
),
Br(),
Text(p.Body),
),
Text(p.Title),
),
Div(
Class("text-xs font-semibold opacity-60"),
Text(p.Body),
),
),
)

View file

@ -11,9 +11,11 @@ type SearchResult struct {
}
func (s *SearchResult) Render() Node {
return A(
Class("panel-block"),
Href(s.URL),
Text(s.Title),
return Li(
Class("list-row"),
A(
Href(s.URL),
Text(s.Title),
),
)
}

View file

@ -15,12 +15,12 @@ func About(ctx echo.Context) error {
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.
// 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(
"Frontend",
"The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.",
[]Tab{
{
Title: "HTMX",
@ -31,15 +31,14 @@ func About(ctx echo.Context) error {
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
},
{
Title: "Bulma",
Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href=\"https://bulma.io/\">bulma.io</a> to learn more.",
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.",
},
},
),
Div(Class("mb-4")),
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(
"Backend",
"The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.",
[]Tab{
{
Title: "Echo",

View file

@ -7,14 +7,12 @@ import (
"entgo.io/ent/entc/load"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/ent/admin"
"github.com/mikestefanello/pagoda/pkg/pager"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
@ -51,12 +49,12 @@ func AdminEntityList(
r.Title = entityTypeName
genHeader := func() Node {
g := make(Group, 0, len(entityList.Columns)+3)
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(), Th())
g = append(g, Th())
return g
}
@ -69,14 +67,14 @@ func AdminEntityList(
g = append(g,
Td(
ButtonLink(
ColorInfo,
r.Path(routenames.AdminEntityEdit(entityTypeName), row.ID),
"is-link",
"Edit",
),
),
Td(
ButtonLink(r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
"is-danger",
Span(Class("mr-2")),
ButtonLink(
ColorError,
r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
"Delete",
),
),
@ -92,45 +90,27 @@ func AdminEntityList(
return g
}
pagedHref := func(page int) string {
return fmt.Sprintf("%s?%s=%d",
r.Path(routenames.AdminEntityList(entityTypeName)),
pager.QueryKey,
page,
)
}
return r.Render(layouts.Primary, Group{
ButtonLink(
r.Path(routenames.AdminEntityAdd(entityTypeName)),
"is-primary",
fmt.Sprintf("Add %s", entityTypeName),
Div(
Class("form-control mb-2"),
ButtonLink(
ColorAccent,
r.Path(routenames.AdminEntityAdd(entityTypeName)),
fmt.Sprintf("Add %s", entityTypeName),
),
),
Table(
Class("table"),
Class("table table-zebra mb-2"),
THead(
Tr(genHeader()),
),
TBody(genRows()),
),
Nav(
Class("pagination"),
A(
Classes{
"pagination-previous": true,
"is-disabled": entityList.Page == 1,
},
If(entityList.Page != 1, Href(pagedHref(entityList.Page-1))),
Text("Previous page"),
),
A(
Classes{
"pagination-previous": true,
"is-disabled": !entityList.HasNextPage,
},
If(entityList.HasNextPage, Href(pagedHref(entityList.Page+1))),
Text("Next page"),
),
Pager(
entityList.Page,
r.Path(routenames.AdminEntityAdd(entityTypeName)),
entityList.HasNextPage,
"",
),
})
}

View file

@ -3,7 +3,7 @@ package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/components"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
@ -17,21 +17,25 @@ func ContactUs(ctx echo.Context, form *forms.Contact) error {
g := Group{
Iff(r.Htmx.Target != "contact", func() Node {
return components.Message(
"is-link",
"",
Group{
P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
P(Text("Only the form below will update async upon submission.")),
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 components.Message(
"is-large is-success",
"Thank you!",
Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."),
)
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)

View file

@ -21,19 +21,19 @@ func UploadFile(ctx echo.Context, files []*models.File) error {
}
n := Group{
Message(
"is-link",
"",
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
),
Hr(),
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),
Hr(),
Divider(""),
H3(
Class("title"),
Text("Uploaded files"),
),
Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))),
Card(CardParams{
Body: Group{Text("Below are all files in the configured upload directory.")},
Color: ColorWarning,
Size: SizeMedium,
}),
Table(
Class("table"),
THead(

View file

@ -1,16 +1,14 @@
package pages
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/routenames"
"github.com/mikestefanello/pagoda/pkg/ui"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/icons"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
"github.com/mikestefanello/pagoda/pkg/ui/models"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
@ -38,69 +36,71 @@ func Home(ctx echo.Context, posts *models.Posts) error {
headerMsg := func() Node {
return Group{
Section(
Class("hero is-info welcome is-small mb-3"),
Div(
Class("hero-body"),
Div(
Class("container"),
H1(
Class("title"),
Iff(r.IsAuth, func() Node {
return Text(fmt.Sprintf("Hello, %s", r.AuthUser.Name))
}),
If(!r.IsAuth, Text("Hello")),
),
H2(
Class("subtitle"),
If(!r.IsAuth, Text("Please login in to your account.")),
If(r.IsAuth, Text("Welcome back!")),
),
),
),
Stats(
Stat{
Title: "User name",
Value: func() string {
if r.IsAuth {
return r.AuthUser.Name
}
return "(not logged in)"
}(),
Description: "The logged in user's name",
Icon: icons.UserCircle(),
},
Stat{
Title: "Admin status",
Value: func() string {
if r.IsAdmin {
return "Administrator"
}
return "Non-administrator"
}(),
Description: "Use `make admin` to create an admin account",
Icon: icons.LockClosed(),
},
Stat{
Title: "GitHub Stars",
Value: "2,500+",
Description: "Star if you like Pagoda",
Icon: icons.Star(),
},
),
Section(
Class("hero is-light is-small mb-5"),
Div(
Class("hero-body"),
Div(
Class("container"),
B(Text("Admin status: ")),
Span(
Classes{
"tag": true,
"is-success": r.IsAdmin,
"is-danger": !r.IsAdmin,
},
Text(fmt.Sprint(r.IsAdmin)),
),
If(!r.IsAdmin, Span(
Class("is-size-7 ml-3"),
Raw(`(<a href="https://github.com/mikestefanello/pagoda#create-an-admin-account">click here</a> for instructions to make an admin account)`),
)),
),
),
),
H2(Class("title"), Text("Recent posts")),
H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
H2(Text("Recent posts")),
Span(Text("Below is an example of both paging and AJAX fetching using HTMX")),
}
}
filesMsg := func() Node {
return Message(
"is-small is-warning mt-5",
"Serving files",
Group{
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
Text("Static files also contain cache-control headers which are configured via middleware."),
},
cards := func() Node {
return Div(
Class("flex w-full gap-2 mt-5"),
Card(CardParams{
Title: "Serving files",
Body: Group{
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
Text("Static files also contain cache-control headers which are configured via middleware."),
},
Color: ColorWarning,
Size: SizeSmall,
}),
Card(CardParams{
Title: "Documentation",
Body: Group{
Text("Have you read through the entire documentation? If not, you may be missing functionality or have questions. "),
},
Footer: Group{
ButtonLink(ColorNeutral, "https://github.com/mikestefanello/pagoda?tab=readme-ov-file#table-of-contents", "Learn more"),
},
Color: ColorNeutral,
Size: SizeSmall,
}),
)
}
g := Group{
Iff(r.Htmx.Target != "posts", headerMsg),
posts.Render(r.Path(routenames.Home)),
Iff(r.Htmx.Target != "posts", filesMsg),
Iff(r.Htmx.Target != "posts", cards),
}
return r.Render(layouts.Primary, g)

View file

@ -3,7 +3,7 @@ package pages
import (
"github.com/labstack/echo/v4"
"github.com/mikestefanello/pagoda/pkg/ui"
"github.com/mikestefanello/pagoda/pkg/ui/components"
. "github.com/mikestefanello/pagoda/pkg/ui/components"
"github.com/mikestefanello/pagoda/pkg/ui/forms"
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
. "maragu.dev/gomponents"
@ -17,23 +17,23 @@ func AddTask(ctx echo.Context, form *forms.Task) error {
g := Group{
Iff(r.Htmx.Target != "task", func() Node {
return components.Message(
"is-link",
"",
Group{
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
P(Raw("See <i>pkg/tasks</i> and the README for more information.")),
})
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 {
return components.Message(
"is-warning",
"",
Group{
If(!r.IsAdmin, P(Text("Log in as an admin in order to access the task and queue monitoring UI."))),
If(r.IsAdmin, P(Text("View all queued tasks by clicking on the Tasks link in the sidebar."))),
})
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),
}
}),
}

View file

@ -57,7 +57,7 @@ type (
}
// LayoutFunc is a callback function intended to render your page node within a given layout.
// This is handled as a callback in order to automatically support HTMX requests so that you can respond
// 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

View file

@ -3,8 +3,6 @@ package ui
import (
"fmt"
"time"
"github.com/mikestefanello/pagoda/config"
)
var (
@ -12,7 +10,12 @@ var (
cacheBuster = fmt.Sprint(time.Now().Unix())
)
// File generates a relative URL to a static file including a cache-buster query parameter.
func File(filepath string) string {
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
// 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)
}

View file

@ -4,13 +4,19 @@ import (
"fmt"
"testing"
"github.com/mikestefanello/pagoda/config"
"github.com/stretchr/testify/assert"
)
func TestFile(t *testing.T) {
func TestPublicFile(t *testing.T) {
path := "abc.txt"
got := File(path)
expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
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)
}