feat(12-01): implement discussion tab and posting
This commit is contained in:
parent
39e21be126
commit
c5477e4ceb
20 changed files with 336 additions and 24 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
118
backend/internal/web/handlers_discussion.go
Normal file
118
backend/internal/web/handlers_discussion.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
92
backend/templates/discussion.templ
Normal file
92
backend/templates/discussion.templ
Normal 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>
|
||||
}
|
||||
83
backend/templates/discussion_forms.go
Normal file
83
backend/templates/discussion_forms.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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">← 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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue