camper/pkg/carousel/admin.go
jordi fita mas b1e3f5017f Add home’s cover carousel
This is a separate carousel from the one displayed at the bottom with
location info; it is, i suppose, a carousel for the hero image.

For the database, it works exactly as the home carousel, but on the
front had to use AlpineJS instead of Slick because it needs to show a
text popping up from the bottom when the slide is show, something i do
not know how to do in Slick.

It now makes no sense to have the carousel inside the “nature” section,
because the heading is no longer in there, and moved it out into a new
“hero” div.

Since i now have two carousels in home, i had to add additional
attributes to carousel.AdminHandler to know which URL to point to when
POSTing, PUTting, or redirecting.
2024-01-16 21:05:52 +01:00

356 lines
10 KiB
Go

/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package carousel
import (
"context"
"fmt"
"net/http"
"strconv"
"github.com/jackc/pgx/v4"
"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 {
IndexURL string
SlidesURL string
name string
indexHandler IndexHandler
}
type IndexHandler func(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn)
func NewAdminHandler(name string, indexHandler IndexHandler) *AdminHandler {
return &AdminHandler{
IndexURL: "/admin/" + name,
SlidesURL: "/admin/" + name + "/slides",
name: name,
indexHandler: indexHandler,
}
}
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.MethodPost:
addSlide(w, r, user, company, conn, h.name, h.IndexURL, h.SlidesURL)
default:
httplib.MethodNotAllowed(w, r, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
f := newSlideForm(h.name, h.IndexURL, h.SlidesURL, company)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "order":
switch r.Method {
case http.MethodPost:
h.orderSlides(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodPost)
}
default:
id, err := strconv.Atoi(head)
if err != nil {
http.NotFound(w, r)
return
}
f := newSlideForm(h.name, h.IndexURL, h.SlidesURL, company)
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:
editSlide(w, r, user, company, conn, f)
case http.MethodDelete:
h.deleteSlide(w, r, user, conn, id)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut, http.MethodDelete)
}
default:
http.NotFound(w, r)
}
}
})
}
func (h *AdminHandler) orderSlides(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
input := r.PostForm["media_id"]
if len(input) > 0 {
var ids []int
for _, s := range input {
if id, err := strconv.Atoi(s); err == nil {
ids = append(ids, id)
} else {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
}
if _, err := conn.Exec(r.Context(), fmt.Sprintf("select order_%[1]s_carousel($1)", h.name), ids); err != nil {
panic(err)
}
}
h.indexHandler(w, r, user, company, conn)
}
type Slide struct {
Media string
Caption string
}
func MustCollectSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale, carouselName string) []*Slide {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select coalesce(i18n.caption, slide.caption) as l10_caption
, media.path
from %[1]s_carousel as slide
join media using (media_id)
left join %[1]s_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1
where media.company_id = $2
order by slide.position, l10_caption
`, carouselName), loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
var carousel []*Slide
for rows.Next() {
slide := &Slide{}
err = rows.Scan(&slide.Caption, &slide.Media)
if err != nil {
panic(err)
}
carousel = append(carousel, slide)
}
if rows.Err() != nil {
panic(rows.Err())
}
return carousel
}
type SlideEntry struct {
Slide
ID int
}
func CollectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn, carouselName string) ([]*SlideEntry, error) {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select media_id
, media.path
, caption
from %[1]s_carousel as carousel
join media using (media_id)
where media.company_id = $1
order by position, caption
`, carouselName), company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var slides []*SlideEntry
for rows.Next() {
slide := &SlideEntry{}
if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption); err != nil {
return nil, err
}
slides = append(slides, slide)
}
return slides, nil
}
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, carouselName string, indexURL, baseURL string) {
f := newSlideForm(carouselName, indexURL, baseURL, company)
f.process(w, r, user, company, conn, func(ctx context.Context, tx *database.Tx) error {
var err error
if f.ID, err = tx.GetInt(ctx, fmt.Sprintf("select add_%s_carousel_slide($1, $2)", f.CarouselName), f.Media, f.Caption[f.DefaultLang]); err != nil {
return err
}
return translateSlide(ctx, tx, company, f)
})
}
func translateSlide(ctx context.Context, tx *database.Tx, company *auth.Company, f *slideForm) error {
for lang := range company.Locales {
l := lang.String()
if l == f.DefaultLang {
continue
}
if _, err := tx.Exec(ctx, fmt.Sprintf("select translate_%s_carousel_slide($1, $2, $3)", f.CarouselName), f.ID, l, f.Caption[l]); err != nil {
return err
}
}
return nil
}
func editSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *slideForm) {
f.process(w, r, user, company, conn, func(ctx context.Context, tx *database.Tx) error {
if _, err := tx.Exec(ctx, fmt.Sprintf("select add_%s_carousel_slide($1, $2)", f.CarouselName), f.Media, f.Caption[f.DefaultLang]); err != nil {
return err
}
if f.ID != f.Media.Int() {
if _, err := tx.Exec(ctx, fmt.Sprintf("update %[1]s_carousel set position = (select position from %[1]s_carousel where media_id = $2) where media_id = $1", f.CarouselName), f.Media, f.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, fmt.Sprintf("update %s_carousel_i18n set media_id = $1 where media_id = $2", f.CarouselName), f.Media, f.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, fmt.Sprintf("select remove_%s_carousel_slide($1)", f.CarouselName), f.ID); err != nil {
return err
}
}
return translateSlide(ctx, tx, company, f)
})
}
func (h *AdminHandler) deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn.MustExec(r.Context(), fmt.Sprintf("select remove_%s_carousel_slide($1)", h.name), id)
httplib.Redirect(w, r, h.IndexURL, http.StatusSeeOther)
}
type slideForm struct {
DefaultLang string
CarouselName string
IndexURL string
SlidesURL string
ID int
Media *form.Media
Caption form.I18nInput
}
func newSlideForm(carouselName string, indexURL string, baseURL string, company *auth.Company) *slideForm {
return &slideForm{
DefaultLang: company.DefaultLanguage.String(),
CarouselName: carouselName,
IndexURL: indexURL,
SlidesURL: baseURL,
Media: &form.Media{
Input: &form.Input{
Name: "media",
},
Label: locale.PgettextNoop("Slide image", "input"),
Prompt: locale.PgettextNoop("Set slide image", "action"),
},
Caption: form.NewI18nInput(company.Locales, "caption"),
}
}
func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
var caption database.RecordArray
row := conn.QueryRow(ctx, fmt.Sprintf(`
select slide.caption
, media_id::text
, array_agg((lang_tag, i18n.caption))
from %[1]s_carousel as slide
left join %[1]s_carousel_i18n as i18n using (media_id)
where media_id = $1
group by slide.caption
, media_id
`, f.CarouselName), pgx.QueryResultFormats{pgx.BinaryFormatCode}, id)
if err := row.Scan(&f.Caption[f.DefaultLang].Val, &f.Media.Val, &caption); err != nil {
return err
}
if err := f.Caption.FillArray(caption); err != nil {
return err
}
return nil
}
func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, act func(ctx context.Context, tx *database.Tx) error) {
if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
if err := act(r.Context(), tx); err == nil {
tx.MustCommit(r.Context())
} else {
if rErr := tx.Rollback(r.Context()); rErr != nil {
err = rErr
}
panic(err)
}
httplib.Redirect(w, r, f.IndexURL, http.StatusSeeOther)
}
func (f *slideForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Caption.FillValue(r)
f.Media.FillValue(r)
return nil
}
func (f *slideForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
if v.CheckRequired(f.Media.Input, l.GettextNoop("Slide image can not be empty.")) {
if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Slide image must be an image media type.")); err != nil {
return false, err
}
}
return v.AllOK, nil
}
func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "carousel/form.gohtml", f)
}