go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
20 changed files with 336 additions and 24 deletions
Showing only changes of commit c5477e4ceb - Show all commits

View file

@ -149,10 +149,11 @@ func main() {
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
etapeDeps := web.EtapesDeps{Queries: q}
eventDeps := web.EventsDeps{Queries: q}
discussionDeps := web.DiscussionDeps{Queries: q}
planningDeps := web.PlanningDeps{Queries: q}
// D-09: pass the embedded static FS — binary has zero runtime file dependencies.
router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, fileDeps, csrfKey, env)
router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, discussionDeps, planningDeps, fileDeps, csrfKey, env)
if err != nil {
slog.Error("router init failed", "err", err)
os.Exit(1)

View file

@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
csrfKey[i] = byte(i + 1)
}
deps := AuthDeps{Queries: q, Store: store, Secure: false}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
if err != nil {
panic("newTestRouterWithCSRF: " + err.Error())
}

View file

@ -36,7 +36,7 @@ var testCSRFKey = func() []byte {
// Referer header are accepted.
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTestRouter: " + err.Error())
}
@ -47,7 +47,7 @@ func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
// enabling rate-limit tests to use a fake clock.
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTestRouterWithLimiter: " + err.Error())
}
@ -56,7 +56,7 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit
func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler {
t.Helper()
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}

View file

@ -0,0 +1,118 @@
package web
import (
"log/slog"
"net/http"
"strings"
"backend/internal/db/sqlc"
"backend/templates"
"github.com/google/uuid"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5/pgtype"
)
type DiscussionDeps struct {
Queries *sqlc.Queries
}
func loadDiscussionTabData(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.DiscussionTabData, bool) {
rows, err := q.ListDiscussionMessagesByTablo(r.Context(), tablo.ID)
if err != nil {
slog.Default().Error("discussion: ListDiscussionMessagesByTablo failed", "tablo_id", tablo.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return templates.DiscussionTabData{}, false
}
data := templates.DiscussionTabData{Messages: templates.DiscussionMessagesFromRows(rows)}
return data, true
}
func markDiscussionRead(r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo, userID uuid.UUID, data templates.DiscussionTabData) {
if len(data.Messages) == 0 {
return
}
last := data.Messages[len(data.Messages)-1]
if _, err := q.UpsertDiscussionReadState(r.Context(), sqlc.UpsertDiscussionReadStateParams{
TabloID: tablo.ID,
UserID: userID,
LastReadMessageID: pgtype.UUID{Bytes: last.ID, Valid: true},
}); err != nil {
slog.Default().Warn("discussion: UpsertDiscussionReadState failed", "tablo_id", tablo.ID, "err", err)
}
}
func TabloDiscussionTabHandler(deps DiscussionDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
data, ok := loadDiscussionTabData(w, r, deps.Queries, tablo)
if !ok {
return
}
markDiscussionRead(r, deps.Queries, tablo, user.ID, data)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Header.Get("HX-Request") == "true" {
_ = templates.DiscussionTabFragment(tablo, data, templates.DiscussionForm{}, templates.DiscussionErrors{}, csrf.Token(r)).Render(r.Context(), w)
return
}
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, data, "discussion").Render(r.Context(), w)
}
}
func DiscussionMessageCreateHandler(deps DiscussionDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
if !ok {
return
}
body := strings.TrimSpace(r.PostFormValue("body"))
form := templates.DiscussionForm{Body: r.PostFormValue("body")}
var errs templates.DiscussionErrors
if body == "" {
errs.Body = "Message is required."
} else if len([]rune(body)) > templates.DiscussionMaxBodyLength {
errs.Body = "Message is too long."
}
if errs.Body != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = templates.DiscussionComposer(tablo, form, errs, csrf.Token(r)).Render(r.Context(), w)
return
}
msg, err := deps.Queries.CreateDiscussionMessage(r.Context(), sqlc.CreateDiscussionMessageParams{
TabloID: tablo.ID,
AuthorUserID: user.ID,
Body: body,
})
if err != nil {
slog.Default().Error("discussion create: CreateDiscussionMessage failed", "tablo_id", tablo.ID, "err", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
errs.General = "Message could not be sent. Please try again."
_ = templates.DiscussionComposer(tablo, form, errs, csrf.Token(r)).Render(r.Context(), w)
return
}
row, err := deps.Queries.GetDiscussionMessageWithAuthor(r.Context(), sqlc.GetDiscussionMessageWithAuthorParams{
ID: msg.ID,
TabloID: tablo.ID,
})
if err != nil {
slog.Default().Error("discussion create: GetDiscussionMessageWithAuthor failed", "tablo_id", tablo.ID, "message_id", msg.ID, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
data := templates.DiscussionTabData{Messages: []templates.DiscussionMessageView{templates.DiscussionMessageFromRow(row)}}
markDiscussionRead(r, deps.Queries, tablo, user.ID, data)
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.DiscussionMessageRow(templates.DiscussionMessageFromRow(row)).Render(r.Context(), w)
return
}
http.Redirect(w, r, templates.DiscussionURL(tablo.ID), http.StatusSeeOther)
}
}

View file

@ -21,7 +21,7 @@ import (
func newDiscussionTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newDiscussionTestRouter: " + err.Error())
}

View file

@ -20,7 +20,7 @@ func newEtapeTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
etapeDeps := EtapesDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newEtapeTestRouter: " + err.Error())
}

View file

@ -166,7 +166,7 @@ func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc {
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
return
}
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, "events").Render(r.Context(), w)
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, calendar, templates.DiscussionTabData{}, "events").Render(r.Context(), w)
}
}

View file

@ -26,7 +26,7 @@ func newEventTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
taskDeps := TasksDeps{Queries: q}
etapeDeps := EtapesDeps{Queries: q}
eventDeps := EventsDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newEventTestRouter: " + err.Error())
}

View file

@ -95,7 +95,7 @@ func TabloFilesTabHandler(deps FilesDeps) http.HandlerFunc {
_ = templates.FilesTabFragment(tablo, fileList, csrf.Token(r)).Render(r.Context(), w)
return
}
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, "files").Render(r.Context(), w)
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, templates.DiscussionTabData{}, "files").Render(r.Context(), w)
}
}
@ -119,7 +119,7 @@ func TabloTasksTabHandler(deps FilesDeps) http.HandlerFunc {
_ = templates.TasksTabFragment(tablo, tasks, etapes, counts, filter, csrf.Token(r)).Render(r.Context(), w)
return
}
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, "tasks").Render(r.Context(), w)
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "tasks").Render(r.Context(), w)
}
}

View file

@ -61,7 +61,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newFileTestRouter: " + err.Error())
}
@ -170,7 +170,7 @@ func TestFileUploadTooLarge(t *testing.T) {
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}

View file

@ -21,8 +21,9 @@ func newPlanningTestRouter(q *sqlc.Queries, store *auth.Store, now func() time.T
taskDeps := TasksDeps{Queries: q}
etapeDeps := EtapesDeps{Queries: q}
eventDeps := EventsDeps{Queries: q}
discussionDeps := DiscussionDeps{Queries: q}
planningDeps := PlanningDeps{Queries: q, Now: now}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, planningDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, eventDeps, discussionDeps, planningDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newPlanningTestRouter: " + err.Error())
}

View file

@ -72,7 +72,7 @@ func withGoogleClaims(deps AuthDeps, claims auth.ProviderClaims) AuthDeps {
func newSocialRouter(t *testing.T, deps AuthDeps) http.Handler {
t.Helper()
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}

View file

@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc {
tasks = []sqlc.Task{}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(r.Context(), w)
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "overview").Render(r.Context(), w)
}
}
@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc {
if tasks == nil {
tasks = []sqlc.Task{}
}
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "overview").Render(ctx, w)
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, templates.DiscussionTabData{}, "overview").Render(ctx, w)
return
}

View file

@ -27,7 +27,7 @@ import (
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTabloTestRouter: " + err.Error())
}

View file

@ -30,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, DiscussionDeps{Queries: q}, PlanningDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTaskTestRouter: " + err.Error())
}

View file

@ -92,7 +92,7 @@ func TestReadyz_Down(t *testing.T) {
// was public. The HTMX demo content is tested by
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
func TestIndex_UnauthRedirects(t *testing.T) {
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
@ -110,7 +110,7 @@ func TestIndex_UnauthRedirects(t *testing.T) {
}
func TestDemoTime_Fragment(t *testing.T) {
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
@ -136,7 +136,7 @@ func TestDemoTime_Fragment(t *testing.T) {
}
func TestRequestID_HeaderSet(t *testing.T) {
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, DiscussionDeps{}, PlanningDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}

View file

@ -48,7 +48,7 @@ type Pinger interface {
// trustedOrigins is an optional list of additional origins for the CSRF
// referer check (used in integration tests to allow localhost requests without
// a Referer header). In production, pass no extra args — leave empty.
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps, planningDeps PlanningDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) {
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, eventDeps EventsDeps, discussionDeps DiscussionDeps, planningDeps PlanningDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) {
r := chi.NewRouter()
r.Use(RequestIDMiddleware)
r.Use(chimw.RealIP)
@ -124,6 +124,9 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Post("/tablos/{id}/events/{event_id}", EventUpdateHandler(eventDeps))
r.Get("/tablos/{id}/events/{event_id}/delete-confirm", EventDeleteConfirmHandler(eventDeps))
r.Post("/tablos/{id}/events/{event_id}/delete", EventDeleteHandler(eventDeps))
// Discussion tab and message routes — static discussion segment before later parametric child routes.
r.Get("/tablos/{id}/discussion", TabloDiscussionTabHandler(discussionDeps))
r.Post("/tablos/{id}/discussion/messages", DiscussionMessageCreateHandler(discussionDeps))
// Parametric task routes — must come after static task segments.
r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps))
r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps))

View file

@ -0,0 +1,92 @@
package templates
import (
"time"
"backend/internal/db/sqlc"
"backend/internal/web/ui"
)
templ DiscussionTabFragment(tablo sqlc.Tablo, data DiscussionTabData, form DiscussionForm, errs DiscussionErrors, csrfToken string) {
<div id="discussion-tab" class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-2xl font-semibold leading-tight text-slate-900">Discussion</h2>
<p class="mt-1 text-sm text-slate-600">1 participant</p>
</div>
</div>
<div id="discussion-messages" class="rounded border border-slate-200 bg-white">
if len(data.Messages) == 0 {
@DiscussionEmptyState()
} else {
<div class="divide-y divide-slate-100">
for i, message := range data.Messages {
if DiscussionShowDaySeparator(data.Messages, i) {
@DiscussionDaySeparator(message.CreatedAt)
}
@DiscussionMessageRow(message)
}
</div>
}
</div>
@DiscussionComposer(tablo, form, errs, csrfToken)
</div>
}
templ DiscussionEmptyState() {
<div class="bg-slate-50 px-4 py-8 text-center">
<h3 class="text-xl font-semibold leading-snug text-slate-800">No messages yet</h3>
<p class="mt-2 text-base text-slate-600">Start the discussion for this tablo.</p>
</div>
}
templ DiscussionDaySeparator(createdAt time.Time) {
<div class="bg-slate-50 px-4 py-3 text-center text-sm text-slate-500">
{ DiscussionDateLabel(createdAt) }
</div>
}
templ DiscussionMessageRow(message DiscussionMessageView) {
<article id={ "discussion-message-" + message.ID.String() } data-message-id={ message.ID.String() } class="px-4 py-3">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-sm font-semibold text-slate-900">{ message.AuthorEmail }</span>
<time class="text-xs text-slate-500" datetime={ message.CreatedAt.Format(time.RFC3339) }>{ DiscussionTimestampLabel(message.CreatedAt) }</time>
</div>
<p class="mt-2 whitespace-pre-wrap break-words text-base leading-6 text-slate-900">{ message.Body }</p>
</article>
}
templ DiscussionComposer(tablo sqlc.Tablo, form DiscussionForm, errs DiscussionErrors, csrfToken string) {
<form
method="POST"
action={ templ.SafeURL(DiscussionPostURL(tablo.ID)) }
hx-post={ DiscussionPostURL(tablo.ID) }
hx-target="#discussion-messages"
hx-swap="beforeend"
class="border-t border-slate-200 pt-4"
>
@ui.CSRFField(csrfToken)
@GeneralError(errs.General)
<div>
<label for="discussion-message-body" class="block text-sm font-medium text-slate-700">Message</label>
<textarea
id="discussion-message-body"
name="body"
rows="4"
maxlength={ DiscussionMaxBodyLengthString() }
placeholder="Write a message..."
class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-base leading-6 placeholder-slate-400 focus:border-blue-600 focus:outline-none"
>{ form.Body }</textarea>
@FieldError(errs.Body)
</div>
<div class="mt-3 flex items-center justify-end">
@ui.Button(ui.ButtonProps{
Label: "Send message",
Variant: ui.ButtonVariantDefault,
Tone: ui.ButtonToneSolid,
Size: ui.SizeMD,
Type: "submit",
})
</div>
</form>
}

View file

@ -0,0 +1,83 @@
package templates
import (
"strconv"
"time"
"backend/internal/db/sqlc"
"github.com/google/uuid"
)
const DiscussionMaxBodyLength = 10000
type DiscussionForm struct {
Body string
}
type DiscussionErrors struct {
Body string
General string
}
type DiscussionMessageView struct {
ID uuid.UUID
AuthorEmail string
Body string
CreatedAt time.Time
}
type DiscussionTabData struct {
Messages []DiscussionMessageView
}
func DiscussionPostURL(tabloID uuid.UUID) string {
return "/tablos/" + tabloID.String() + "/discussion/messages"
}
func DiscussionMaxBodyLengthString() string {
return strconv.Itoa(DiscussionMaxBodyLength)
}
func DiscussionURL(tabloID uuid.UUID) string {
return "/tablos/" + tabloID.String() + "/discussion"
}
func DiscussionMessagesFromRows(rows []sqlc.ListDiscussionMessagesByTabloRow) []DiscussionMessageView {
messages := make([]DiscussionMessageView, 0, len(rows))
for _, row := range rows {
messages = append(messages, DiscussionMessageView{
ID: row.ID,
AuthorEmail: row.AuthorEmail,
Body: row.Body,
CreatedAt: row.CreatedAt.Time,
})
}
return messages
}
func DiscussionMessageFromRow(row sqlc.GetDiscussionMessageWithAuthorRow) DiscussionMessageView {
return DiscussionMessageView{
ID: row.ID,
AuthorEmail: row.AuthorEmail,
Body: row.Body,
CreatedAt: row.CreatedAt.Time,
}
}
func DiscussionDateLabel(t time.Time) string {
return t.Local().Format("January 2, 2006")
}
func DiscussionTimestampLabel(t time.Time) string {
return t.Local().Format("January 2, 2006 15:04")
}
func DiscussionShowDaySeparator(messages []DiscussionMessageView, index int) bool {
if index == 0 {
return true
}
current := messages[index].CreatedAt.Local()
previous := messages[index-1].CreatedAt.Local()
return current.Year() != previous.Year() || current.YearDay() != previous.YearDay()
}

View file

@ -171,7 +171,7 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs).
// UI-SPEC §3 Interaction Contract — GET /tablos/{id}.
// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url.
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, activeTab string) {
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, etapes []sqlc.Etape, counts EtapeTaskCounts, filter EtapeFilter, files []sqlc.TabloFile, events EventsCalendar, discussion DiscussionTabData, activeTab string) {
@Layout("Tablos — Xtablo", user, csrfToken) {
<div class="mb-4">
<a href="/" class="text-sm text-slate-600 hover:underline">&larr; Back to tablos</a>
@ -235,6 +235,18 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
}
>Events</a>
<a
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/discussion") }
hx-get={ "/tablos/" + tablo.ID.String() + "/discussion" }
hx-target="#tab-content"
hx-swap="innerHTML"
hx-push-url={ "/tablos/" + tablo.ID.String() + "/discussion" }
if activeTab == "discussion" {
class="px-4 py-2 text-sm font-semibold border-b-2 border-slate-800 text-slate-800 -mb-px"
} else {
class="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 hover:border-b-2 hover:border-slate-400 -mb-px"
}
>Discussion</a>
</nav>
<!-- Tab content area — HTMX tab switches target this div -->
<div id="tab-content" class="mt-6">
@ -244,6 +256,8 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
@FilesTabFragment(tablo, files, csrfToken)
} else if activeTab == "events" {
@EventsTabFragment(tablo, events, csrfToken)
} else if activeTab == "discussion" {
@DiscussionTabFragment(tablo, discussion, DiscussionForm{}, DiscussionErrors{}, csrfToken)
} else {
@TabloOverviewTabFragment(tablo, csrfToken)
}