diff --git a/cli/htmgo/tasks/astgen/entry.go b/cli/htmgo/tasks/astgen/entry.go index d14cb34..7b4d13b 100644 --- a/cli/htmgo/tasks/astgen/entry.go +++ b/cli/htmgo/tasks/astgen/entry.go @@ -293,6 +293,17 @@ func formatRoute(path string) string { path = strings.ReplaceAll(path, "_", "/") path = strings.ReplaceAll(path, ".", "/") path = strings.ReplaceAll(path, "\\", "/") + + parts := strings.Split(path, "/") + + for i, part := range parts { + if strings.HasPrefix(part, ":") { + parts[i] = fmt.Sprintf("{%s}", part[1:]) + } + } + + path = strings.Join(parts, "/") + if path == "" { return "/" } diff --git a/examples/chat/chat/service.go b/examples/chat/chat/service.go new file mode 100644 index 0000000..465b2ec --- /dev/null +++ b/examples/chat/chat/service.go @@ -0,0 +1,25 @@ +package chat + +import ( + "chat/internal/db" + "context" + "github.com/maddalax/htmgo/framework/service" +) + +type Service struct { + queries *db.Queries +} + +func NewService(locator *service.Locator) *Service { + return &Service{ + queries: service.Get[db.Queries](locator), + } +} + +func (s *Service) GetRoom(id string) (*db.ChatRoom, error) { + room, err := s.queries.GetChatRoom(context.Background(), id) + if err != nil { + return nil, err + } + return &room, nil +} diff --git a/examples/chat/components/button.go b/examples/chat/components/button.go new file mode 100644 index 0000000..bc767c8 --- /dev/null +++ b/examples/chat/components/button.go @@ -0,0 +1,41 @@ +package components + +import "github.com/maddalax/htmgo/framework/h" + +type ButtonProps struct { + Id string + Text string + Target string + Type string + Trigger string + Get string + Class string + Children []h.Ren +} + +func PrimaryButton(props ButtonProps) h.Ren { + props.Class = h.MergeClasses(props.Class, "border-slate-800 bg-slate-900 hover:bg-slate-800 text-white") + return Button(props) +} + +func SecondaryButton(props ButtonProps) h.Ren { + props.Class = h.MergeClasses(props.Class, "border-gray-700 bg-gray-700 text-white") + return Button(props) +} + +func Button(props ButtonProps) h.Ren { + + text := h.Text(props.Text) + + button := h.Button( + h.If(props.Id != "", h.Id(props.Id)), + h.If(props.Children != nil, h.Children(props.Children...)), + h.Class("flex gap-1 items-center justify-center border p-4 rounded cursor-hover", props.Class), + h.If(props.Get != "", h.Get(props.Get)), + h.If(props.Target != "", h.HxTarget(props.Target)), + h.IfElse(props.Type != "", h.Type(props.Type), h.Type("button")), + text, + ) + + return button +} diff --git a/examples/chat/components/error.go b/examples/chat/components/error.go new file mode 100644 index 0000000..a20ba0d --- /dev/null +++ b/examples/chat/components/error.go @@ -0,0 +1,11 @@ +package components + +import "github.com/maddalax/htmgo/framework/h" + +func FormError(error string) *h.Element { + return h.Div( + h.Id("form-error"), + h.Text(error), + h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")), + ) +} diff --git a/examples/chat/components/input.go b/examples/chat/components/input.go new file mode 100644 index 0000000..eadad94 --- /dev/null +++ b/examples/chat/components/input.go @@ -0,0 +1,54 @@ +package components + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/hx" +) + +type InputProps struct { + Id string + Label string + Name string + Type string + DefaultValue string + Placeholder string + Required bool + ValidationPath string + Error string + Children []h.Ren +} + +func Input(props InputProps) *h.Element { + validation := h.If(props.ValidationPath != "", h.Children( + h.Post(props.ValidationPath, hx.BlurEvent), + h.Attribute("hx-swap", "innerHTML transition:true"), + h.Attribute("hx-target", "next div"), + )) + + if props.Type == "" { + props.Type = "text" + } + + input := h.Input( + props.Type, + h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"), + h.If(props.Name != "", h.Name(props.Name)), + h.If(props.Children != nil, h.Children(props.Children...)), + h.If(props.Required, h.Required()), + h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)), + validation, + ) + + wrapped := h.Div( + h.If(props.Id != "", h.Id(props.Id)), + h.Class("flex flex-col gap-1"), + h.If(props.Label != "", h.Label(h.Text(props.Label))), + input, + h.Div( + h.Id(props.Id+"-error"), + h.Class("text-red-500"), + ), + ) + + return wrapped +} diff --git a/examples/chat/pages/chat.$id.go b/examples/chat/pages/chat.$id.go new file mode 100644 index 0000000..f695264 --- /dev/null +++ b/examples/chat/pages/chat.$id.go @@ -0,0 +1,91 @@ +package pages + +import ( + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/hx" + "github.com/maddalax/htmgo/framework/js" +) + +func ChatRoom(ctx *h.RequestContext) *h.Page { + return h.NewPage( + RootPage( + h.Div( + h.JoinExtensions( + h.TriggerChildren(), + h.HxExtension("ws"), + ), + h.Attribute("ws-connect", "/chat"), + h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"), + Form(ctx), + h.Div( + h.Div( + h.Id("messages"), + h.Class("flex flex-col gap-2 w-full"), + ), + ), + ), + ), + ) +} + +func MessageInput() *h.Element { + return h.Input("text", + h.Id("message-input"), + h.Required(), + h.Class("p-4 rounded-md border border-slate-200"), + h.Name("message"), + h.Placeholder("Message"), + h.HxBeforeWsSend( + js.SetValue(""), + ), + h.OnEvent(hx.KeyDownEvent, js.SubmitFormOnEnter()), + ) +} + +func Form(ctx *h.RequestContext) *h.Element { + return h.Div( + h.Class("flex flex-col items-center justify-center p-4 gap-6"), + h.H2F("Form submission with ws example", h.Class("text-2xl font-bold")), + h.Form( + h.Attribute("ws-send", ""), + h.Class("flex flex-col gap-2"), + h.LabelFor("name", "Your Message"), + MessageInput(), + SubmitButton(), + ), + ) +} + +func SubmitButton() *h.Element { + buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center" + return h.Div( + h.HxBeforeRequest( + js.RemoveClassOnChildren(".loading", "hidden"), + js.SetClassOnChildren(".submit", "hidden"), + ), + h.HxAfterRequest( + js.SetClassOnChildren(".loading", "hidden"), + js.RemoveClassOnChildren(".submit", "hidden"), + ), + h.Class("flex gap-2 justify-center"), + h.Button( + h.Class("loading hidden relative text-center", buttonClasses), + Spinner(), + h.Disabled(), + h.Text("Submitting..."), + ), + h.Button( + h.Type("submit"), + h.Class("submit", buttonClasses), + h.Text("Submit"), + ), + ) +} + +func Spinner(children ...h.Ren) *h.Element { + return h.Div( + h.Children(children...), + h.Class("absolute left-1 spinner spinner-border animate-spin inline-block w-6 h-6 border-4 rounded-full border-slate-200 border-t-transparent"), + h.Attribute("role", "status"), + ) +} diff --git a/examples/chat/pages/index.go b/examples/chat/pages/index.go index 59b4628..c077f08 100644 --- a/examples/chat/pages/index.go +++ b/examples/chat/pages/index.go @@ -1,91 +1,63 @@ package pages import ( + "chat/components" + "chat/partials" "github.com/maddalax/htmgo/framework/h" - "github.com/maddalax/htmgo/framework/hx" - "github.com/maddalax/htmgo/framework/js" ) -func IndexPage(ctx *h.RequestContext) *h.Page { +func ChatAppFirstScreen(ctx *h.RequestContext) *h.Page { return h.NewPage( RootPage( h.Div( - h.JoinExtensions( - h.TriggerChildren(), - h.HxExtension("ws"), - ), - h.Attribute("ws-connect", "/chat"), - h.Class("flex flex-col gap-4 items-center pt-24 min-h-screen bg-neutral-100"), - Form(ctx), + h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"), h.Div( - h.Div( - h.Id("messages"), - h.Class("flex flex-col gap-2 w-full"), + h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"), + h.H2F("htmgo chat", h.Class("text-3xl font-bold text-center mb-6")), + h.Form( + h.Attribute("hx-swap", "none"), + h.PostPartial(partials.CreateOrJoinRoom), + h.Class("flex flex-col gap-3"), + + components.Input(components.InputProps{ + Id: "username", + Name: "username", + Label: "Username", + Required: true, + }), + + h.Div( + h.Class("mt-6 flex flex-col gap-3"), + + components.Input(components.InputProps{ + Name: "new-chat-room", + Label: "Create a New Chat Room", + Placeholder: "Chat Room Name", + }), + + h.Div( + h.Class("flex items-center justify-center gap-4"), + h.Div(h.Class("border-t border-gray-300 flex-grow")), + h.P(h.Text("OR"), h.Class("text-gray-500")), + h.Div(h.Class("border-t border-gray-300 flex-grow")), + ), + + components.Input(components.InputProps{ + Id: "join-chat-room", + Name: "join-chat-room", + Label: "Join a Chat Room", + Placeholder: "Chat Room Id", + }), + ), + + components.FormError(""), + components.PrimaryButton(components.ButtonProps{ + Type: "submit", + Text: "Submit", + }), ), ), ), ), ) } - -func MessageInput() *h.Element { - return h.Input("text", - h.Id("message-input"), - h.Required(), - h.Class("p-4 rounded-md border border-slate-200"), - h.Name("message"), - h.Placeholder("Message"), - h.HxBeforeWsSend( - js.SetValue(""), - ), - h.OnEvent(hx.KeyDownEvent, js.SubmitFormOnEnter()), - ) -} - -func Form(ctx *h.RequestContext) *h.Element { - return h.Div( - h.Class("flex flex-col items-center justify-center p-4 gap-6"), - h.H2F("Form submission with ws example", h.Class("text-2xl font-bold")), - h.Form( - h.Attribute("ws-send", ""), - h.Class("flex flex-col gap-2"), - h.LabelFor("name", "Your Message"), - MessageInput(), - SubmitButton(), - ), - ) -} - -func SubmitButton() *h.Element { - buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center" - return h.Div( - h.HxBeforeRequest( - js.RemoveClassOnChildren(".loading", "hidden"), - js.SetClassOnChildren(".submit", "hidden"), - ), - h.HxAfterRequest( - js.SetClassOnChildren(".loading", "hidden"), - js.RemoveClassOnChildren(".submit", "hidden"), - ), - h.Class("flex gap-2 justify-center"), - h.Button( - h.Class("loading hidden relative text-center", buttonClasses), - Spinner(), - h.Disabled(), - h.Text("Submitting..."), - ), - h.Button( - h.Type("submit"), - h.Class("submit", buttonClasses), - h.Text("Submit"), - ), - ) -} - -func Spinner(children ...h.Ren) *h.Element { - return h.Div( - h.Children(children...), - h.Class("absolute left-1 spinner spinner-border animate-spin inline-block w-6 h-6 border-4 rounded-full border-slate-200 border-t-transparent"), - h.Attribute("role", "status"), - ) -} diff --git a/examples/chat/partials/index.go b/examples/chat/partials/index.go index f5b47e8..d9b3c98 100644 --- a/examples/chat/partials/index.go +++ b/examples/chat/partials/index.go @@ -1,54 +1,30 @@ package partials import ( + "chat/chat" + "chat/components" "github.com/maddalax/htmgo/framework/h" - "strconv" ) -func CounterPartial(ctx *h.RequestContext) *h.Partial { - count, err := strconv.ParseInt(ctx.FormValue("count"), 10, 64) +func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial { + locator := ctx.ServiceLocator() + service := chat.NewService(locator) - if err != nil { - count = 0 + chatRoomId := ctx.FormValue("join-chat-room") + + if chatRoomId != "" { + room, _ := service.GetRoom(chatRoomId) + if room == nil { + return h.SwapPartial(ctx, components.FormError("Room not found")) + } else { + return h.RedirectPartial("/chat/" + chatRoomId) + } } - count++ + chatRoomName := ctx.FormValue("chat-room-name") + if chatRoomName != "" { + // create room + } - return h.SwapManyPartial( - ctx, - CounterForm(int(count)), - h.ElementIf(count > 10, SubmitButton("New record!")), - ) -} - -func CounterForm(count int) *h.Element { - return h.Form( - h.Class("flex flex-col gap-3 items-center"), - h.Id("counter-form"), - h.PostPartial(CounterPartial), - h.Input("text", - h.Class("hidden"), - h.Value(count), - h.Name("count"), - ), - h.P( - h.AttributePairs( - "id", "counter", - "class", "text-xl", - "name", "count", - "text", "count", - ), - h.TextF("Count: %d", count), - ), - SubmitButton("Increment"), - ) -} - -func SubmitButton(text string) *h.Element { - return h.Button( - h.Class("bg-rose-400 hover:bg-rose-500 text-white font-bold py-2 px-4 rounded"), - h.Id("swap-text"), - h.Type("submit"), - h.Text(text), - ) + return h.SwapPartial(ctx, components.FormError("Create a new room or join an existing one")) } diff --git a/framework-ui/ui/input.go b/framework-ui/ui/input.go index 585fc3d..151e457 100644 --- a/framework-ui/ui/input.go +++ b/framework-ui/ui/input.go @@ -11,6 +11,8 @@ type InputProps struct { Name string Type string DefaultValue string + Placeholder string + Required bool ValidationPath string Children []h.Ren } @@ -22,19 +24,24 @@ func Input(props InputProps) h.Ren { h.Attribute("hx-target", "next div"), )) + if props.Type == "" { + props.Type = "text" + } + input := h.Input( props.Type, h.Class("border p-2 rounded"), h.If(props.Id != "", h.Id(props.Id)), h.If(props.Name != "", h.Name(props.Name)), h.If(props.Children != nil, h.Children(props.Children...)), + h.If(props.Required, h.Required()), h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)), validation, ) wrapped := h.Div( h.Class("flex flex-col gap-1"), - h.If(props.Label != "", h.Label(props.Label)), + h.If(props.Label != "", h.Label(h.Text(props.Label))), input, h.Div(h.Class("text-red-500")), ) diff --git a/framework/h/base.go b/framework/h/base.go index 01ecd76..9301ef8 100644 --- a/framework/h/base.go +++ b/framework/h/base.go @@ -1,6 +1,7 @@ package h import ( + "github.com/maddalax/htmgo/framework/hx" "html" "net/http" "reflect" @@ -56,6 +57,10 @@ func SwapPartial(ctx *RequestContext, swap *Element) *Partial { SwapMany(ctx, swap)) } +func RedirectPartial(url string) *Partial { + return NewPartialWithHeaders(NewHeaders(hx.RedirectHeader, url), Fragment()) +} + func SwapManyPartial(ctx *RequestContext, swaps ...*Element) *Partial { return NewPartial( SwapMany(ctx, swaps...), diff --git a/framework/h/swap.go b/framework/h/swap.go index b43a2af..127d05b 100644 --- a/framework/h/swap.go +++ b/framework/h/swap.go @@ -65,7 +65,7 @@ func SwapMany(ctx *RequestContext, elements ...*Element) *Element { for _, element := range elements { element.AppendChild(outOfBandSwap("")) } - return Template(Map(elements, func(arg *Element) Ren { + return Fragment(Map(elements, func(arg *Element) Ren { return arg })...) }