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 (
"net/http"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database"
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 {
@ -32,6 +35,33 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
switch head {
case "types":
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:
http.NotFound(w, r)
}

View File

@ -94,7 +94,8 @@ func newPublicPage(ctx context.Context, company *auth.Company, conn *database.Co
if err != nil {
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 {
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) {
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) {
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) {

View File

@ -124,7 +124,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
if err != nil {
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 {
panic(err)
}
@ -136,7 +136,7 @@ func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, c
page.MustRender(w, r, user, company)
}
func getCalendarYear(query url.Values) int {
func GetCalendarYear(query url.Values) int {
yearStr := strings.TrimSpace(query.Get("year"))
if yearStr != "" {
if year, err := strconv.Atoi(yearStr); err == nil {
@ -146,6 +146,16 @@ func getCalendarYear(query url.Values) int {
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) {
rows, err := conn.Query(ctx, `
select '/admin/seasons/' || season.slug
@ -213,9 +223,9 @@ var longMonthNames = []string{
locale.PgettextNoop("December", "month"),
}
func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int) (*Calendar, error) {
firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC)
func CollectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int, firstMonth time.Month, monthCount int) (*Calendar, error) {
firstDay := time.Date(year, firstMonth, 1, 0, 0, 0, 0, time.UTC)
lastDay := firstDay.AddDate(0, monthCount, 0).Add(-1 * time.Second)
rows, err := conn.Query(ctx, `
select t.day::date
, 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)
}
month = &Month{
Year: day.Date.Year(),
Month: dayMonth,
Name: longMonthNames[dayMonth-1],
}
@ -283,6 +294,7 @@ type Calendar struct {
}
type Month struct {
Year int
Month time.Month
Name string
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) {
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) {
@ -416,7 +428,7 @@ func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.Use
f.StartDate.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 {
@ -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) {
calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year)
calendar, err := CollectSeasonCalendar(r.Context(), company, conn, year, time.January, 12)
if err != nil {
panic(err)
}
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"
"strconv"
"strings"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth"
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...)
}
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)
}
@ -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)
}
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) {
layout := "layout.gohtml"
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 {
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 {
num, _ := strconv.ParseInt(s, 16, 0)
return int(num)

View File

@ -835,14 +835,10 @@ dt {
.campsite_type_calendar > header {
display: flex;
gap: 2rem;
justify-content: center;
justify-content: space-between;
align-items: center;
}
.campsite_type_calendar > header button:first-of-type {
order: -1;
}
.campsite_type_calendar > header button:first-of-type::before {
content: "←";
}
@ -852,27 +848,20 @@ dt {
}
.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;
display: flex;
flex-wrap: nowrap;
gap: 1em;
}
@media (max-width: 48rem) {
.campsite_type_calendar > div {
display: flex;
flex-direction: column;
}
.campsite_type_calendar table {
width: 100%;
}
overflow-x: scroll;
scroll-behavior: smooth;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
.campsite_type_calendar table {
border-collapse: collapse;
flex: 0 0 27rem;
height: 100%;
}
.campsite_type_calendar td {

View File

@ -2,48 +2,42 @@
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<article class="campsite_type_calendar">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Calendar*/ -}}
<header>
<h3>{{ .Year }}</h3>
<button type="button"
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ dec .Year }}"
><span class="sr-only">{{ pgettext "Prev" "navigation" }}</span></button>
<button type="button"
data-hx-get="/{{ currentLocale }}/seasons/calendar?year={{ inc .Year }}"
><span class="sr-only">{{ pgettext "Next" "navigation" }}</span></button>
</header>
<div>
{{ range .Months -}}
<table class="month">
<caption>{{ pgettext .Name "month" }}</caption>
<thead>
<tr>
<th scope="col">{{(pgettext "Mon" "day" )}}</th>
<th scope="col">{{(pgettext "Tue" "day" )}}</th>
<th scope="col">{{(pgettext "Wed" "day" )}}</th>
<th scope="col">{{(pgettext "Thu" "day" )}}</th>
<th scope="col">{{(pgettext "Fri" "day" )}}</th>
<th scope="col">{{(pgettext "Sat" "day" )}}</th>
<th scope="col">{{(pgettext "Sun" "day" )}}</th>
</tr>
</thead>
<tbody>
{{ range .Weeks }}
<tr>
{{- range . }}
<td>
{{- if .Color -}}
<time style="--red: {{ slice .Color 1 3 | hexToDec }}; --green: {{ slice .Color 3 5 | hexToDec }}; --blue: {{ slice .Color 5 7 | hexToDec }}"
datetime="{{ .Date.Format "2006-01-02" }}"
>{{ .Date.Format "2" }}</time>
{{- end -}}
</td>
{{- end }}
</tr>
{{ $last := dec (len .Months)}}
{{ range $index, $month := .Months -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/season.Month */ -}}
<table class="month"
{{ if eq $index $last -}}
data-hx-get="/{{ currentLocale }}/campsites/calendar?year={{ .Year }}&amp;month={{ .Month | int }}"
data-hx-trigger="intersect once"
data-hx-swap="afterend"
{{- end }}
>
<caption>{{ pgettext .Name "month" }} {{ .Year }}</caption>
<thead>
<tr>
<th scope="col">{{(pgettext "Mon" "day" )}}</th>
<th scope="col">{{(pgettext "Tue" "day" )}}</th>
<th scope="col">{{(pgettext "Wed" "day" )}}</th>
<th scope="col">{{(pgettext "Thu" "day" )}}</th>
<th scope="col">{{(pgettext "Fri" "day" )}}</th>
<th scope="col">{{(pgettext "Sat" "day" )}}</th>
<th scope="col">{{(pgettext "Sun" "day" )}}</th>
</tr>
</thead>
<tbody>
{{ range .Weeks }}
<tr>
{{- range . }}
<td>
{{- if .Color -}}
<time style="--red: {{ slice .Color 1 3 | hexToDec }}; --green: {{ slice .Color 3 5 | hexToDec }}; --blue: {{ slice .Color 5 7 | hexToDec }}"
datetime="{{ .Date.Format "2006-01-02" }}"
>{{ .Date.Format "2" }}</time>
{{- end -}}
</td>
{{- end }}
</tbody>
</table>
</tr>
{{- end }}
</div>
</article>
</tbody>
</table>
{{- end }}

View File

@ -82,7 +82,17 @@
{{- end }}
</div>
{{ template "calendar.gohtml" .Calendar }}
<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 }}
</div>
</article>
{{ with .Features -}}
<article class="campsite_type_features">
@ -112,4 +122,20 @@
{{ 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 }}

View File

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