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.
356 lines
10 KiB
Go
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)
|
|
}
|