Compare commits
2 Commits
cfa0734f08
...
72741c9027
| Author | SHA1 | Date | |
|---|---|---|---|
| 72741c9027 | |||
| aa8410c723 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
tmp/
|
||||||
9
htmx-alpine-pt2/go.mod
Normal file
9
htmx-alpine-pt2/go.mod
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module htmx
|
||||||
|
|
||||||
|
go 1.25.2
|
||||||
|
|
||||||
|
require github.com/zepyrshut/hrender v0.0.0-20251204145920-50fdd9cb5ff1
|
||||||
|
|
||||||
|
require github.com/alexedwards/flow v1.1.0
|
||||||
|
|
||||||
|
replace github.com/zepyrshut/hrender => ../../hrender
|
||||||
2
htmx-alpine-pt2/go.sum
Normal file
2
htmx-alpine-pt2/go.sum
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github.com/alexedwards/flow v1.1.0 h1:4Xmg4lehS/iI9y6h5Mfm6QSeXdfPdzaTzSKN4RjAATY=
|
||||||
|
github.com/alexedwards/flow v1.1.0/go.mod h1:DwbobKI6HQD1iMu4/wRgtD4WbmISV8KM3owR9KSSsOQ=
|
||||||
223
htmx-alpine-pt2/main.go
Normal file
223
htmx-alpine-pt2/main.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alexedwards/flow"
|
||||||
|
"github.com/zepyrshut/hrender"
|
||||||
|
"github.com/zepyrshut/hrender/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TodoList struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Completed bool
|
||||||
|
|
||||||
|
ui.CRUDActions
|
||||||
|
}
|
||||||
|
|
||||||
|
var todoList = []TodoList{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Sacar a la perra",
|
||||||
|
Completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Limpiar la casa",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Ir al supermercado",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TodoList) GetID() string {
|
||||||
|
return strconv.Itoa(t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TodoList) SetCRUDActions(actions ui.CRUDActions) {
|
||||||
|
t.CRUDActions = actions
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
templatesFS := os.DirFS("./templates")
|
||||||
|
|
||||||
|
h := hrender.NewHTMLRender(templatesFS, false)
|
||||||
|
|
||||||
|
mux := flow.New()
|
||||||
|
|
||||||
|
mux.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("sleeping 0.5 second, path %s, method %s", r.URL.Path, r.Method)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set(hrender.ContentType, hrender.ContentTextHTMLUTF8)
|
||||||
|
err := h.RenderW(w, "pages/index", hrender.H{}, "layouts/base")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("error loading template, err: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}, "GET")
|
||||||
|
|
||||||
|
mux.HandleFunc("/todo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set(hrender.ContentType, hrender.ContentTextHTMLUTF8)
|
||||||
|
|
||||||
|
sort.Slice(todoList, func(i, j int) bool {
|
||||||
|
return todoList[i].ID > todoList[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
err := h.RenderW(w, "fragments/todo-widget", hrender.H{
|
||||||
|
"Todo": todoList,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("error loading template, err: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}, "GET")
|
||||||
|
|
||||||
|
mux.HandleFunc("/todo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(hrender.ContentType, hrender.ContentTextHTMLUTF8)
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
w.Header().Set("HX-Retarget", "#create-todo-form")
|
||||||
|
w.Header().Set("HX-Reswap", "outerHTML")
|
||||||
|
err := h.RenderW(w, "fragments/todo-row-form", hrender.H{
|
||||||
|
"Name": name,
|
||||||
|
"Error": "El nombre es obligatorio",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error loading template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newID := 0
|
||||||
|
sort.Slice(todoList, func(i, j int) bool {
|
||||||
|
return todoList[i].ID > todoList[j].ID
|
||||||
|
})
|
||||||
|
if len(todoList) > 0 {
|
||||||
|
newID = todoList[0].ID + 1
|
||||||
|
} else {
|
||||||
|
newID = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
newItem := TodoList{
|
||||||
|
ID: newID,
|
||||||
|
Name: name,
|
||||||
|
Completed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
todoList = append(todoList, newItem)
|
||||||
|
|
||||||
|
err = h.RenderW(w, "fragments/todo-row", hrender.H{
|
||||||
|
"ID": newID,
|
||||||
|
"Name": newItem.Name,
|
||||||
|
"Completed": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error loading template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}, "POST")
|
||||||
|
|
||||||
|
mux.HandleFunc("/todo/:id/completed", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundItem *TodoList
|
||||||
|
for i := range todoList {
|
||||||
|
if todoList[i].ID == id {
|
||||||
|
todoList[i].Completed = !todoList[i].Completed
|
||||||
|
foundItem = &todoList[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundItem == nil {
|
||||||
|
http.Error(w, "item not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(hrender.ContentType, hrender.ContentTextHTMLUTF8)
|
||||||
|
err = h.RenderW(w, "fragments/todo-row", hrender.H{
|
||||||
|
"ID": foundItem.ID,
|
||||||
|
"Name": foundItem.Name,
|
||||||
|
"Completed": foundItem.Completed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error loading template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}, "PATCH")
|
||||||
|
|
||||||
|
mux.HandleFunc("/todo/:id", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newName := r.FormValue("name")
|
||||||
|
if newName == "some error" {
|
||||||
|
log.Println("error triggered")
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundItem *TodoList
|
||||||
|
for i := range todoList {
|
||||||
|
if todoList[i].ID == id {
|
||||||
|
todoList[i].Name = newName
|
||||||
|
foundItem = &todoList[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundItem == nil {
|
||||||
|
http.Error(w, "item not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(hrender.ContentType, hrender.ContentTextHTMLUTF8)
|
||||||
|
err = h.RenderW(w, "fragments/todo-row", hrender.H{
|
||||||
|
"ID": foundItem.ID,
|
||||||
|
"Name": foundItem.Name,
|
||||||
|
"Completed": foundItem.Completed,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error loading template", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}, "PUT")
|
||||||
|
|
||||||
|
log.Println("server started on port 8080")
|
||||||
|
err := http.ListenAndServe(":8080", mux)
|
||||||
|
if err != nil {
|
||||||
|
panic("server cannot start")
|
||||||
|
}
|
||||||
|
}
|
||||||
14
htmx-alpine-pt2/templates/fragments/inspector.html
Normal file
14
htmx-alpine-pt2/templates/fragments/inspector.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{{ $data := .Data }} {{ $depth := .Depth }} {{ $mapa := $data | toMap }} {{
|
||||||
|
$depth | indent }} {{ if $mapa }} {{ $nextDepth := add $depth 1 }}
|
||||||
|
|
||||||
|
<ul style="list-style: none">
|
||||||
|
{{ range $key, $value := $mapa }}
|
||||||
|
<li>
|
||||||
|
{{ $nextDepth | indent }}
|
||||||
|
<strong>{{ $key }}:</strong>
|
||||||
|
{{ template "fragments/inspector" (dict "Data" $value "Depth" $nextDepth) }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{ else }} {{ $data }} {{ end }}
|
||||||
43
htmx-alpine-pt2/templates/fragments/todo-row-form.html
Normal file
43
htmx-alpine-pt2/templates/fragments/todo-row-form.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<form
|
||||||
|
id="create-todo-form"
|
||||||
|
class="flex gap-2 items-start w-full"
|
||||||
|
hx-post="/todo"
|
||||||
|
hx-swap="afterbegin"
|
||||||
|
hx-target="#todo-list-body"
|
||||||
|
hx-target-error="this"
|
||||||
|
x-data="{
|
||||||
|
hasError: {{ if .Error }}true{{ else }}false{{ end }}
|
||||||
|
}"
|
||||||
|
@htmx:after-request="
|
||||||
|
if($event.detail.successful && $event.detail.target.id === 'todo-list-body') {
|
||||||
|
$el.reset();
|
||||||
|
hasError = false;
|
||||||
|
creating = false;
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex-grow form-control space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="input input-sm input-bordered w-full"
|
||||||
|
:class="{ 'input-error': hasError }"
|
||||||
|
value="{{ .Name }}"
|
||||||
|
placeholder="Escribe tu tarea..."
|
||||||
|
x-init="$el.focus()"
|
||||||
|
@input="hasError = false"
|
||||||
|
/>
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="text-error text-xs" x-show="hasError">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
@click="creating = false; hasError = false; $el.form.reset();"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Confirmar</button>
|
||||||
|
</form>
|
||||||
72
htmx-alpine-pt2/templates/fragments/todo-row.html
Normal file
72
htmx-alpine-pt2/templates/fragments/todo-row.html
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<li
|
||||||
|
id="todo-{{.ID}}"
|
||||||
|
class="card bg-base-200 shadow-sm"
|
||||||
|
x-data="{
|
||||||
|
isEditing: false,
|
||||||
|
form: {
|
||||||
|
name: '{{ .Name }}'
|
||||||
|
},
|
||||||
|
cancelEdit() {
|
||||||
|
this.isEditing = false;
|
||||||
|
this.form.name = '{{ .Name }}';
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="card-body p-4 flex-row items-center justify-between gap-4"
|
||||||
|
x-show="!isEditing"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 flex-grow">
|
||||||
|
<span class="cursor-pointer"> {{ .Name }} </span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{{if not .Completed}}
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="isEditing = true">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
hx-patch="/todo/{{.ID}}/completed"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
Completar
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<span
|
||||||
|
class="badge badge-success badge-sm text-white cursor-pointer"
|
||||||
|
hx-patch="/todo/{{.ID}}/completed"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-trigger="dblclick"
|
||||||
|
>Completado</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card-body p-4 flex-row items-center justify-between gap-4"
|
||||||
|
x-show="isEditing"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="flex w-full gap-2"
|
||||||
|
hx-put="/todo/{{.ID}}"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="input input-sm input-bordered flex-grow"
|
||||||
|
x-model="form.name"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" @click="cancelEdit()">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Guardar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
26
htmx-alpine-pt2/templates/fragments/todo-widget.html
Normal file
26
htmx-alpine-pt2/templates/fragments/todo-widget.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<div
|
||||||
|
class="card bg-base-100 shadow-xl w-1/3"
|
||||||
|
x-data="{
|
||||||
|
creating: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="card-title text-2xl font-medium">Listado de tareas</h2>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary" @click="creating = true">
|
||||||
|
Nueva tarea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="creating" class="w-full pb-4">
|
||||||
|
{{ template "fragments/todo-row-form" .}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol id="todo-list-body" class="space-y-3">
|
||||||
|
{{ range .Todo }} {{ template "fragments/todo-row" . }} {{ end }}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ template "fragments/inspector" (dict "Data" . "Depth" 0) }}
|
||||||
28
htmx-alpine-pt2/templates/layouts/base.html
Normal file
28
htmx-alpine-pt2/templates/layouts/base.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Lista de tareas</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"
|
||||||
|
></script>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body data-theme="bumblebee">
|
||||||
|
{{ embed }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
htmx-alpine-pt2/templates/pages/index.html
Normal file
3
htmx-alpine-pt2/templates/pages/index.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="p-4">
|
||||||
|
<div hx-get="/todo" hx-trigger="load" hx-swap="outerHTML">Cargando...</div>
|
||||||
|
</div>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<li
|
<li
|
||||||
id="todo-{{.ID}}"
|
id="todo-{{.ID}}"
|
||||||
x-data="{ editing: false, name: '{{.Name}}', backup: '{{.Name}}' }"
|
x-data="{ editing: false, name: '{{.Name}}', backup: '{{.Name}}', error: '' }"
|
||||||
@htmx:response-error="name = backup; $dispatch('show-toast', 'Error al actualizar. Inténtelo de nuevo.');"
|
@htmx:response-error="name = backup; $dispatch('show-toast', 'Error al actualizar. Inténtelo de nuevo.');"
|
||||||
>
|
>
|
||||||
<div x-show="!editing" @dblclick="editing = true" x-text="name">
|
<div x-show="!editing" @dblclick="editing = true" x-text="name">
|
||||||
@ -12,17 +12,27 @@
|
|||||||
hx-put="/todo/{{.ID}}/update"
|
hx-put="/todo/{{.ID}}/update"
|
||||||
hx-target="closest li"
|
hx-target="closest li"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
@submit="editing = false"
|
@htmx:before-request="
|
||||||
|
if (name.trim().length <3) {
|
||||||
|
$event.preventDefault();
|
||||||
|
error = 'Debe tener 3 caracteres como mínimo';
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
error = '';
|
||||||
|
editing = false;}"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value="{{ .Name }}"
|
value="{{ .Name }}"
|
||||||
@keydown.escape="name = backup; editing = false"
|
@keydown.escape="name = backup; editing = false"
|
||||||
|
@input="error = ''"
|
||||||
x-model="name"
|
x-model="name"
|
||||||
x-init="$el.focus()"
|
x-init="$el.focus()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<span x-show="error" x-text="error"></span>
|
||||||
|
|
||||||
<button type="button" @click="name =backup; editing = false">
|
<button type="button" @click="name =backup; editing = false">
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user