/* * SPDX-FileCopyrightText: 2023 jordi fita mas * 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 { name string indexHandler IndexHandler locales locale.Locales } type IndexHandler func(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) func NewAdminHandler(name string, indexHandler IndexHandler, locales locale.Locales) *AdminHandler { return &AdminHandler{ name: name, indexHandler: indexHandler, locales: locales, } } 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) default: httplib.MethodNotAllowed(w, r, http.MethodPost) } case "new": switch r.Method { case http.MethodGet: f := newSlideForm(h.name, 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, 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) { f := newSlideForm(carouselName, 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, "/admin/"+h.name, http.StatusSeeOther) } type slideForm struct { DefaultLang string CarouselName string ID int Media *form.Media Caption form.I18nInput } func newSlideForm(carouselName string, company *auth.Company) *slideForm { return &slideForm{ DefaultLang: company.DefaultLanguage.String(), CarouselName: carouselName, 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, "/admin/"+f.CarouselName, 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) }