feat(10-01): add events calendar creation slice
This commit is contained in:
parent
4fbd960621
commit
0bfe8cfbb4
18 changed files with 636 additions and 20 deletions
|
|
@ -148,9 +148,10 @@ func main() {
|
|||
|
||||
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
|
||||
etapeDeps := web.EtapesDeps{Queries: q}
|
||||
eventDeps := web.EventsDeps{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, fileDeps, csrfKey, env)
|
||||
router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, fileDeps, csrfKey, env)
|
||||
if err != nil {
|
||||
slog.Error("router init failed", "err", err)
|
||||
os.Exit(1)
|
||||
|
|
|
|||
17
backend/internal/db/queries/events.sql
Normal file
17
backend/internal/db/queries/events.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- name: CreateEvent :one
|
||||
INSERT INTO events (tablo_id, title, event_date, start_time, end_time, description, location)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at;
|
||||
|
||||
-- name: GetEventByID :one
|
||||
SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at
|
||||
FROM events
|
||||
WHERE id = $1 AND tablo_id = $2;
|
||||
|
||||
-- name: ListEventsByTabloRange :many
|
||||
SELECT id, tablo_id, title, event_date, start_time, end_time, description, location, created_at, updated_at
|
||||
FROM events
|
||||
WHERE tablo_id = $1
|
||||
AND event_date >= $2
|
||||
AND event_date <= $3
|
||||
ORDER BY event_date, start_time, title;
|
||||
|
|
@ -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}, 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}, 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}, 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}, 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}, 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}, 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{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, etapeDeps, EventsDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
if err != nil {
|
||||
panic("newEtapeTestRouter: " + err.Error())
|
||||
}
|
||||
|
|
|
|||
242
backend/internal/web/handlers_events.go
Normal file
242
backend/internal/web/handlers_events.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/db/sqlc"
|
||||
"backend/templates"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type EventsDeps struct {
|
||||
Queries *sqlc.Queries
|
||||
}
|
||||
|
||||
func parseCalendarMonth(raw string, now time.Time) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw != "" {
|
||||
if parsed, err := time.Parse("2006-01", raw); err == nil {
|
||||
return time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
}
|
||||
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func pgDateFromTime(t time.Time) pgtype.Date {
|
||||
return pgtype.Date{Time: time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), Valid: true}
|
||||
}
|
||||
|
||||
func parseEventDate(raw string) (pgtype.Date, bool) {
|
||||
parsed, err := time.Parse("2006-01-02", strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return pgtype.Date{}, false
|
||||
}
|
||||
return pgDateFromTime(parsed), true
|
||||
}
|
||||
|
||||
func parseEventTime(raw string) (pgtype.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return pgtype.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse("15:04", raw)
|
||||
if err != nil {
|
||||
return pgtype.Time{}, false
|
||||
}
|
||||
micros := int64(parsed.Hour())*int64(time.Hour/time.Microsecond) + int64(parsed.Minute())*int64(time.Minute/time.Microsecond)
|
||||
return pgtype.Time{Microseconds: micros, Valid: true}, true
|
||||
}
|
||||
|
||||
func buildEventsCalendar(month time.Time, events []sqlc.Event) templates.EventsCalendar {
|
||||
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
monthEnd := monthStart.AddDate(0, 1, -1)
|
||||
leading := (int(monthStart.Weekday()) + 6) % 7
|
||||
gridStart := monthStart.AddDate(0, 0, -leading)
|
||||
days := make([]templates.EventCalendarDay, 0, 42)
|
||||
byDate := make(map[string][]sqlc.Event)
|
||||
for _, event := range events {
|
||||
key := templates.FormatEventDate(event.EventDate)
|
||||
byDate[key] = append(byDate[key], event)
|
||||
}
|
||||
for i := 0; i < 42; i++ {
|
||||
day := gridStart.AddDate(0, 0, i)
|
||||
key := day.Format("2006-01-02")
|
||||
dayEvents := byDate[key]
|
||||
more := 0
|
||||
if len(dayEvents) > 3 {
|
||||
more = len(dayEvents) - 3
|
||||
}
|
||||
days = append(days, templates.EventCalendarDay{
|
||||
Date: key,
|
||||
DayNumber: day.Day(),
|
||||
InMonth: !day.Before(monthStart) && !day.After(monthEnd),
|
||||
Events: dayEvents,
|
||||
MoreCount: more,
|
||||
})
|
||||
}
|
||||
return templates.EventsCalendar{
|
||||
Month: monthStart.Format("2006-01"),
|
||||
MonthLabel: monthStart.Format("January 2006"),
|
||||
PrevMonth: monthStart.AddDate(0, -1, 0).Format("2006-01"),
|
||||
NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"),
|
||||
Days: days,
|
||||
WeekdayLabs: templates.DefaultWeekdayLabels(),
|
||||
}
|
||||
}
|
||||
|
||||
func loadEventsCalendar(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) (templates.EventsCalendar, bool) {
|
||||
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now())
|
||||
monthStart := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||
monthEnd := monthStart.AddDate(0, 1, -1)
|
||||
events, err := q.ListEventsByTabloRange(r.Context(), sqlc.ListEventsByTabloRangeParams{
|
||||
TabloID: tablo.ID,
|
||||
EventDate: pgDateFromTime(monthStart),
|
||||
EventDate_2: pgDateFromTime(monthEnd),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Default().Error("events tab: ListEventsByTabloRange failed", "tablo_id", tablo.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return templates.EventsCalendar{}, false
|
||||
}
|
||||
return buildEventsCalendar(month, events), true
|
||||
}
|
||||
|
||||
func renderEventsTab(w http.ResponseWriter, r *http.Request, q *sqlc.Queries, tablo sqlc.Tablo) {
|
||||
calendar, ok := loadEventsCalendar(w, r, q, tablo)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Retarget", "#events-tab")
|
||||
w.Header().Set("HX-Reswap", "outerHTML")
|
||||
}
|
||||
_ = templates.EventsTabFragment(tablo, calendar, csrf.Token(r)).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func TabloEventsTabHandler(deps EventsDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, user, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
calendar, ok := loadEventsCalendar(w, r, deps.Queries, tablo)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
|
||||
func EventNewFormHandler(deps EventsDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
||||
form := templates.EventCreateForm{EventDate: strings.TrimSpace(r.URL.Query().Get("date"))}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.EventCreateFormFragment(tablo.ID, form, templates.EventCreateErrors{}, csrf.Token(r), month).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
func EventCancelNewHandler(deps EventsDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries}); !ok {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(""))
|
||||
}
|
||||
}
|
||||
|
||||
func EventCreateHandler(deps EventsDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, _, ok := loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
form, params, errs, valid := parseEventCreateForm(r, tablo.ID)
|
||||
if !valid {
|
||||
month := parseCalendarMonth(r.URL.Query().Get("month"), time.Now()).Format("2006-01")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Retarget", "#event-form-slot")
|
||||
w.Header().Set("HX-Reswap", "innerHTML")
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_ = templates.EventCreateFormFragment(tablo.ID, form, errs, csrf.Token(r), month).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
if _, err := deps.Queries.CreateEvent(r.Context(), params); err != nil {
|
||||
slog.Default().Error("events create: CreateEvent failed", "tablo_id", tablo.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderEventsTab(w, r, deps.Queries, tablo)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEventCreateForm(r *http.Request, tabloID uuid.UUID) (templates.EventCreateForm, sqlc.CreateEventParams, templates.EventCreateErrors, bool) {
|
||||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
eventDate := strings.TrimSpace(r.PostFormValue("event_date"))
|
||||
startTime := strings.TrimSpace(r.PostFormValue("start_time"))
|
||||
endTime := strings.TrimSpace(r.PostFormValue("end_time"))
|
||||
location := strings.TrimSpace(r.PostFormValue("location"))
|
||||
description := strings.TrimSpace(r.PostFormValue("description"))
|
||||
form := templates.EventCreateForm{
|
||||
Title: title,
|
||||
EventDate: eventDate,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Location: location,
|
||||
Description: description,
|
||||
}
|
||||
var errs templates.EventCreateErrors
|
||||
if title == "" {
|
||||
errs.Title = "Title is required."
|
||||
} else if len(title) > 255 {
|
||||
errs.Title = "Title must be 255 characters or fewer."
|
||||
}
|
||||
dateValue, ok := parseEventDate(eventDate)
|
||||
if !ok {
|
||||
errs.EventDate = "Date is required."
|
||||
}
|
||||
startValue, ok := parseEventTime(startTime)
|
||||
if !ok {
|
||||
errs.StartTime = "Start time is required."
|
||||
}
|
||||
var endValue pgtype.Time
|
||||
if endTime != "" {
|
||||
var endOK bool
|
||||
endValue, endOK = parseEventTime(endTime)
|
||||
if !endOK {
|
||||
errs.EndTime = "End time must use HH:MM."
|
||||
} else if startValue.Valid && endValue.Microseconds <= startValue.Microseconds {
|
||||
errs.EndTime = "End time must be after the start time."
|
||||
}
|
||||
}
|
||||
valid := errs.Title == "" && errs.EventDate == "" && errs.StartTime == "" && errs.EndTime == ""
|
||||
params := sqlc.CreateEventParams{
|
||||
TabloID: tabloID,
|
||||
Title: title,
|
||||
EventDate: dateValue,
|
||||
StartTime: startValue,
|
||||
EndTime: endValue,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Location: pgtype.Text{String: location, Valid: location != ""},
|
||||
}
|
||||
return form, params, errs, valid
|
||||
}
|
||||
|
|
@ -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, "files").Render(r.Context(), w)
|
||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, fileList, templates.EventsCalendar{}, "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, "tasks").Render(r.Context(), w)
|
||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, etapes, counts, filter, nil, templates.EventsCalendar{}, "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}, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{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}, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{Queries: q}, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, 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, "overview").Render(r.Context(), w)
|
||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "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, "overview").Render(ctx, w)
|
||||
_ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, templates.EtapeTaskCounts{}, templates.EtapeFilter{}, nil, templates.EventsCalendar{}, "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}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, EtapesDeps{Queries: q}, EventsDeps{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}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, EtapesDeps{Queries: q}, EventsDeps{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{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, 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{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, 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{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
|
||||
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, EtapesDeps{}, EventsDeps{}, 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, 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, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) {
|
||||
r := chi.NewRouter()
|
||||
r.Use(RequestIDMiddleware)
|
||||
r.Use(chimw.RealIP)
|
||||
|
|
@ -114,6 +114,11 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
|
|||
r.Post("/tablos/{id}/etapes/{etape_id}", EtapeUpdateHandler(etapeDeps))
|
||||
r.Get("/tablos/{id}/etapes/{etape_id}/delete-confirm", EtapeDeleteConfirmHandler(etapeDeps))
|
||||
r.Post("/tablos/{id}/etapes/{etape_id}/delete", EtapeDeleteHandler(etapeDeps))
|
||||
// Events tab and event routes — static segments BEFORE future parametric routes.
|
||||
r.Get("/tablos/{id}/events", TabloEventsTabHandler(eventDeps))
|
||||
r.Get("/tablos/{id}/events/new", EventNewFormHandler(eventDeps))
|
||||
r.Get("/tablos/{id}/events/cancel-new", EventCancelNewHandler(eventDeps))
|
||||
r.Post("/tablos/{id}/events", EventCreateHandler(eventDeps))
|
||||
// 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))
|
||||
|
|
|
|||
28
backend/migrations/0008_events.sql
Normal file
28
backend/migrations/0008_events.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-- migrations/0008_events.sql
|
||||
-- Phase 10: Events
|
||||
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
event_date date NOT NULL,
|
||||
start_time time NOT NULL,
|
||||
end_time time,
|
||||
description text,
|
||||
location text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT events_title_not_blank CHECK (length(trim(title)) > 0),
|
||||
CONSTRAINT events_end_after_start CHECK (end_time IS NULL OR end_time > start_time)
|
||||
);
|
||||
|
||||
CREATE INDEX events_tablo_month_idx ON events(tablo_id, event_date, start_time, title);
|
||||
CREATE INDEX events_date_idx ON events(event_date, start_time);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP INDEX IF EXISTS events_date_idx;
|
||||
DROP INDEX IF EXISTS events_tablo_month_idx;
|
||||
DROP TABLE IF EXISTS events;
|
||||
167
backend/templates/events.templ
Normal file
167
backend/templates/events.templ
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"backend/internal/db/sqlc"
|
||||
"backend/internal/web/ui"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
templ EventsTabFragment(tablo sqlc.Tablo, calendar EventsCalendar, csrfToken string) {
|
||||
<div id="events-tab" class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold leading-snug text-slate-800">Events</h2>
|
||||
<p class="text-sm text-slate-600">{ calendar.MonthLabel }</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={ templ.SafeURL(EventMonthURL(tablo.ID, calendar.PrevMonth)) }
|
||||
hx-get={ EventMonthURL(tablo.ID, calendar.PrevMonth) }
|
||||
hx-target="#tab-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ EventMonthURL(tablo.ID, calendar.PrevMonth) }
|
||||
class="ui-button ui-button-soft-neutral-md"
|
||||
aria-label={ "Previous month: " + calendar.PrevMonth }
|
||||
>Previous month</a>
|
||||
<a
|
||||
href={ templ.SafeURL(EventMonthURL(tablo.ID, calendar.NextMonth)) }
|
||||
hx-get={ EventMonthURL(tablo.ID, calendar.NextMonth) }
|
||||
hx-target="#tab-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ EventMonthURL(tablo.ID, calendar.NextMonth) }
|
||||
class="ui-button ui-button-soft-neutral-md"
|
||||
aria-label={ "Next month: " + calendar.NextMonth }
|
||||
>Next month</a>
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "New event",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": EventNewURL(tablo.ID, calendar.Month),
|
||||
"hx-target": "#event-form-slot",
|
||||
"hx-swap": "innerHTML",
|
||||
"aria-label": "New event",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
<div id="event-form-slot"></div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="grid min-w-[680px] grid-cols-7 gap-px rounded border border-slate-200 bg-slate-200">
|
||||
for _, label := range calendar.WeekdayLabs {
|
||||
<div class="bg-slate-50 px-2 py-2 text-sm font-semibold text-slate-700">{ label }</div>
|
||||
}
|
||||
for _, day := range calendar.Days {
|
||||
@EventDayCell(tablo.ID, calendar.Month, day)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EventDayCell(tabloID uuid.UUID, month string, day EventCalendarDay) {
|
||||
<div
|
||||
class="min-h-24 bg-white p-2"
|
||||
if !day.InMonth {
|
||||
class="min-h-24 bg-slate-50 p-2 text-slate-400"
|
||||
}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-sm font-semibold">{ day.DayNumber }</span>
|
||||
if day.InMonth {
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-slate-500 hover:text-slate-800"
|
||||
hx-get={ EventNewForDayURL(tabloID, day.Date, month) }
|
||||
hx-target="#event-form-slot"
|
||||
hx-swap="innerHTML"
|
||||
aria-label={ "Create event on " + day.Date }
|
||||
>+</button>
|
||||
}
|
||||
</div>
|
||||
if len(day.Events) > 0 {
|
||||
<div class="mt-2 space-y-1">
|
||||
for _, event := range EventDayEvents(day.Events) {
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full truncate rounded border border-slate-200 bg-slate-50 px-2 py-1 text-left text-sm text-slate-800 hover:border-slate-400"
|
||||
hx-get={ "/tablos/" + tabloID.String() + "/events/" + event.ID.String() + "/edit?month=" + month }
|
||||
hx-target="#event-form-slot"
|
||||
hx-swap="innerHTML"
|
||||
aria-label={ "Edit event: " + event.Title }
|
||||
>{ event.Title }</button>
|
||||
}
|
||||
if day.MoreCount > 0 {
|
||||
<p class="text-xs text-slate-500">{ EventMoreLabel(day.MoreCount) }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EventCreateFormFragment(tabloID uuid.UUID, form EventCreateForm, errs EventCreateErrors, csrfToken string, month string) {
|
||||
<form
|
||||
method="POST"
|
||||
action={ templ.SafeURL(EventPostURL(tabloID, month)) }
|
||||
hx-post={ EventPostURL(tabloID, month) }
|
||||
hx-target="#events-tab"
|
||||
hx-swap="outerHTML"
|
||||
class="rounded border border-slate-200 bg-white p-3 shadow-sm space-y-3"
|
||||
>
|
||||
@ui.CSRFField(csrfToken)
|
||||
@GeneralError(errs.General)
|
||||
<div>
|
||||
<label for="event-title" class="block text-sm font-medium text-slate-700">Title</label>
|
||||
<input id="event-title" type="text" name="title" value={ form.Title } maxlength="255" required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"/>
|
||||
@FieldError(errs.Title)
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="event-date" class="block text-sm font-medium text-slate-700">Date</label>
|
||||
<input id="event-date" type="date" name="event_date" value={ form.EventDate } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
|
||||
@FieldError(errs.EventDate)
|
||||
</div>
|
||||
<div>
|
||||
<label for="event-start-time" class="block text-sm font-medium text-slate-700">Start time</label>
|
||||
<input id="event-start-time" type="time" name="start_time" value={ form.StartTime } required class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
|
||||
@FieldError(errs.StartTime)
|
||||
</div>
|
||||
<div>
|
||||
<label for="event-end-time" class="block text-sm font-medium text-slate-700">End time <span class="text-slate-400">(optional)</span></label>
|
||||
<input id="event-end-time" type="time" name="end_time" value={ form.EndTime } class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none"/>
|
||||
@FieldError(errs.EndTime)
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="event-location" class="block text-sm font-medium text-slate-700">Location <span class="text-slate-400">(optional)</span></label>
|
||||
<input id="event-location" type="text" name="location" value={ form.Location } maxlength="255" class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="event-description" class="block text-sm font-medium text-slate-700">Description <span class="text-slate-400">(optional)</span></label>
|
||||
<textarea id="event-description" name="description" rows="3" class="mt-1 block w-full rounded border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none">{ form.Description }</textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Create event",
|
||||
Variant: ui.ButtonVariantDefault,
|
||||
Tone: ui.ButtonToneSolid,
|
||||
Size: ui.SizeMD,
|
||||
Type: "submit",
|
||||
})
|
||||
@ui.Button(ui.ButtonProps{
|
||||
Label: "Close form",
|
||||
Variant: ui.ButtonVariantNeutral,
|
||||
Tone: ui.ButtonToneSoft,
|
||||
Size: ui.SizeMD,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + tabloID.String() + "/events/cancel-new",
|
||||
"hx-target": "#event-form-slot",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
142
backend/templates/events_forms.go
Normal file
142
backend/templates/events_forms.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"backend/internal/db/sqlc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type EventCreateForm struct {
|
||||
Title string
|
||||
EventDate string
|
||||
StartTime string
|
||||
EndTime string
|
||||
Location string
|
||||
Description string
|
||||
}
|
||||
|
||||
type EventCreateErrors struct {
|
||||
Title string
|
||||
EventDate string
|
||||
StartTime string
|
||||
EndTime string
|
||||
General string
|
||||
}
|
||||
|
||||
type EventsCalendar struct {
|
||||
Month string
|
||||
MonthLabel string
|
||||
PrevMonth string
|
||||
NextMonth string
|
||||
Days []EventCalendarDay
|
||||
WeekdayLabs []string
|
||||
}
|
||||
|
||||
type EventCalendarDay struct {
|
||||
Date string
|
||||
DayNumber int
|
||||
InMonth bool
|
||||
Events []sqlc.Event
|
||||
MoreCount int
|
||||
}
|
||||
|
||||
func DefaultWeekdayLabels() []string {
|
||||
return []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
}
|
||||
|
||||
func EventFormFromEvent(event sqlc.Event) EventCreateForm {
|
||||
return EventCreateForm{
|
||||
Title: event.Title,
|
||||
EventDate: FormatEventDate(event.EventDate),
|
||||
StartTime: FormatEventTime(event.StartTime),
|
||||
EndTime: FormatOptionalEventTime(event.EndTime),
|
||||
Location: event.Location.String,
|
||||
Description: event.Description.String,
|
||||
}
|
||||
}
|
||||
|
||||
func FormatEventDate(date pgtype.Date) string {
|
||||
if !date.Valid {
|
||||
return ""
|
||||
}
|
||||
return date.Time.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func FormatEventTime(value pgtype.Time) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
minutes := value.Microseconds / int64(time.Minute/time.Microsecond)
|
||||
hours := minutes / 60
|
||||
mins := minutes % 60
|
||||
return twoDigits(int(hours)) + ":" + twoDigits(int(mins))
|
||||
}
|
||||
|
||||
func FormatOptionalEventTime(value pgtype.Time) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return FormatEventTime(value)
|
||||
}
|
||||
|
||||
func EventTimeRange(event sqlc.Event) string {
|
||||
start := FormatEventTime(event.StartTime)
|
||||
if start == "" {
|
||||
return ""
|
||||
}
|
||||
if !event.EndTime.Valid {
|
||||
return start
|
||||
}
|
||||
return start + "-" + FormatEventTime(event.EndTime)
|
||||
}
|
||||
|
||||
func EventDayEvents(events []sqlc.Event) []sqlc.Event {
|
||||
if len(events) <= 3 {
|
||||
return events
|
||||
}
|
||||
return events[:3]
|
||||
}
|
||||
|
||||
func EventMoreLabel(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return "+" + strconv.Itoa(count) + " more"
|
||||
}
|
||||
|
||||
func EventNewURL(tabloID uuid.UUID, month string) string {
|
||||
if month == "" {
|
||||
return "/tablos/" + tabloID.String() + "/events/new"
|
||||
}
|
||||
return "/tablos/" + tabloID.String() + "/events/new?month=" + month
|
||||
}
|
||||
|
||||
func EventNewForDayURL(tabloID uuid.UUID, date string, month string) string {
|
||||
url := "/tablos/" + tabloID.String() + "/events/new?date=" + date
|
||||
if month != "" {
|
||||
url += "&month=" + month
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func EventPostURL(tabloID uuid.UUID, month string) string {
|
||||
if month == "" {
|
||||
return "/tablos/" + tabloID.String() + "/events"
|
||||
}
|
||||
return "/tablos/" + tabloID.String() + "/events?month=" + month
|
||||
}
|
||||
|
||||
func EventMonthURL(tabloID uuid.UUID, month string) string {
|
||||
return "/tablos/" + tabloID.String() + "/events?month=" + month
|
||||
}
|
||||
|
||||
func twoDigits(value int) string {
|
||||
if value < 10 {
|
||||
return "0" + strconv.Itoa(value)
|
||||
}
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
|
@ -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, 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, 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>
|
||||
|
|
@ -223,6 +223,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"
|
||||
}
|
||||
>Files</a>
|
||||
<a
|
||||
href={ templ.SafeURL("/tablos/" + tablo.ID.String() + "/events") }
|
||||
hx-get={ "/tablos/" + tablo.ID.String() + "/events" }
|
||||
hx-target="#tab-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ "/tablos/" + tablo.ID.String() + "/events" }
|
||||
if activeTab == "events" {
|
||||
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"
|
||||
}
|
||||
>Events</a>
|
||||
</nav>
|
||||
<!-- Tab content area — HTMX tab switches target this div -->
|
||||
<div id="tab-content" class="mt-6">
|
||||
|
|
@ -230,6 +242,8 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
|
|||
@TasksTabFragment(tablo, tasks, etapes, counts, filter, csrfToken)
|
||||
} else if activeTab == "files" {
|
||||
@FilesTabFragment(tablo, files, csrfToken)
|
||||
} else if activeTab == "events" {
|
||||
@EventsTabFragment(tablo, events, csrfToken)
|
||||
} else {
|
||||
@TabloOverviewTabFragment(tablo, csrfToken)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue