go-htmx-gsd #1
4 changed files with 153 additions and 67 deletions
|
|
@ -6,10 +6,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"backend/internal/auth"
|
||||
"backend/templates"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
// HealthzHandler returns an HTTP handler that probes the supplied Pinger
|
||||
|
|
@ -37,20 +34,6 @@ func HealthzHandler(pinger Pinger) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// IndexHandler renders the root page (templates.Index) as text/html.
|
||||
// The authenticated user is pulled from the request context (set by
|
||||
// auth.ResolveSession) and passed to the template so the layout header can
|
||||
// render the logout button and email.
|
||||
// csrf.Token(r) is threaded into the template so the logout form includes the
|
||||
// hidden _csrf field (AUTH-06, D-14).
|
||||
func IndexHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.Index(user, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// DemoTimeHandler renders the HTMX fragment for /demo/time. The `now` clock
|
||||
// is injected so tests can substitute a deterministic time source; production
|
||||
// passes time.Now.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,153 @@
|
|||
package web
|
||||
|
||||
import "backend/internal/db/sqlc"
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/internal/auth"
|
||||
"backend/internal/db/sqlc"
|
||||
"backend/templates"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// TablosDeps holds dependencies for all tablo handlers.
|
||||
// Introduced in Plan 01 as a stub to allow handlers_tablos_test.go to compile.
|
||||
// Plans 02 and 03 add the actual handler implementations.
|
||||
// Plan 02 adds the actual handler implementations.
|
||||
type TablosDeps struct {
|
||||
Queries *sqlc.Queries
|
||||
}
|
||||
|
||||
// TablosListHandler handles GET / for authenticated users.
|
||||
// Fetches all tablos for the current user newest-first and renders TablosDashboard.
|
||||
// Returns the empty-state via TablosEmptyState when the user has no tablos (TABLO-01).
|
||||
func TablosListHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
|
||||
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
slog.Default().Error("tablos list: query failed", "user_id", user.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if tablos == nil {
|
||||
tablos = []sqlc.Tablo{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TablosNewHandler handles GET /tablos/new.
|
||||
// Returns the create form fragment for HTMX insertion into #create-form-slot.
|
||||
// Works without HX-Request too (falls back to rendering the fragment as a full response).
|
||||
func TablosNewHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TabloCreateFormFragment(
|
||||
templates.TabloCreateForm{},
|
||||
templates.TabloCreateErrors{},
|
||||
csrf.Token(r),
|
||||
).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
// TablosCreateHandler handles POST /tablos.
|
||||
// Validates, inserts the tablo, and either:
|
||||
// - HTMX path: sets HX-Retarget + HX-Reswap and renders TabloCardWithOOBFormClear (200)
|
||||
// - Non-HTMX path: 303 redirect to / (TABLO-06 degrade-gracefully)
|
||||
//
|
||||
// Security invariants:
|
||||
// - Form values read via r.PostFormValue only (gorilla/csrf consumes r.Body — Pitfall 2)
|
||||
// - User extracted from RequireAuth-gated context (T-03-02-01)
|
||||
// - Title validates non-empty and <= 255 chars (T-03-02-04)
|
||||
// - Description and Color inserted as pgtype.Text{Valid: s != ""} (T-03-02-03)
|
||||
// - On validation error: 422 + fragment (HTMX) or 422 + full page (non-HTMX)
|
||||
func TablosCreateHandler(deps TablosDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_, user, _ := auth.Authed(ctx)
|
||||
|
||||
// 1. Read form values — always r.PostFormValue, never r.Body (Pitfall 2).
|
||||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
description := r.PostFormValue("description")
|
||||
color := strings.TrimSpace(r.PostFormValue("color"))
|
||||
|
||||
var errs templates.TabloCreateErrors
|
||||
|
||||
// 2. Validate title (UI-SPEC Validation copy).
|
||||
if title == "" {
|
||||
errs.Title = "Title is required."
|
||||
} else if len(title) > 255 {
|
||||
errs.Title = "Title must be 255 characters or fewer."
|
||||
}
|
||||
|
||||
if errs.Title != "" {
|
||||
renderTabloCreateError(w, r, templates.TabloCreateForm{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Color: color,
|
||||
}, errs, http.StatusUnprocessableEntity, deps)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Build insert params with nullable pgtype.Text for description and color.
|
||||
params := sqlc.InsertTabloParams{
|
||||
UserID: user.ID,
|
||||
Title: title,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
}
|
||||
|
||||
tablo, err := deps.Queries.InsertTablo(ctx, params)
|
||||
if err != nil {
|
||||
slog.Default().Error("tablos create: insert failed", "user_id", user.ID, "err", err)
|
||||
errs.General = "Something went wrong. Please try again."
|
||||
renderTabloCreateError(w, r, templates.TabloCreateForm{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Color: color,
|
||||
}, errs, http.StatusInternalServerError, deps)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Success response.
|
||||
// HTMX: dual-target swap — prepend card to #tablos-list + OOB clear form slot.
|
||||
// Non-HTMX: 303 redirect to / (Pitfall 9 — NOT 302).
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("HX-Retarget", "#tablos-list")
|
||||
w.Header().Set("HX-Reswap", "afterbegin")
|
||||
_ = templates.TabloCardWithOOBFormClear(tablo, csrf.Token(r)).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// renderTabloCreateError writes a tablo create validation-error response.
|
||||
// For HTMX requests it renders only the form fragment; for plain requests it
|
||||
// renders the full dashboard (mirrors the auth handler pattern).
|
||||
func renderTabloCreateError(w http.ResponseWriter, r *http.Request, form templates.TabloCreateForm, errs templates.TabloCreateErrors, status int, deps TablosDeps) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
_ = templates.TabloCreateFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
// Non-HTMX: render full dashboard with errs embedded in the form.
|
||||
// Fetch the user's tablos so the list is still accurate on re-render.
|
||||
_, user, _ := auth.Authed(r.Context())
|
||||
tablos, err := deps.Queries.ListTablosByUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
tablos = []sqlc.Tablo{}
|
||||
}
|
||||
// Render full page — form fragment is not embedded in the full page by default;
|
||||
// for the non-HTMX error case we redirect so the user sees their list intact
|
||||
// and can try again (simpler than threading form state through the full page).
|
||||
_ = templates.TablosDashboard(user, csrf.Token(r), tablos).Render(r.Context(), w)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,10 +71,14 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
|
|||
// Protected routes — require an authenticated session (D-23, AUTH-05).
|
||||
// RequireAuth checks the context set by ResolveSession above and redirects
|
||||
// unauthenticated requests to /login (HTMX: HX-Redirect, plain: 303).
|
||||
// Route ordering: static segments (/tablos/new) declared BEFORE parametric
|
||||
// (/tablos/{id}) so chi v5 resolves them correctly (Pitfall 1).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireAuth)
|
||||
r.Get("/", IndexHandler())
|
||||
r.Get("/", TablosListHandler(tabloDeps))
|
||||
r.Post("/logout", LogoutHandler(deps))
|
||||
r.Get("/tablos/new", TablosNewHandler(tabloDeps))
|
||||
r.Post("/tablos", TablosCreateHandler(tabloDeps))
|
||||
})
|
||||
|
||||
r.Get("/healthz", HealthzHandler(pinger))
|
||||
|
|
|
|||
|
|
@ -1,48 +1,4 @@
|
|||
// Package templates — index.templ retired in Phase 3.
|
||||
// GET / is now served by TablosListHandler + TablosDashboard (tablos.templ).
|
||||
// DemoTimeHandler (handlers.go) still uses TimeFragment from time.templ.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"backend/internal/auth"
|
||||
"backend/internal/web/ui"
|
||||
)
|
||||
|
||||
// Index renders the root page (protected, requires auth).
|
||||
// The user parameter is the authenticated user from request context, passed
|
||||
// through to Layout so the header can render the logout button and email.
|
||||
// csrfToken is passed to Layout so the logout form can include the hidden
|
||||
// _csrf field (AUTH-06, D-14).
|
||||
templ Index(user *auth.User, csrfToken string) {
|
||||
@Layout("Xtablo", user, csrfToken) {
|
||||
<p class="text-sm text-slate-500 mb-6">Signed in as { user.Email }</p>
|
||||
<h1 class="text-[28px] font-semibold leading-tight">Xtablo</h1>
|
||||
<p class="mt-2 text-base text-slate-600">
|
||||
Go + HTMX foundation. The Tablos workflow ships in later phases.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
@ui.Card(nil) {
|
||||
<h2 class="text-xl font-semibold leading-snug">HTMX demo</h2>
|
||||
<p class="mt-2 text-base text-slate-600">
|
||||
Click the button to fetch the server time as an HTML fragment.
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Fetch server time",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/demo/time",
|
||||
"hx-target": "#demo-out",
|
||||
"hx-swap": "innerHTML",
|
||||
"hx-indicator": "#demo-spinner",
|
||||
},
|
||||
})
|
||||
<span id="demo-spinner" class="htmx-indicator text-sm text-slate-600">Loading…</span>
|
||||
</div>
|
||||
<div id="demo-out" class="mt-4 text-base text-slate-600">
|
||||
No time fetched yet.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue