diff --git a/framework/h/array.go b/framework/h/array.go index f6a6154..d1f8c48 100644 --- a/framework/h/array.go +++ b/framework/h/array.go @@ -14,6 +14,15 @@ func Unique[T any](slice []T, key func(item T) string) []T { return result } +func Find[T any](slice []T, predicate func(item *T) bool) *T { + for _, v := range slice { + if predicate(&v) { + return &v + } + } + return nil +} + // Filter returns a new slice with only items that match the predicate. func Filter[T any](slice []T, predicate func(item T) bool) []T { var result []T diff --git a/htmgo-site/internal/markdown/render.go b/htmgo-site/internal/markdown/render.go index 97168af..51b286b 100644 --- a/htmgo-site/internal/markdown/render.go +++ b/htmgo-site/internal/markdown/render.go @@ -62,7 +62,7 @@ func RenderMarkdown(reader io.Reader) bytes.Buffer { highlighting.WithFormatOptions( chromahtml.WithLineNumbers(true), chromahtml.WithCustomCSS(map[chroma.TokenType]string{ - chroma.PreWrapper: "padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important;", + chroma.PreWrapper: "font-size: 14px; padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important;", }), ), highlighting.WithStyle("github"), diff --git a/htmgo-site/pages/examples/click-to-edit.go b/htmgo-site/pages/examples/click-to-edit.go new file mode 100644 index 0000000..0873c9b --- /dev/null +++ b/htmgo-site/pages/examples/click-to-edit.go @@ -0,0 +1,10 @@ +package examples + +import ( + "github.com/maddalax/htmgo/framework/h" +) + +func ClickToEditExample(ctx *h.RequestContext) *h.Page { + SetSnippet(ctx, &ClickToEditSnippet) + return Index(ctx) +} diff --git a/htmgo-site/pages/examples/code.go b/htmgo-site/pages/examples/code.go index b073913..c65a1a6 100644 --- a/htmgo-site/pages/examples/code.go +++ b/htmgo-site/pages/examples/code.go @@ -8,6 +8,9 @@ import ( "io" "log/slog" "net/http" + "os" + "reflect" + "runtime" "strings" "time" ) @@ -29,20 +32,38 @@ var RenderCodeToStringCached = h.CachedPerKeyT(time.Minute*30, func(snippet *Sni }) func renderCodeToString(snippet *Snippet) *h.Element { - url := GetGithubRawPath(snippet.path) - slog.Info("getting snippet source code", slog.String("url", url)) - resp, err := http.Get(url) - if err != nil { - return h.Empty() + source := "" + // in development, use the local file + if h.IsDevelopment() { + ptr := reflect.ValueOf(snippet.partial).Pointer() + fnInfo := runtime.FuncForPC(ptr) + if fnInfo == nil { + return h.Empty() + } + file, _ := fnInfo.FileLine(ptr) + b, err := os.ReadFile(file) + if err != nil { + return h.Empty() + } + source = string(b) + } else { + url := GetGithubRawPath(snippet.path) + slog.Info("getting snippet source code", slog.String("url", url)) + resp, err := http.Get(url) + if err != nil { + return h.Empty() + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return h.Empty() + } + out := bytes.NewBuffer(nil) + _, err = io.Copy(out, resp.Body) + if err != nil { + return h.Empty() + } + source = out.String() } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return h.Empty() - } - out := bytes.NewBuffer(nil) - _, err = io.Copy(out, resp.Body) - if err != nil { - return h.Empty() - } - return ui.CodeSnippet(out.String(), "border-radius: 0.5rem;") + + return ui.CodeSnippet(source, "border-radius: 0.5rem;") } diff --git a/htmgo-site/pages/examples/examples.go b/htmgo-site/pages/examples/examples.go index 2791ec9..4bf1b24 100644 --- a/htmgo-site/pages/examples/examples.go +++ b/htmgo-site/pages/examples/examples.go @@ -5,7 +5,7 @@ import "htmgo-site/partials/snippets" var FormWithLoadingStateSnippet = Snippet{ name: "Form", description: "A simple form submission example with a loading state", - sidebarName: "Form with loading state", + sidebarName: "Form With Loading State", path: "/examples/form", partial: snippets.FormExample, } @@ -22,16 +22,16 @@ var UserAuthSnippet = Snippet{ var ChatSnippet = Snippet{ name: "Chat App", description: "A simple chat application built with htmgo using SSE for real-time updates", - sidebarName: "Chat App", + sidebarName: "Chat App Using SSE", path: "/examples/chat", externalRoute: "https://chat-example.htmgo.dev", sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/chat", } var HackerNewsSnippet = Snippet{ - name: "Hacker News Clone", + name: "HackerNews Clone", description: "A hacker news reader clone built with htmgo", - sidebarName: "Hacker News Clone", + sidebarName: "HackerNews Clone", path: "/examples/hackernews", externalRoute: "https://hn.htmgo.dev", sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/hackernews", @@ -55,8 +55,17 @@ var TodoListSnippet = Snippet{ sourceCodePath: "https://github.com/maddalax/htmgo/tree/master/examples/todo-list", } +var ClickToEditSnippet = Snippet{ + name: "Inline Click To Edit", + description: "List view of items with a click to edit button and persistence", + sidebarName: "Inline click to edit", + path: "/examples/click-to-edit", + partial: snippets.ClickToEdit, +} + var examples = []Snippet{ FormWithLoadingStateSnippet, + ClickToEditSnippet, UserAuthSnippet, ChatSnippet, HackerNewsSnippet, diff --git a/htmgo-site/pages/html-to-go.go b/htmgo-site/pages/html-to-go.go index f03a1d2..6cc8428 100644 --- a/htmgo-site/pages/html-to-go.go +++ b/htmgo-site/pages/html-to-go.go @@ -17,6 +17,7 @@ func HtmlToGoPage(ctx *h.RequestContext) *h.Page { h.Div( h.Class("h-full w-full flex gap-4 p-8"), partials.HtmlInput(), + partials.HiddenCopyOutput(""), partials.GoOutput(""), ), ), diff --git a/htmgo-site/partials/html-to-go.go b/htmgo-site/partials/html-to-go.go index a43f70c..8283f9c 100644 --- a/htmgo-site/partials/html-to-go.go +++ b/htmgo-site/partials/html-to-go.go @@ -2,18 +2,20 @@ package partials import ( "github.com/maddalax/htmgo/framework/h" - "github.com/maddalax/htmgo/framework/js" "github.com/maddalax/htmgo/tools/html-to-htmgo/htmltogo" "htmgo-site/ui" ) func ConvertHtmlToGo(ctx *h.RequestContext) *h.Partial { value := ctx.FormValue("html-input") - parsed := htmltogo.Parse([]byte(value)) + parsed := string(htmltogo.Parse([]byte(value))) - formatted := ui.FormatCode(string(parsed), "height: 100%;") + formatted := ui.FormatCode(parsed, "height: 100%;") - return h.SwapPartial(ctx, GoOutput(formatted)) + return h.SwapManyPartial(ctx, + GoOutput(formatted), + HiddenCopyOutput(parsed), + ) } func HtmlInput() *h.Element { @@ -30,6 +32,14 @@ func HtmlInput() *h.Element { ) } +func HiddenCopyOutput(content string) *h.Element { + return h.Div( + h.Class("hidden"), + h.Id("go-output-raw"), + h.UnsafeRaw(content), + ) +} + func GoOutput(content string) *h.Element { return h.Div( h.Class("h-full w-1/2 min-w-1/2"), @@ -43,25 +53,7 @@ func GoOutput(content string) *h.Element { ), h.If( content != "", - h.Div( - h.Class("absolute top-0 right-0 p-2 bg-slate-800 text-white rounded-bl-md cursor-pointer"), - h.Text("Copy"), - h.OnClick( - // language=JavaScript - js.EvalJs(` - if(!navigator.clipboard) { - alert("Clipboard API not supported"); - return; - } - let text = self.parentElement.querySelector("#go-output-content").innerText; - navigator.clipboard.writeText(text); - self.innerText = "Copied!"; - setTimeout(() => { - self.innerText = "Copy"; - }, 1000); - `), - ), - ), + ui.CopyButton("#go-output-raw"), ), ), ) diff --git a/htmgo-site/partials/snippets/click-to-edit.go b/htmgo-site/partials/snippets/click-to-edit.go new file mode 100644 index 0000000..cad0135 --- /dev/null +++ b/htmgo-site/partials/snippets/click-to-edit.go @@ -0,0 +1,178 @@ +package snippets + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" +) + +// RowClasses defined here for simplicity of the example +var RowClasses = "whitespace-nowrap px-4 py-4 font-medium text-gray-900 text-left" +var ButtonClasses = "inline-block rounded bg-slate-900 px-4 py-2 text-xs font-medium text-white hover:bg-slate-800" +var InputClasses = "-ml-2 max-w-[125px] border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800" + +type Record struct { + Id string + Name string + Birthday string + Role string + Salary string +} + +type TableProps struct { + EditingId string +} + +var records = []Record{ + { + Id: "1", + Name: "John Doe", + Birthday: "24/05/1995", + Role: "htmgo developer", + Salary: "$250,000", + }, + { + Id: "2", + Name: "Jake Smith", + Birthday: "24/05/1995", + Role: "htmx developer", + Salary: "$100,000", + }, +} + +func ClickToEdit(ctx *h.RequestContext) *h.Partial { + return h.NewPartial( + h.Div( + h.Class("flex gap-2 items-center w-full"), + Table(TableProps{ + // no record is being edited initially + EditingId: "", + }), + ), + ) +} + +// StartEditing is a partial that is called when the user clicks on the edit button, +// it will swap in the form for editing for the given record +func StartEditing(ctx *h.RequestContext) *h.Partial { + id := ctx.QueryParam("id") + + record := h.Find(records, func(record *Record) bool { + return record.Id == id + }) + + if record == nil { + return h.EmptyPartial() + } + + return h.SwapManyPartial( + ctx, + TableRow(record, true), + ) +} + +// SaveEditing is a partial that is called when the user clicks on the save button while editing, +// it will update the record with the new values and swap it back out +// note: in the example, we are just creating a new record in memory instead of updating the existing one, +// normally you would update a persistent record in a database +func SaveEditing(ctx *h.RequestContext) *h.Partial { + id := ctx.QueryParam("id") + + // just for the example, create a new record so it doesn't affect the global original + record := Record{ + Id: id, + Name: ctx.FormValue("name"), + Birthday: ctx.FormValue("birthday"), + Role: ctx.FormValue("role"), + Salary: ctx.FormValue("salary"), + } + + return h.SwapPartial(ctx, TableRow(&record, false)) +} + +func Table(props TableProps) *h.Element { + return h.Div( + h.Class("overflow-x-auto w-full"), + h.Table( + h.Class("divide-y divide-gray-200 bg-white table-fixed"), + h.THead( + h.Tr( + h.Th( + h.Class(RowClasses), + h.Text("Name"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Date of Birth"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Role"), + ), + h.Th( + h.Class(RowClasses), + h.Text("Salary"), + ), + h.Th( + h.Class("px-4 py-2"), + ), + ), + ), + h.TBody( + h.Class("divide-y divide-gray-200"), + h.List(records, func(record Record, index int) *h.Element { + editing := props.EditingId == record.Id + return TableRow(&record, editing) + }), + ), + ), + ) +} + +func TableRow(record *Record, editing bool) *h.Element { + recordId := fmt.Sprintf("record-%s", record.Id) + + var Cell = func(name string, value string) *h.Element { + return h.Td( + h.Class(RowClasses, "h-[75px]"), + h.IfElse( + !editing, + h.Pf( + value, + h.Class("w-[125px]"), + ), + h.Input( + "text", + h.Class(InputClasses), + h.Value(value), + h.Name(name), + ), + ), + ) + } + + return h.Tr( + h.If( + editing, + // this is important to make sure the inputs are included in the form submission + h.HxInclude("input"), + ), + h.Id(recordId), + Cell("name", record.Name), + Cell("birthday", record.Birthday), + Cell("role", record.Role), + Cell("salary", record.Salary), + // Edit button + h.Td( + h.Button( + h.Class(ButtonClasses), + h.PostPartialWithQs( + h.Ternary(!editing, StartEditing, SaveEditing), + h.NewQs("id", record.Id), + ), + h.Text( + h.Ternary(!editing, "Edit", "Save"), + ), + ), + ), + ) +} diff --git a/htmgo-site/ui/copy.go b/htmgo-site/ui/copy.go new file mode 100644 index 0000000..453e744 --- /dev/null +++ b/htmgo-site/ui/copy.go @@ -0,0 +1,28 @@ +package ui + +import ( + "fmt" + "github.com/maddalax/htmgo/framework/h" + "github.com/maddalax/htmgo/framework/js" +) + +func CopyButton(selector string) *h.Element { + return h.Div( + h.Class("absolute top-0 right-0 p-2 bg-slate-800 text-white rounded-bl-md cursor-pointer"), + h.Text("Copy"), + h.OnClick( + // language=JavaScript + js.EvalJs(fmt.Sprintf(` + if(!navigator.clipboard) { + return; + } + let text = document.querySelector("%s").innerText; + navigator.clipboard.writeText(text); + self.innerText = "Copied!"; + setTimeout(() => { + self.innerText = "Copy"; + }, 1000); + `, selector)), + ), + ) +} diff --git a/htmgo-site/ui/snippet.go b/htmgo-site/ui/snippet.go index ccdcbe2..ba9f999 100644 --- a/htmgo-site/ui/snippet.go +++ b/htmgo-site/ui/snippet.go @@ -7,6 +7,7 @@ import ( "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/google/uuid" "github.com/maddalax/htmgo/framework/h" "strings" ) @@ -19,7 +20,7 @@ func FormatCode(code string, customStyles ...string) string { html.WrapLongLines(true), html.WithLineNumbers(true), html.WithCustomCSS(map[chroma.TokenType]string{ - chroma.PreWrapper: fmt.Sprintf("padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important; %s", strings.Join(customStyles, ";")), + chroma.PreWrapper: fmt.Sprintf("font-size: 14px; padding: 12px; overflow: auto; background-color: rgb(245, 245, 245) !important; %s", strings.Join(customStyles, ";")), })) iterator, err := lexer.Tokenise(nil, code) if err != nil { @@ -30,7 +31,17 @@ func FormatCode(code string, customStyles ...string) string { } func CodeSnippet(code string, customStyles ...string) *h.Element { + id := fmt.Sprintf("code-snippet-%s", uuid.NewString()) return h.Div( - h.UnsafeRaw(FormatCode(code, customStyles...)), + h.Class("relative"), + h.Div( + h.UnsafeRaw(code), + h.Class("hidden"), + h.Id(id), + ), + CopyButton("#"+id), + h.UnsafeRaw( + FormatCode(code, customStyles...), + ), ) }