jordi fita mas 96fb253354 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/
2023-10-18 21:06:41 +02:00

343 lines
8.7 KiB
Go

/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package media
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
mediaDir string
}
func NewAdminHandler(mediaDir string) *AdminHandler {
// mediaDir is already created in public handler
return &AdminHandler{
mediaDir: mediaDir,
}
}
func (h *AdminHandler) Handler(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:
serveMediaIndex(w, r, user, company, conn)
case http.MethodPost:
uploadMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "picker":
switch r.Method {
case http.MethodGet:
serveMediaPicker(w, r, user, company, conn, newMediaPicker(r.URL.Query()))
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "field":
switch r.Method {
case http.MethodGet:
pickMedia(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "upload":
switch r.Method {
case http.MethodGet:
f := newUploadForm()
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
return
}
f := newMediaForm()
if err = f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editMedia(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "content":
switch r.Method {
case http.MethodGet:
httplib.Redirect(w, r, f.Path, http.StatusFound)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
})
}
func serveMediaIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &mediaIndex{
Media: media,
}
page.MustRender(w, r, user, company)
}
func collectMediaEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*mediaEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
from media
where company_id = $1
order by media_id
`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*mediaEntry
for rows.Next() {
entry := &mediaEntry{}
if err = rows.Scan(&entry.ID, &entry.Path); err != nil {
return nil, err
}
media = append(media, entry)
}
return media, nil
}
type mediaEntry struct {
ID uint
Path string
}
type mediaIndex struct {
Media []*mediaEntry
}
func (page *mediaIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/index.gohtml", page)
}
func serveMediaPicker(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, picker *mediaPicker) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
picker.Media = media
picker.MustRender(w, r, user, company)
}
func pickMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
picker := newMediaPicker(r.URL.Query())
picker.MustRenderField(w, r, user, company)
}
type mediaPicker struct {
*uploadForm
Media []*mediaEntry
Field *form.Media
}
func newMediaPicker(query url.Values) *mediaPicker {
return newMediaPickerWithUploadForm(newUploadForm(), query)
}
func newMediaPickerWithUploadForm(f *uploadForm, query url.Values) *mediaPicker {
return &mediaPicker{
uploadForm: f,
Field: &form.Media{
Input: &form.Input{
Name: strings.TrimSpace(query.Get("name")),
Val: strings.TrimSpace(query.Get("value")),
},
Label: strings.TrimSpace(query.Get("label")),
Prompt: strings.TrimSpace(query.Get("prompt")),
},
}
}
func (picker *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
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.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) {
f := newUploadForm()
ok, err := form.HandleMultipart(f, w, r, user)
if err != nil {
return
}
isPicker := r.Form.Has("picker")
if !ok {
if isPicker {
serveMediaPicker(w, r, user, company, conn, newMediaPickerWithUploadForm(f, r.Form))
} else {
f.MustRender(w, r, user, company)
}
return
}
defer f.Close()
bytes := f.MustReadAllFile()
mediaId := conn.MustGetText(r.Context(), "select add_media($1, $2, $3, $4)::text", company.ID, f.File.Filename(), f.File.ContentType, bytes)
if isPicker {
picker := newMediaPickerWithUploadForm(f, r.Form)
picker.Field.Val = mediaId
picker.MustRenderField(w, r, user, company)
} else {
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
}
type uploadForm struct {
File *form.File
}
func newUploadForm() *uploadForm {
return &uploadForm{
File: &form.File{
Name: "media",
MaxSize: 10 * 1 << 20,
},
}
}
func (f *uploadForm) ParseMultipart(w http.ResponseWriter, r *http.Request) error {
maxSize := f.File.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
if err := f.File.FillValue(r); err != nil {
return err
}
return nil
}
func (f *uploadForm) Close() error {
return f.File.Close()
}
func (f *uploadForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.Check(f.File, f.HasFile(), l.GettextNoop("Uploaded file can not be empty."))
return v.AllOK
}
func (f *uploadForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/upload.gohtml", f)
}
func (f *uploadForm) HasFile() bool {
return f.File.HasData()
}
func (f *uploadForm) MustReadAllFile() []byte {
if !f.HasFile() {
return nil
}
bytes, err := io.ReadAll(f.File)
if err != nil {
panic(err)
}
return bytes
}
type mediaForm struct {
*uploadForm
ID int
Path string
OriginalFilename *form.Input
}
func newMediaForm() *mediaForm {
return &mediaForm{
uploadForm: newUploadForm(),
OriginalFilename: &form.Input{
Name: "original_filename",
},
}
}
func (f *mediaForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, "select original_filename, media.path from media where media_id = $1", id)
return row.Scan(&f.OriginalFilename.Val, &f.Path)
}
func (f *mediaForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/form.gohtml", f)
}
func (f *mediaForm) ParseMultipart(w http.ResponseWriter, r *http.Request) error {
if err := f.uploadForm.ParseMultipart(w, r); err != nil {
return err
}
f.OriginalFilename.FillValue(r)
return nil
}
func (f *mediaForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(f.OriginalFilename, l.GettextNoop("Filename can not be empty."))
return v.AllOK
}
func editMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *mediaForm) {
if ok, err := form.HandleMultipart(f, w, r, user); err != nil {
return
} else if !ok {
f.MustRender(w, r, user, company)
return
}
if f.HasFile() {
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select edit_media($1, $2, $3, $4)", f.ID, f.OriginalFilename, f.File.ContentType, bytes)
} else {
conn.MustExec(r.Context(), "select edit_media($1, $2)", f.ID, f.OriginalFilename)
}
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}