Show the campsite type’s calendar in an “infinite scroll” carousel

Oriol does not want to waste so much vertical space for the calendar,
and wants it to show in a carousel, initially with only 6 months, and
loading the next three each time the user scrolls past the last.

I now use HTMx in the public page too for this auto-loading behavior,
based on their “infinite scroll” example[0].

Had to put the /calendar URI inside campsites because in the
calendar.gohtml i do not know the current type’s UUID, and can not use
a relative URL to “add subdirectories”, because the type does not end
with a slash.

Had to change season.CollectCalendar to expect the first month and a
number of months to show, to be able to load only 6 or 3 months after
the current, for the initial carousel content, or after the last month
of the carousel.

[0]: https://htmx.org/examples/infinite-scroll/
This commit is contained in:
jordi fita mas 2023-10-18 21:06:41 +02:00
parent 6e7df4ca79
commit 96fb253354
9 changed files with 148 additions and 78 deletions

View File

@ -7,11 +7,14 @@ package campsite
import ( import (
"net/http" "net/http"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite/types" "dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/season"
"dev.tandem.ws/tandem/camper/pkg/template"
) )
type PublicHandler struct { type PublicHandler struct {
@ -32,6 +35,33 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
switch head { switch head {
case "types": case "types":
h.types.Handler(user, company, conn).ServeHTTP(w, r) h.types.Handler(user, company, conn).ServeHTTP(w, r)
case "calendar":
h.calendarHandler(user, company, conn).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
func (h *PublicHandler) calendarHandler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
year := season.GetCalendarYear(r.URL.Query())
month := season.GetCalendarMonth(r.URL.Query())
date := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
calendar, err := season.CollectSeasonCalendar(r.Context(), company, conn, date.Year(), date.Month(), 3)
if err != nil {
panic(err)
}
template.MustRenderPublicNoLayout(w, r, user, company, "campsite/calendar.gohtml", calendar)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@ -94,7 +94,8 @@ func newPublicPage(ctx context.Context, company *auth.Company, conn *database.Co
if err != nil { if err != nil {
return nil, err return nil, err
} }
calendar, err := season.CollectSeasonCalendar(ctx, company, conn, time.Now().Year()) now := time.Now()
calendar, err := season.CollectSeasonCalendar(ctx, company, conn, now.Year(), now.Month(), 6)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -197,11 +197,11 @@ func newMediaPickerWithUploadForm(f *uploadForm, query url.Values) *mediaPicker
} }
func (picker *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (picker *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/picker.gohtml", picker) template.MustRenderAdminNoLayout(w, r, user, company, "media/picker.gohtml", picker)
} }
func (picker *mediaPicker) MustRenderField(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { func (picker *mediaPicker) MustRenderField(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/field.gohtml", picker.Field) template.MustRenderAdminNoLayout(w, r, user, company, "media/field.gohtml", picker.Field)
} }
func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {

View File

@ -124,7 +124,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
if err != nil { if err != nil {
panic(err) panic(err)
} }
calendar, err := CollectSeasonCalendar(r.Context(), company, conn, getCalendarYear(r.URL.Query())) calendar, err := CollectSeasonCalendar(r.Context(), company, conn, GetCalendarYear(r.URL.Query()), time.January, 12)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -136,7 +136,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
page.MustRender(w, r, user, company) page.MustRender(w, r, user, company)
} }
func getCalendarYear(query url.Values) int { func GetCalendarYear(query url.Values) int {
yearStr := strings.TrimSpace(query.Get("year")) yearStr := strings.TrimSpace(query.Get("year"))
if yearStr != "" { if yearStr != "" {
if year, err := strconv.Atoi(yearStr); err == nil { if year, err := strconv.Atoi(yearStr); err == nil {
@ -146,6 +146,16 @@ func getCalendarYear(query url.Values) int {
return time.Now().Year() return time.Now().Year()
} }
func GetCalendarMonth(query url.Values) time.Month {
monthStr := strings.TrimSpace(query.Get("month"))
if monthStr != "" {
if month, err := strconv.Atoi(monthStr); err == nil {
return time.Month(month)
}
}
return time.Now().Month()
}
func collectSeasonEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*seasonEntry, error) { func collectSeasonEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*seasonEntry, error) {
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, `
select '/admin/seasons/' || season.slug select '/admin/seasons/' || season.slug
@ -213,9 +223,9 @@ var longMonthNames = []string{
locale.PgettextNoop("December", "month"), locale.PgettextNoop("December", "month"),
} }
func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int) (*Calendar, error) { func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int, firstMonth time.Month, monthCount int) (*Calendar, error) {
firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) firstDay := time.Date(year, firstMonth, 1, 0, 0, 0, 0, time.UTC)
lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC) lastDay := firstDay.AddDate(0, monthCount, 0).Add(-1 * time.Second)
rows, err := conn.Query(ctx, ` rows, err := conn.Query(ctx, `
select t.day::date select t.day::date
, to_color(coalesce(color, $1)) as color , to_color(coalesce(color, $1)) as color
@ -248,6 +258,7 @@ func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *dat
calendar.Months = append(calendar.Months, month) calendar.Months = append(calendar.Months, month)
} }
month = &Month{ month = &Month{
Year: day.Date.Year(),
Month: dayMonth, Month: dayMonth,
Name: longMonthNames[dayMonth-1], Name: longMonthNames[dayMonth-1],
} }
@ -283,6 +294,7 @@ type Calendar struct {
} }
type Month struct { type Month struct {
Year int
Month time.Month Month time.Month
Name string Name string
Weeks []Week Weeks []Week
@ -394,7 +406,7 @@ func (f *seasonForm) MustRender(w http.ResponseWriter, r *http.Request, user *au
func serveSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func serveSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newCalendarForm(r.Context(), company, conn) f := newCalendarForm(r.Context(), company, conn)
f.MustRender(w, r, user, company, conn, getCalendarYear(r.URL.Query())) f.MustRender(w, r, user, company, conn, GetCalendarYear(r.URL.Query()))
} }
func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
@ -416,7 +428,7 @@ func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.Use
f.StartDate.Val = "" f.StartDate.Val = ""
f.EndDate.Val = "" f.EndDate.Val = ""
} }
f.MustRender(w, r, user, company, conn, getCalendarYear(r.Form)) f.MustRender(w, r, user, company, conn, GetCalendarYear(r.Form))
} }
type calendarForm struct { type calendarForm struct {
@ -498,10 +510,10 @@ func (f *calendarForm) Valid(l *locale.Locale) bool {
} }
func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) { func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) {
calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year) calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year, time.January, 12)
if err != nil { if err != nil {
panic(err) panic(err)
} }
calendar.Form = f calendar.Form = f
template.MustRenderNoLayout(w, r, user, company, "season/calendar.gohtml", calendar) template.MustRenderAdminNoLayout(w, r, user, company, "season/calendar.gohtml", calendar)
} }

View File

@ -14,6 +14,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
@ -44,7 +45,7 @@ func MustRenderAdminFiles(w io.Writer, r *http.Request, user *auth.User, company
mustRenderLayout(w, user, company, adminTemplateFile, data, filenames...) mustRenderLayout(w, user, company, adminTemplateFile, data, filenames...)
} }
func MustRenderNoLayout(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) { func MustRenderAdminNoLayout(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
mustRenderLayout(w, user, company, adminTemplateFile, data, filename) mustRenderLayout(w, user, company, adminTemplateFile, data, filename)
} }
@ -53,6 +54,10 @@ func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *au
mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename) mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename)
} }
func MustRenderPublicNoLayout(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
mustRenderLayout(w, user, company, publicTemplateFile, data, filename)
}
func MustRenderPublicFiles(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, data interface{}, filenames ...string) { func MustRenderPublicFiles(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, data interface{}, filenames ...string) {
layout := "layout.gohtml" layout := "layout.gohtml"
filenames = append([]string{layout}, filenames...) filenames = append([]string{layout}, filenames...)
@ -95,6 +100,18 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"dec": func(i int) int { "dec": func(i int) int {
return i - 1 return i - 1
}, },
"int": func(v interface{}) int {
switch v := v.(type) {
case int:
return v
case time.Weekday:
return int(v)
case time.Month:
return int(v)
default:
panic(fmt.Errorf("Could not convert to integer"))
}
},
"hexToDec": func(s string) int { "hexToDec": func(s string) int {
num, _ := strconv.ParseInt(s, 16, 0) num, _ := strconv.ParseInt(s, 16, 0)
return int(num) return int(num)

View File

@ -835,14 +835,10 @@ dt {
.campsite_type_calendar > header { .campsite_type_calendar > header {
display: flex; display: flex;
gap: 2rem; gap: 2rem;
justify-content: center; justify-content: space-between;
align-items: center; align-items: center;
} }
.campsite_type_calendar > header button:first-of-type {
order: -1;
}
.campsite_type_calendar > header button:first-of-type::before { .campsite_type_calendar > header button:first-of-type::before {
content: "←"; content: "←";
} }
@ -851,28 +847,21 @@ dt {
content: "→"; content: "→";
} }
.campsite_type_calendar > div {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(27rem, 1fr));
grid-auto-rows: 1fr;
justify-content: center;
align-items: start;
gap: 1em;
}
@media (max-width: 48rem) {
.campsite_type_calendar > div { .campsite_type_calendar > div {
display: flex; display: flex;
flex-direction: column; flex-wrap: nowrap;
} gap: 1em;
overflow-x: scroll;
.campsite_type_calendar table { scroll-behavior: smooth;
width: 100%; scrollbar-width: none;
} -webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
} }
.campsite_type_calendar table { .campsite_type_calendar table {
border-collapse: collapse; border-collapse: collapse;
flex: 0 0 27rem;
height: 100%;
} }
.campsite_type_calendar td { .campsite_type_calendar td {

View File

@ -2,21 +2,17 @@
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog> SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only SPDX-License-Identifier: AGPL-3.0-only
--> -->
<article class="campsite_type_calendar"> {{ $last := dec (len .Months)}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Calendar*/ -}} {{ range $index, $month := .Months -}}
<header> {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Month */ -}}
<h3>{{ .Year }}</h3> <table class="month"
<button type="button" {{ if eq $index $last -}}
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ dec .Year }}" data-hx-get="/{{ currentLocale }}/campsites/calendar?year={{ .Year }}&amp;month={{ .Month | int }}"
><span class="sr-only">{{ pgettext "Prev" "navigation" }}</span></button> data-hx-trigger="intersect once"
<button type="button" data-hx-swap="afterend"
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ inc .Year }}" {{- end }}
><span class="sr-only">{{ pgettext "Next" "navigation" }}</span></button> >
</header> <caption>{{ pgettext .Name "month" }} {{ .Year }}</caption>
<div>
{{ range .Months -}}
<table class="month">
<caption>{{ pgettext .Name "month" }}</caption>
<thead> <thead>
<tr> <tr>
<th scope="col">{{(pgettext "Mon" "day" )}}</th> <th scope="col">{{(pgettext "Mon" "day" )}}</th>
@ -45,5 +41,3 @@
</tbody> </tbody>
</table> </table>
{{- end }} {{- end }}
</div>
</article>

View File

@ -82,7 +82,17 @@
{{- end }} {{- end }}
</div> </div>
<article class="campsite_type_calendar">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Calendar*/ -}}
<header>
<h3 class="sr-only">{{( pgettext "Calendar" "title" )}}</h3>
<button type="button"><span class="sr-only">{{ pgettext "Prev" "navigation" }}</span></button>
<button type="button"><span class="sr-only">{{ pgettext "Next" "navigation" }}</span></button>
</header>
<div>
{{ template "calendar.gohtml" .Calendar }} {{ template "calendar.gohtml" .Calendar }}
</div>
</article>
{{ with .Features -}} {{ with .Features -}}
<article class="campsite_type_features"> <article class="campsite_type_features">
@ -112,4 +122,20 @@
{{ template "carouselInit" }} {{ template "carouselInit" }}
<script>
(function () {
'use strict';
const calendar = document.querySelector('.campsite_type_calendar');
const carousel = calendar.querySelector('div');
const month = carousel.querySelector('table');
const left = calendar.querySelector('header button:first-of-type');
left.addEventListener('click', () => carousel.scrollLeft -= month.clientWidth);
const right = calendar.querySelector('header button:last-of-type');
right.addEventListener('click', () => carousel.scrollLeft += month.clientWidth);
})();
</script>
{{- end }} {{- end }}

View File

@ -16,6 +16,7 @@
{{ range .LocalizedAlternates -}} {{ range .LocalizedAlternates -}}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .HRef }}"/> <link rel="alternate" hreflang="{{ .Lang }}" href="{{ .HRef }}"/>
{{ end }} {{ end }}
<script src="/static/htmx@1.9.3.min.js"></script>
{{- block "head" . }}{{ end }} {{- block "head" . }}{{ end }}
</head> </head>
<body> <body>