Make home page’s carousel manageable via the database

I debated with myself whether to create the home_carousel relation or
rather if it would be better to have a single carousel relation for all
pages.  However, i thought that it would be actually harder to maintain
a single relation because i would need an additional column to tell one
carrousel from another, and what would that column be? An enum? A
foreign key to another relation? home_carousel carries no such issues.

I was starting to duplicate logic all over the packages, such as the
way to encode media paths or “localization” (l10n) input fields.
Therefore, i refactorized them.

In the case of media path, i added a function that accepts rows of
media, because always need the same columns from the row, and it was
yet another repetition if i needed to pass them all the time.  Plus,
these kind of functions can be called as `table.function`, that make
them look like columns from the table; if PostgreSQL implemented virtual
generated columns, i would have used that instead.

I am not sure whether that media_path function can be immutable. An
immutable function is “guaranteed to return the same results given the
same arguments forever”, which would be true if the inputs where the
hash and the original_filename columns, instead of the whole rows, but
i left it as static because i did not know whether PostgreSQL interprets
the “same row but with different values” as a different input.  That is,
whether PostgreSQL’s concept of row is the actual tuple or the space
that has a rowid, irrespective of contents; in the latter case, the
function can not be immutable.  Just to be in the safe side, i left it
stable.

The home page was starting to grow a bit too much inside the app
package, new that it has its own admin handler, and moved it all to a
separate package.
This commit is contained in:
jordi fita mas 2023-09-15 01:05:38 +02:00
parent 7330ae83ec
commit f746c82b46
54 changed files with 1732 additions and 169 deletions

View File

@ -29,6 +29,34 @@ values (52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plo
, (52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64')) , (52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64'))
, (52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64')) , (52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64'))
, (52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64')) , (52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64'))
, (52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64'))
, (52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64'))
, (52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64'))
, (52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64'))
, (52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64'))
, (52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64'))
, (52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64'))
, (52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64'))
, (52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64'))
;
insert into home_carousel (media_id, caption)
values (66, 'Volcà de Santa Margarida')
, (67, 'Gorga fosca Sadernes')
, (68, 'Castellfollit de la Roca')
, (69, 'Besalú')
, (70, 'Santa Pau')
, (71, 'Banyoles')
, (72, 'Girona')
, (73, 'Costa Brava')
, (74, 'Barcelona')
;
insert into home_carousel_i18n (media_id, lang_tag, caption)
values (66, 'en', 'Santa Margarida volcano')
, (66, 'es', 'Volcán de Santa Margarida')
, (67, 'en', 'Sadernes dark gorge')
, (67, 'es', 'Piletón oscuro Sadernes')
; ;
alter sequence campsite_type_campsite_type_id_seq restart with 72; alter sequence campsite_type_campsite_type_id_seq restart with 72;

View File

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 311 KiB

View File

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View File

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View File

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 220 KiB

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

View File

@ -0,0 +1,25 @@
-- Deploy camper:add_home_carousel_slide to pg
-- requires: roles
-- requires: schema_camper
-- requires: home_carousel
begin;
set search_path to camper, public;
create or replace function add_home_carousel_slide(media_id integer, caption text) returns integer as
$$
insert into home_carousel (media_id, caption)
values (media_id, coalesce(caption, ''))
on conflict (media_id) do update
set caption = excluded.caption
returning media_id
;
$$
language sql
;
revoke execute on function add_home_carousel_slide(integer, text) from public;
grant execute on function add_home_carousel_slide(integer, text) to admin;
commit;

53
deploy/home_carousel.sql Normal file
View File

@ -0,0 +1,53 @@
-- Deploy camper:home_carousel to pg
-- requires: roles
-- requires: schema_public
-- requires: company
-- requires: media
-- requires: user_profile
begin;
set search_path to camper, public;
create table home_carousel (
media_id integer not null references media primary key,
caption text not null
);
grant select on table home_carousel to guest;
grant select on table home_carousel to employee;
grant select, insert, update, delete on table home_carousel to admin;
alter table home_carousel enable row level security;
create policy guest_ok
on home_carousel
for select
using (true)
;
create policy insert_to_company
on home_carousel
for insert
with check (
exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id)
)
;
create policy update_company
on home_carousel
for update
using (
exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id)
)
;
create policy delete_from_company
on home_carousel
for delete
using (
exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id)
)
;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy camper:home_carousel_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: home_carousel
-- requires: language
begin;
set search_path to camper, public;
create table home_carousel_i18n (
media_id integer not null references home_carousel,
lang_tag text not null references language,
caption text not null,
primary key (media_id, lang_tag)
);
grant select on table home_carousel_i18n to guest;
grant select on table home_carousel_i18n to employee;
grant select, insert, update, delete on table home_carousel_i18n to admin;
commit;

23
deploy/media_path.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy camper:media_path to pg
-- requires: roles
-- requires: schema_camper
-- requires: media
begin;
set search_path to camper, public;
create or replace function path(media) returns text as
$$
select '/media/' || encode($1.hash, 'hex') || '/' || $1.original_filename;
$$
language sql
stable
;
revoke execute on function path(media) from public;
grant execute on function path(media) to guest;
grant execute on function path(media) to employee;
grant execute on function path(media) to admin;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy camper:remove_home_carousel_slide to pg
-- requires: roles
-- requires: schema_camper
-- requires: home_carousel
-- requires: home_carousel_i18n
begin;
set search_path to camper, public;
create or replace function remove_home_carousel_slide (media_id integer) returns void as
$$
delete from home_carousel_i18n where media_id = $1;
delete from home_carousel where media_id = $1;
$$
language sql
;
revoke execute on function remove_home_carousel_slide (integer) from public;
grant execute on function remove_home_carousel_slide (integer) to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:translate_home_carousel_slide to pg
-- requires: roles
-- requires: schema_camper
-- requires: home_carousel_i18n
begin;
set search_path to camper, public;
create or replace function translate_home_carousel_slide (media_id integer, lang_tag text, caption text) returns void as
$$
insert into home_carousel_i18n (media_id, lang_tag, caption)
values (media_id, lang_tag, coalesce(caption, ''))
on conflict (media_id, lang_tag) do update
set caption = excluded.caption
$$
language sql
;
revoke execute on function translate_home_carousel_slide(integer, text, text) from public;
grant execute on function translate_home_carousel_slide(integer, text, text) to admin;
commit;

View File

@ -12,6 +12,7 @@ import (
"dev.tandem.ws/tandem/camper/pkg/campsite" "dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/company" "dev.tandem.ws/tandem/camper/pkg/company"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/home"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/season" "dev.tandem.ws/tandem/camper/pkg/season"
@ -21,6 +22,7 @@ import (
type adminHandler struct { type adminHandler struct {
campsite *campsite.AdminHandler campsite *campsite.AdminHandler
company *company.AdminHandler company *company.AdminHandler
home *home.AdminHandler
season *season.AdminHandler season *season.AdminHandler
} }
@ -28,6 +30,7 @@ func newAdminHandler(locales locale.Locales) *adminHandler {
return &adminHandler{ return &adminHandler{
campsite: campsite.NewAdminHandler(locales), campsite: campsite.NewAdminHandler(locales),
company: company.NewAdminHandler(), company: company.NewAdminHandler(),
home: home.NewAdminHandler(locales),
season: season.NewAdminHandler(), season: season.NewAdminHandler(),
} }
} }
@ -52,6 +55,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
h.campsite.Handler(user, company, conn).ServeHTTP(w, r) h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "company": case "company":
h.company.Handler(user, company, conn).ServeHTTP(w, r) h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "home":
h.home.Handler(user, company, conn).ServeHTTP(w, r)
case "seasons": case "seasons":
h.season.Handler(user, company, conn).ServeHTTP(w, r) h.season.Handler(user, company, conn).ServeHTTP(w, r)
case "": case "":

View File

@ -6,23 +6,23 @@
package app package app
import ( import (
"context"
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite" "dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/home"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
) )
type publicHandler struct { type publicHandler struct {
home *home.PublicHandler
campsite *campsite.PublicHandler campsite *campsite.PublicHandler
} }
func newPublicHandler() *publicHandler { func newPublicHandler() *publicHandler {
return &publicHandler{ return &publicHandler{
home: home.NewPublicHandler(),
campsite: campsite.NewPublicHandler(), campsite: campsite.NewPublicHandler(),
} }
} }
@ -33,8 +33,7 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head { switch head {
case "": case "":
home := newHomePage() h.home.Handler(user, company, conn).ServeHTTP(w, r)
home.MustRender(w, r, user, company, conn)
case "campsites": case "campsites":
h.campsite.Handler(user, company, conn).ServeHTTP(w, r) h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
default: default:
@ -42,58 +41,3 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da
} }
}) })
} }
type homePage struct {
*template.PublicPage
CampsiteTypes []*campsiteType
}
func newHomePage() *homePage {
return &homePage{template.NewPublicPage(), nil}
}
func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale)
template.MustRenderPublic(w, r, user, company, "home.gohtml", p)
}
type campsiteType struct {
Label string
HRef string
Media string
}
func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType {
rows, err := conn.Query(ctx, `
select coalesce(i18n.name, campsite_type.name) as l10_name
, '/campsites/types/' || slug
, '/media/' || encode(hash, 'hex') || '/' || original_filename
from campsite_type
left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and lang_tag = $1
join media using (media_id)
where campsite_type.company_id = $2
and campsite_type.active
`, loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
localePath := "/" + loc.Language.String()
var items []*campsiteType
for rows.Next() {
item := &campsiteType{}
err = rows.Scan(&item.Label, &item.HRef, &item.Media)
if err != nil {
panic(err)
}
item.HRef = localePath + item.HRef
items = append(items, item)
}
if rows.Err() != nil {
panic(rows.Err())
}
return items
}

View File

@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
@ -66,12 +65,12 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
} }
panic(err) panic(err)
} }
h.TypeHandler(user, company, conn, f).ServeHTTP(w, r) h.typeHandler(user, company, conn, f).ServeHTTP(w, r)
} }
}) })
} }
func (h *AdminHandler) TypeHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) http.Handler { func (h *AdminHandler) typeHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path) head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
@ -87,18 +86,13 @@ func (h *AdminHandler) TypeHandler(user *auth.User, company *auth.Company, conn
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
} }
default: default:
tag, err := language.Parse(head) loc, ok := h.locales.Get(head)
if err != nil {
http.NotFound(w, r)
return
}
loc, ok := h.locales[tag]
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
l10n := newTypeL10nForm(f, loc) l10n := newTypeL10nForm(f, loc)
if err = l10n.FillFromDatabase(r.Context(), conn); err != nil { if err := l10n.FillFromDatabase(r.Context(), conn); err != nil {
panic(err) panic(err)
} }
switch r.Method { switch r.Method {
@ -267,7 +261,7 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl
row := conn.QueryRow(ctx, ` row := conn.QueryRow(ctx, `
select name select name
, description , description
, encode(hash, 'hex') , media.path
, active , active
from campsite_type from campsite_type
join media using (media_id) join media using (media_id)

View File

@ -17,34 +17,19 @@ import (
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
) )
type L10nInput struct {
form.Input
Source string
}
type typeL10nForm struct { type typeL10nForm struct {
Locale *locale.Locale Locale *locale.Locale
Slug string Slug string
Name *L10nInput Name *form.L10nInput
Description *L10nInput Description *form.L10nInput
} }
func newTypeL10nForm(f *typeForm, loc *locale.Locale) *typeL10nForm { func newTypeL10nForm(f *typeForm, loc *locale.Locale) *typeL10nForm {
return &typeL10nForm{ return &typeL10nForm{
Locale: loc, Locale: loc,
Slug: f.Slug, Slug: f.Slug,
Name: &L10nInput{ Name: f.Name.L10nInput(),
Input: form.Input{ Description: f.Description.L10nInput(),
Name: f.Name.Name,
},
Source: f.Name.Val,
},
Description: &L10nInput{
Input: form.Input{
Name: f.Description.Name,
},
Source: f.Description.Val,
},
} }
} }

View File

@ -27,3 +27,17 @@ func (input *Input) FillValue(r *http.Request) {
func (input *Input) Value() (driver.Value, error) { func (input *Input) Value() (driver.Value, error) {
return input.Val, nil return input.Val, nil
} }
func (input *Input) L10nInput() *L10nInput {
return &L10nInput{
Input: Input{
Name: input.Name,
},
Source: input.Val,
}
}
type L10nInput struct {
Input
Source string
}

64
pkg/home/admin.go Normal file
View File

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
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 {
locales locale.Locales
}
func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{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.MethodGet:
serveHomeIndex(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "slides":
h.carouselHandler(user, company, conn).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := collectSlideEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &homeIndex{
Slides: slides,
}
page.MustRender(w, r, user, company)
}
type homeIndex struct {
Slides []*slideEntry
}
func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "home/index.gohtml", page)
}

311
pkg/home/carousel.go Normal file
View File

@ -0,0 +1,311 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
import (
"context"
"io"
"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"
)
func (h *AdminHandler) carouselHandler(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)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "new":
switch r.Method {
case http.MethodGet:
f := newSlideForm()
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)
}
f := newSlideForm()
if err := f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
var langTag string
langTag, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch langTag {
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:
deleteSlide(w, r, user, conn, id)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
loc, ok := h.locales.Get(langTag)
if !ok {
http.NotFound(w, r)
return
}
l10n := newSlideL10nForm(f, loc)
if err := l10n.FillFromDatabase(r.Context(), conn); err != nil {
panic(err)
}
switch r.Method {
case http.MethodGet:
l10n.MustRender(w, r, user, company)
case http.MethodPut:
editSlideL10n(w, r, user, company, conn, l10n)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
}
}
})
}
type carouselSlide struct {
Media string
Caption string
}
func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*carouselSlide {
rows, err := conn.Query(ctx, `
select coalesce(i18n.caption, slide.caption) as l10_caption
, media.path
from home_carousel as slide
join media using (media_id)
left join home_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1
where media.company_id = $2
`, loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
var carousel []*carouselSlide
for rows.Next() {
slide := &carouselSlide{}
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 {
carouselSlide
ID int
Translations []*translation
}
type translation struct {
Language string
Endonym string
Missing bool
}
func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
, caption
, array_agg((lang_tag, endonym, not exists (select 1 from home_carousel_i18n as i18n where i18n.media_id = home_carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from home_carousel
join media using (media_id)
join company using (company_id)
, language
where lang_tag <> default_lang_tag
and language.selectable
and media.company_id = $1
group by media_id
, media.path
, caption
order by caption
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var slides []*slideEntry
for rows.Next() {
slide := &slideEntry{}
var translations database.RecordArray
if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil {
return nil, err
}
for _, el := range translations.Elements {
slide.Translations = append(slide.Translations, &translation{
el.Fields[0].Get().(string),
el.Fields[1].Get().(string),
el.Fields[2].Get().(bool),
})
}
slides = append(slides, slide)
}
return slides, nil
}
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newSlideForm()
editSlide(w, r, user, company, conn, f)
}
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, false, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
if bytes == nil {
conn.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption)
} else {
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
f.ID = tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption)
tx.MustCommit(ctx)
}
})
}
func 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(), "select remove_home_carousel_slide($1)", id)
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
type slideForm struct {
ID int
Media *form.File
Caption *form.Input
}
func newSlideForm() *slideForm {
return &slideForm{
Media: &form.File{
Name: "media",
MaxSize: 1 << 20,
},
Caption: &form.Input{
Name: "caption",
},
}
}
func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, `
select caption
, media.path
from home_carousel
join media using (media_id)
where media_id = $1
`, id)
return row.Scan(&f.Caption.Val, &f.Media.Val)
}
func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, act func(ctx context.Context)) {
if err := f.Parse(w, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer f.Close()
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if !f.Valid(user.Locale, mediaRequired) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
act(r.Context())
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (f *slideForm) Parse(w http.ResponseWriter, r *http.Request) error {
maxSize := f.Media.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
f.Caption.FillValue(r)
if err := f.Media.FillValue(r); err != nil {
return err
}
return nil
}
func (f *slideForm) Close() error {
return f.Media.Close()
}
func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool {
v := form.NewValidator(l)
if f.HasMediaFile() {
v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image."))
} else {
v.Check(f.Media, !mediaRequired, l.GettextNoop("Slide image can not be empty."))
}
return v.AllOK
}
func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "home/carousel/form.gohtml", f)
}
func (f *slideForm) HasMediaFile() bool {
return f.Media.HasData()
}
func (f *slideForm) MustReadAllMedia() []byte {
if !f.HasMediaFile() {
return nil
}
bytes, err := io.ReadAll(f.Media)
if err != nil {
panic(err)
}
return bytes
}

79
pkg/home/l10n.go Normal file
View File

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
import (
"context"
"net/http"
"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 slideL10nForm struct {
Locale *locale.Locale
ID int
Caption *form.L10nInput
}
func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm {
return &slideL10nForm{
Locale: loc,
ID: f.ID,
Caption: f.Caption.L10nInput(),
}
}
func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, `
select coalesce(i18n.caption, '') as l10n_caption
from home_carousel
left join home_carousel_i18n as i18n on home_carousel.media_id = i18n.media_id and i18n.lang_tag = $1
where home_carousel.media_id = $2
`, l10n.Locale.Language, l10n.ID)
return row.Scan(&l10n.Caption.Val)
}
func (l10n *slideL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "home/carousel/l10n.gohtml", l10n)
}
func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) {
if err := l10n.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 !l10n.Valid(user.Locale) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
l10n.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select translate_home_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption)
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (l10n *slideL10nForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
l10n.Caption.FillValue(r)
return nil
}
func (l10n *slideL10nForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
return v.AllOK
}

93
pkg/home/public.go Normal file
View File

@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
import (
"context"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type PublicHandler struct {
}
func NewPublicHandler() *PublicHandler {
return &PublicHandler{}
}
func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
home := newHomePage()
home.MustRender(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
})
}
type homePage struct {
*template.PublicPage
CampsiteTypes []*campsiteType
Carousel []*carouselSlide
}
func newHomePage() *homePage {
return &homePage{PublicPage: template.NewPublicPage()}
}
func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale)
p.Carousel = mustCollectCarouselSlides(r.Context(), company, conn, user.Locale)
template.MustRenderPublic(w, r, user, company, "home.gohtml", p)
}
type campsiteType struct {
Label string
HRef string
Media string
}
func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType {
rows, err := conn.Query(ctx, `
select coalesce(i18n.name, campsite_type.name) as l10_name
, '/campsites/types/' || slug
, media.path
from campsite_type
left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and lang_tag = $1
join media using (media_id)
where campsite_type.company_id = $2
and campsite_type.active
`, loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
localePath := "/" + loc.Language.String()
var items []*campsiteType
for rows.Next() {
item := &campsiteType{}
err = rows.Scan(&item.Label, &item.HRef, &item.Media)
if err != nil {
panic(err)
}
item.HRef = localePath + item.HRef
items = append(items, item)
}
if rows.Err() != nil {
panic(rows.Err())
}
return items
}

View File

@ -33,6 +33,15 @@ func (m Locales) Tags() []language.Tag {
return keys return keys
} }
func (m Locales) Get(lang string) (loc *Locale, ok bool) {
tag, err := language.Parse(lang)
if err != nil {
return
}
loc, ok = m[tag]
return
}
func GetAll(ctx context.Context, db *database.DB) (Locales, error) { func GetAll(ctx context.Context, db *database.DB) (Locales, error) {
availableLanguages, err := getAvailableLanguages(ctx, db) availableLanguages, err := getAvailableLanguages(ctx, db)
if err != nil { if err != nil {

103
po/ca.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-12 20:18+0200\n" "POT-Creation-Date: 2023-09-15 00:05+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -56,15 +56,7 @@ msgstr "A menys duna hora de <strong>Girona</strong>, a una de <strong>La Bis
msgid "Discover the surroundings" msgid "Discover the surroundings"
msgstr "Descobreix lentorn" msgstr "Descobreix lentorn"
#: web/templates/public/home.gohtml:44 web/templates/public/home.gohtml:48 #: web/templates/public/home.gohtml:54
#: web/templates/public/home.gohtml:52 web/templates/public/home.gohtml:56
#: web/templates/public/home.gohtml:60 web/templates/public/home.gohtml:64
#: web/templates/public/home.gohtml:68 web/templates/public/home.gohtml:72
#: web/templates/public/home.gohtml:76
msgid "Legend"
msgstr "Llegenda"
#: web/templates/public/home.gohtml:80
msgid "Come and enjoy!" msgid "Come and enjoy!"
msgstr "Vine a gaudir!" msgstr "Vine a gaudir!"
@ -116,6 +108,7 @@ msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71 #: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77 #: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65 #: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action" msgctxt "action"
msgid "Update" msgid "Update"
msgstr "Actualitza" msgstr "Actualitza"
@ -123,6 +116,7 @@ msgstr "Actualitza"
#: web/templates/admin/campsite/form.gohtml:73 #: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79 #: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67 #: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action" msgctxt "action"
msgid "Add" msgid "Add"
msgstr "Afegeix" msgstr "Afegeix"
@ -192,6 +186,7 @@ msgid "Name"
msgstr "Nom" msgstr "Nom"
#: web/templates/admin/campsite/type/form.gohtml:59 #: web/templates/admin/campsite/type/form.gohtml:59
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input" msgctxt "input"
msgid "Cover image" msgid "Cover image"
msgstr "Imatge de portada" msgstr "Imatge de portada"
@ -221,6 +216,7 @@ msgid "Name"
msgstr "Nom" msgstr "Nom"
#: web/templates/admin/campsite/type/index.gohtml:19 #: web/templates/admin/campsite/type/index.gohtml:19
#: web/templates/admin/home/index.gohtml:20
msgctxt "campsite type" msgctxt "campsite type"
msgid "Translations" msgid "Translations"
msgstr "Traduccions" msgstr "Traduccions"
@ -237,16 +233,19 @@ msgstr "Traducció del tipus dallotjament a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22 #: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34 #: web/templates/admin/campsite/type/l10n.gohtml:34
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:" msgid "Source:"
msgstr "Origen:" msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24 #: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37 #: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input" msgctxt "input"
msgid "Translation:" msgid "Translation:"
msgstr "Traducció:" msgstr "Traducció:"
#: web/templates/admin/campsite/type/l10n.gohtml:46 #: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action" msgctxt "action"
msgid "Translate" msgid "Translate"
msgstr "Tradueix" msgstr "Tradueix"
@ -442,6 +441,69 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" msgstr "Surt"
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
msgctxt "title"
msgid "Home Page"
msgstr "Pàgina dinici"
#: web/templates/admin/home/carousel/form.gohtml:8
#: web/templates/admin/home/carousel/form.gohtml:27
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edició de la diapositiva del carrusel"
#: web/templates/admin/home/carousel/form.gohtml:10
#: web/templates/admin/home/carousel/form.gohtml:29
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nova diapositiva del carrusel"
#: web/templates/admin/home/carousel/form.gohtml:48
#: web/templates/admin/home/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Llegenda"
#: web/templates/admin/home/carousel/l10n.gohtml:7
#: web/templates/admin/home/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducció de la diapositiva del carrusel a %s"
#: web/templates/admin/home/index.gohtml:12
msgctxt "title"
msgid "Carousel"
msgstr "Carrusel"
#: web/templates/admin/home/index.gohtml:13
msgctxt "action"
msgid "Add slide"
msgstr "Afegeix diapositiva"
#: web/templates/admin/home/index.gohtml:18
msgctxt "header"
msgid "Image"
msgstr "Imatge"
#: web/templates/admin/home/index.gohtml:19
msgctxt "header"
msgid "Caption"
msgstr "Llegenda"
#: web/templates/admin/home/index.gohtml:21
msgctxt "campsite type"
msgid "Actions"
msgstr "Accions"
#: web/templates/admin/home/index.gohtml:40
msgctxt "action"
msgid "Delete"
msgstr "Esborra"
#: web/templates/admin/home/index.gohtml:48
msgid "No slides added yet."
msgstr "No sha afegit cap diapositiva encara."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc." msgstr "No podeu deixar el correu-e en blanc."
@ -463,8 +525,8 @@ msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105 #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:90
#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203 #: pkg/campsite/types/admin.go:293 pkg/season/admin.go:203
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
@ -476,15 +538,15 @@ msgstr "La confirmació no es correspon amb la contrasenya."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid." msgstr "Lidioma escollit no és vàlid."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303 #: pkg/app/user.go:253 pkg/campsite/types/admin.go:295 pkg/home/carousel.go:286
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/app/admin.go:44 #: pkg/app/admin.go:47
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Accés prohibit" msgstr "Accés prohibit"
#: pkg/campsite/types/admin.go:305 #: pkg/campsite/types/admin.go:297
msgid "Cover image can not be empty." msgid "Cover image can not be empty."
msgstr "No podeu deixar la imatge de portada en blanc." msgstr "No podeu deixar la imatge de portada en blanc."
@ -568,13 +630,16 @@ msgstr "No podeu deixar el format del número de factura en blanc."
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats." msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."
#: pkg/home/carousel.go:288
msgid "Slide image can not be empty."
msgstr "No podeu deixar la imatge de la diapositiva en blanc."
#~ msgid "Legend"
#~ msgstr "Llegenda"
#~ msgid "Environment" #~ msgid "Environment"
#~ msgstr "Entorn" #~ msgstr "Entorn"
#~ msgctxt "title"
#~ msgid "New Page"
#~ msgstr "Nova pàgina"
#~ msgctxt "input" #~ msgctxt "input"
#~ msgid "Title" #~ msgid "Title"
#~ msgstr "Títol" #~ msgstr "Títol"

103
po/es.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-12 20:18+0200\n" "POT-Creation-Date: 2023-09-15 00:05+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -56,15 +56,7 @@ msgstr "A menos de una hora de <strong>Girona</strong>, a una de <strong>La Bisb
msgid "Discover the surroundings" msgid "Discover the surroundings"
msgstr "Descubre el entorno" msgstr "Descubre el entorno"
#: web/templates/public/home.gohtml:44 web/templates/public/home.gohtml:48 #: web/templates/public/home.gohtml:54
#: web/templates/public/home.gohtml:52 web/templates/public/home.gohtml:56
#: web/templates/public/home.gohtml:60 web/templates/public/home.gohtml:64
#: web/templates/public/home.gohtml:68 web/templates/public/home.gohtml:72
#: web/templates/public/home.gohtml:76
msgid "Legend"
msgstr "Leyenda"
#: web/templates/public/home.gohtml:80
msgid "Come and enjoy!" msgid "Come and enjoy!"
msgstr "¡Ven a disfrutar!" msgstr "¡Ven a disfrutar!"
@ -116,6 +108,7 @@ msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71 #: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77 #: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65 #: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action" msgctxt "action"
msgid "Update" msgid "Update"
msgstr "Actualizar" msgstr "Actualizar"
@ -123,6 +116,7 @@ msgstr "Actualizar"
#: web/templates/admin/campsite/form.gohtml:73 #: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79 #: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67 #: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action" msgctxt "action"
msgid "Add" msgid "Add"
msgstr "Añadir" msgstr "Añadir"
@ -192,6 +186,7 @@ msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: web/templates/admin/campsite/type/form.gohtml:59 #: web/templates/admin/campsite/type/form.gohtml:59
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input" msgctxt "input"
msgid "Cover image" msgid "Cover image"
msgstr "Imagen de portada" msgstr "Imagen de portada"
@ -221,6 +216,7 @@ msgid "Name"
msgstr "Nombre" msgstr "Nombre"
#: web/templates/admin/campsite/type/index.gohtml:19 #: web/templates/admin/campsite/type/index.gohtml:19
#: web/templates/admin/home/index.gohtml:20
msgctxt "campsite type" msgctxt "campsite type"
msgid "Translations" msgid "Translations"
msgstr "Traducciones" msgstr "Traducciones"
@ -237,16 +233,19 @@ msgstr "Traducción de tipo de alojamiento a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22 #: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34 #: web/templates/admin/campsite/type/l10n.gohtml:34
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:" msgid "Source:"
msgstr "Origen:" msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24 #: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37 #: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input" msgctxt "input"
msgid "Translation:" msgid "Translation:"
msgstr "Traducción" msgstr "Traducción"
#: web/templates/admin/campsite/type/l10n.gohtml:46 #: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action" msgctxt "action"
msgid "Translate" msgid "Translate"
msgstr "Traducir" msgstr "Traducir"
@ -442,6 +441,69 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
msgctxt "title"
msgid "Home Page"
msgstr "Página de inicio"
#: web/templates/admin/home/carousel/form.gohtml:8
#: web/templates/admin/home/carousel/form.gohtml:27
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edición de la diapositiva del carrusel"
#: web/templates/admin/home/carousel/form.gohtml:10
#: web/templates/admin/home/carousel/form.gohtml:29
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nueva diapositiva del carrusel"
#: web/templates/admin/home/carousel/form.gohtml:48
#: web/templates/admin/home/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Leyenda"
#: web/templates/admin/home/carousel/l10n.gohtml:7
#: web/templates/admin/home/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducción de la diapositiva de carrusel a %s"
#: web/templates/admin/home/index.gohtml:12
msgctxt "title"
msgid "Carousel"
msgstr "Carrusel"
#: web/templates/admin/home/index.gohtml:13
msgctxt "action"
msgid "Add slide"
msgstr "Añadir diapositiva"
#: web/templates/admin/home/index.gohtml:18
msgctxt "header"
msgid "Image"
msgstr "Imagen"
#: web/templates/admin/home/index.gohtml:19
msgctxt "header"
msgid "Caption"
msgstr "Leyenda"
#: web/templates/admin/home/index.gohtml:21
msgctxt "campsite type"
msgid "Actions"
msgstr "Acciones"
#: web/templates/admin/home/index.gohtml:40
msgctxt "action"
msgid "Delete"
msgstr "Borrar"
#: web/templates/admin/home/index.gohtml:48
msgid "No slides added yet."
msgstr "No se ha añadido ninguna diapositiva todavía."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." msgstr "No podéis dejar el correo-e en blanco."
@ -463,8 +525,8 @@ msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105 #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:90
#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203 #: pkg/campsite/types/admin.go:293 pkg/season/admin.go:203
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." msgstr "No podéis dejar el nombre en blanco."
@ -476,15 +538,15 @@ msgstr "La confirmación no se corresponde con la contraseña."
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido." msgstr "El idioma escogido no es válido."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303 #: pkg/app/user.go:253 pkg/campsite/types/admin.go:295 pkg/home/carousel.go:286
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/app/admin.go:44 #: pkg/app/admin.go:47
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Acceso prohibido" msgstr "Acceso prohibido"
#: pkg/campsite/types/admin.go:305 #: pkg/campsite/types/admin.go:297
msgid "Cover image can not be empty." msgid "Cover image can not be empty."
msgstr "No podéis dejar la imagen de portada en blanco." msgstr "No podéis dejar la imagen de portada en blanco."
@ -568,13 +630,16 @@ msgstr "No podéis dejar el formato de número de factura en blanco."
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados." msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
#: pkg/home/carousel.go:288
msgid "Slide image can not be empty."
msgstr "No podéis dejar la imagen de la diapositiva en blanco."
#~ msgid "Legend"
#~ msgstr "Leyenda"
#~ msgid "Environment" #~ msgid "Environment"
#~ msgstr "Entorno" #~ msgstr "Entorno"
#~ msgctxt "title"
#~ msgid "New Page"
#~ msgstr "Nueva página"
#~ msgctxt "input" #~ msgctxt "input"
#~ msgid "Title" #~ msgid "Title"
#~ msgstr "Título" #~ msgstr "Título"

View File

@ -0,0 +1,7 @@
-- Revert camper:add_home_carousel_slide from pg
begin;
drop function if exists camper.add_home_carousel_slide(integer, text);
commit;

7
revert/home_carousel.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:home_carousel from pg
begin;
drop table if exists camper.home_carousel;
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:home_carousel_i18n from pg
begin;
drop table if exists camper.home_carousel_i18n;
commit;

7
revert/media_path.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:media_path from pg
begin;
drop function if exists camper.path(camper.media);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:remove_home_carousel_slide from pg
begin;
drop function if exists camper.remove_home_carousel_slide(integer);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:translate_home_carousel_slide from pg
begin;
drop function if exists camper.translate_home_carousel_slide(integer, text, text);
commit;

View File

@ -42,6 +42,7 @@ change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jord
media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blog> # Add domain for media type media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blog> # Add domain for media type
media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media
add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media
media_path [roles schema_camper media] 2023-09-13T22:50:14Z jordi fita mas <jordi@tandem.blog> # Add function to get the URL path of a media
campsite_type [roles schema_camper company media user_profile] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type campsite_type [roles schema_camper company media user_profile] 2023-07-31T11:20:29Z jordi fita mas <jordi@tandem.blog> # Add relation of campsite type
campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas <jordi@tandem.blog> # Add relation for campsite_type translations campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas <jordi@tandem.blog> # Add relation for campsite_type translations
add_campsite_type [roles schema_camper campsite_type company] 2023-08-04T16:14:48Z jordi fita mas <jordi@tandem.blog> # Add function to create campsite types add_campsite_type [roles schema_camper campsite_type company] 2023-08-04T16:14:48Z jordi fita mas <jordi@tandem.blog> # Add function to create campsite types
@ -57,3 +58,8 @@ to_color [roles schema_camper color] 2023-08-16T13:11:32Z jordi fita mas <jordi@
season [roles schema_camper company user_profile] 2023-08-16T13:21:28Z jordi fita mas <jordi@tandem.blog> # Add relation of (tourist) season season [roles schema_camper company user_profile] 2023-08-16T13:21:28Z jordi fita mas <jordi@tandem.blog> # Add relation of (tourist) season
add_season [roles schema_camper season color to_integer] 2023-08-16T16:59:17Z jordi fita mas <jordi@tandem.blog> # Add function to create seasons add_season [roles schema_camper season color to_integer] 2023-08-16T16:59:17Z jordi fita mas <jordi@tandem.blog> # Add function to create seasons
edit_season [roles schema_camper season color to_integer] 2023-08-16T17:09:02Z jordi fita mas <jordi@tandem.blog> # Add function to update seasons edit_season [roles schema_camper season color to_integer] 2023-08-16T17:09:02Z jordi fita mas <jordi@tandem.blog> # Add function to update seasons
home_carousel [roles schema_public company media user_profile] 2023-09-13T17:16:34Z jordi fita mas <jordi@tandem.blog> # Add relation for home pages image carousel
home_carousel_i18n [roles schema_camper home_carousel language] 2023-09-13T23:22:42Z jordi fita mas <jordi@tandem.blog> # Add relation for home carousel translations
add_home_carousel_slide [roles schema_camper home_carousel] 2023-09-14T17:49:21Z jordi fita mas <jordi@tandem.blog> # Add function to create slides for the home carousel
translate_home_carousel_slide [roles schema_camper home_carousel_i18n] 2023-09-14T18:17:36Z jordi fita mas <jordi@tandem.blog> # Add function to translate a home carousel slider
remove_home_carousel_slide [roles schema_camper home_carousel home_carousel_i18n] 2023-09-14T21:57:48Z jordi fita mas <jordi@tandem.blog> # Add function to remove sliders from the home carousel

View File

@ -0,0 +1,75 @@
-- Test add_home_carousel_slide
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
set search_path to camper, public;
select plan(14);
select has_function('camper', 'add_home_carousel_slide', array['integer', 'text']);
select function_lang_is('camper', 'add_home_carousel_slide', array['integer', 'text'], 'sql');
select function_returns('camper', 'add_home_carousel_slide', array['integer', 'text'], 'integer');
select isnt_definer('camper', 'add_home_carousel_slide', array['integer', 'text']);
select volatility_is('camper', 'add_home_carousel_slide', array['integer', 'text'], 'volatile');
select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into home_carousel (media_id, caption)
values (5, 'Previous caption')
;
select lives_ok(
$$ select add_home_carousel_slide(6, 'A caption') $$,
'Should be able to add a carousel slide with a caption'
);
select lives_ok(
$$ select add_home_carousel_slide(7, null) $$,
'Should be able to add a carousel slide without caption'
);
select lives_ok(
$$ select add_home_carousel_slide(5, 'New caption') $$,
'Should be able to overwrite a slide with a new caption'
);
select bag_eq(
$$ select media_id, caption from home_carousel $$,
$$ values (5, 'New caption')
, (6, 'A caption')
, (7, '')
$$,
'Should have all three slides'
);
select is_empty(
$$ select * from home_carousel_i18n $$,
'Should not have added any translation'
);
select *
from finish();
rollback;

178
test/home_carousel.sql Normal file
View File

@ -0,0 +1,178 @@
-- Test home_carousel
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(30);
set search_path to camper, public;
select has_table('home_carousel');
select has_pk('home_carousel');
select table_privs_are('home_carousel', 'guest', array['SELECT']);
select table_privs_are('home_carousel', 'employee', array['SELECT']);
select table_privs_are('home_carousel', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('home_carousel', 'authenticator', array[]::text[]);
select has_column('home_carousel', 'media_id');
select col_is_pk('home_carousel', 'media_id');
select col_is_fk('home_carousel', 'media_id');
select fk_ok('home_carousel', 'media_id', 'media', 'media_id');
select col_type_is('home_carousel', 'media_id', 'integer');
select col_not_null('home_carousel', 'media_id');
select col_hasnt_default('home_carousel', 'media_id');
select has_column('home_carousel', 'caption');
select col_type_is('home_carousel', 'caption', 'text');
select col_not_null('home_carousel', 'caption');
select col_hasnt_default('home_carousel', 'caption');
set client_min_messages to warning;
truncate home_carousel cascade;
truncate media cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
truncate auth."user" cascade;
reset client_min_messages;
insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at)
values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month')
, (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month')
;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
, (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into company_user (company_id, user_id, role)
values (2, 1, 'admin')
, (4, 5, 'admin')
;
insert into company_host (company_id, host)
values (2, 'co2')
, (4, 'co4')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values ( 7, 2, 'text2.txt', 'text/plain', 'content2')
, ( 8, 2, 'text3.txt', 'text/plain', 'content3')
, ( 9, 4, 'text4.txt', 'text/plain', 'content4')
, (10, 4, 'text5.txt', 'text/plain', 'content5')
;
insert into home_carousel (media_id, caption)
values (7, 'Caption 7')
, (9, 'Caption 9')
;
prepare carousel_data as
select media_id, caption
from home_carousel
order by media_id, caption;
set role guest;
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (9, 'Caption 9')
$$,
'Everyone should be able to list all carousel media across all companies'
);
reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2');
select lives_ok(
$$ insert into home_carousel(media_id, caption)
values (8, 'Caption 8') $$,
'Admin from company 2 should be able to insert a new carousel media to that company.'
);
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (8, 'Caption 8')
, (9, 'Caption 9')
$$,
'The new row should have been added'
);
select lives_ok(
$$ update home_carousel set caption = 'Caption 8.8' where media_id = 8 $$,
'Admin from company 2 should be able to update carousel media of that company.'
);
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (8, 'Caption 8.8')
, (9, 'Caption 9')
$$,
'The row should have been updated.'
);
select lives_ok(
$$ delete from home_carousel where media_id = 8 $$,
'Admin from company 2 should be able to delete carousel media from that company.'
);
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (9, 'Caption 9')
$$,
'The row should have been deleted.'
);
select throws_ok(
$$ insert into home_carousel (media_id, caption)
values (10, 'Caption 10') $$,
'42501', 'new row violates row-level security policy for table "home_carousel"',
'Admin from company 2 should NOT be able to insert new media to company 4.'
);
select lives_ok(
$$ update home_carousel set caption = 'Nope' where media_id = 9 $$,
'Admin from company 2 should not be able to update new carousel media of company 4, but no error if media_id is not changed.'
);
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (9, 'Caption 9')
$$,
'No row should have been changed.'
);
select throws_ok(
$$ update home_carousel set media_id = 10 where media_id = 7 $$,
'42501', 'new row violates row-level security policy for table "home_carousel"',
'Admin from company 2 should NOT be able to move carousel media to company 4'
);
select lives_ok(
$$ delete from home_carousel where media_id = 9 $$,
'Admin from company 2 should NOT be able to delete carousel media from company 4, but no error is thrown'
);
select bag_eq(
'carousel_data',
$$ values (7, 'Caption 7')
, (9, 'Caption 9')
$$,
'No row should have been changed'
);
reset role;
select *
from finish();
rollback;

View File

@ -0,0 +1,44 @@
-- Test home_carousel_i18n
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(23);
set search_path to camper, public;
select has_table('home_carousel_i18n');
select has_pk('home_carousel_i18n');
select col_is_pk('home_carousel_i18n', array['media_id', 'lang_tag']);
select table_privs_are('home_carousel_i18n', 'guest', array['SELECT']);
select table_privs_are('home_carousel_i18n', 'employee', array['SELECT']);
select table_privs_are('home_carousel_i18n', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('home_carousel_i18n', 'authenticator', array[]::text[]);
select has_column('home_carousel_i18n', 'media_id');
select col_is_fk('home_carousel_i18n', 'media_id');
select fk_ok('home_carousel_i18n', 'media_id', 'home_carousel', 'media_id');
select col_type_is('home_carousel_i18n', 'media_id', 'integer');
select col_not_null('home_carousel_i18n', 'media_id');
select col_hasnt_default('home_carousel_i18n', 'media_id');
select has_column('home_carousel_i18n', 'lang_tag');
select col_is_fk('home_carousel_i18n', 'lang_tag');
select fk_ok('home_carousel_i18n', 'lang_tag', 'language', 'lang_tag');
select col_type_is('home_carousel_i18n', 'lang_tag', 'text');
select col_not_null('home_carousel_i18n', 'lang_tag');
select col_hasnt_default('home_carousel_i18n', 'lang_tag');
select has_column('home_carousel_i18n', 'caption');
select col_type_is('home_carousel_i18n', 'caption', 'text');
select col_not_null('home_carousel_i18n', 'caption');
select col_hasnt_default('home_carousel_i18n', 'caption');
select *
from finish();
rollback;

53
test/media_path.sql Normal file
View File

@ -0,0 +1,53 @@
-- Test media_path
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(10);
set search_path to camper, public;
select has_function('camper', 'path', array['media']);
select function_lang_is('camper', 'path', array['media'], 'sql');
select function_returns('camper', 'path', array['media'], 'text');
select isnt_definer('camper', 'path', array['media']);
select volatility_is('camper', 'path', array['media'], 'stable');
select function_privs_are('camper', 'path', array['media'], 'guest', array['EXECUTE']);
select function_privs_are('camper', 'path', array['media'], 'employee', array['EXECUTE']);
select function_privs_are('camper', 'path', array['media'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'path', array['media'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover1.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};')
, (4, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};')
, (5, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'text.txt', 'text/plain', 'hello, world!')
;
select bag_eq(
$$ select media_id, media.path from media order by media_id $$,
$$ values (3, '/media/fbbac389f80d12ef13a3dd6e5dec9541622b7f870f22cfa94c3ca374d6dbc6b4/cover1.xpm')
, (4, '/media/b1ac118723104963266ceff35cc5170802885412d1bc6ea247981c15eb4b00b1/cover2.xpm')
, (5, '/media/72ccd5c651416d714740715035e0e099c18785ce9f9a8f4222bfb72f663b1c18/cover3.xpm')
, (6, '/media/ffc9f5e4fdeea83920c171e2bd17577127c5d1a2c3c76f07440e10d387132280/image.svg')
, (7, '/media/68e656b251e67e8358bef8483ab0d51c6619f3e7a1a9f0e75838d41ff368f728/text.txt')
$$,
'Should give out the URL path for each media'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,80 @@
-- Test remove_home_carousel_slide
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to camper, public;
select has_function('camper', 'remove_home_carousel_slide', array['integer']);
select function_lang_is('camper', 'remove_home_carousel_slide', array['integer'], 'sql');
select function_returns('camper', 'remove_home_carousel_slide', array['integer'], 'void');
select isnt_definer('camper', 'remove_home_carousel_slide', array['integer']);
select volatility_is('camper', 'remove_home_carousel_slide', array['integer'], 'volatile');
select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'guest', array[]::text[]);
select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'employee', array[]::text[]);
select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into home_carousel (media_id, caption)
values (5, 'Source caption')
, (6, 'Another caption')
, (7, 'N/A')
;
insert into home_carousel_i18n (media_id, lang_tag, caption)
values (5, 'en', 'Target caption')
, (5, 'es', 'Target caption (spanish)')
, (6, 'en', 'Target caption')
, (6, 'es', 'Target caption (spanish)')
, (7, 'en', 'Target caption')
, (7, 'es', 'Target caption (spanish)')
;
select lives_ok(
$$ select remove_home_carousel_slide(6) $$,
'Should be able to delete a slide'
);
select bag_eq(
$$ select media_id, caption from home_carousel $$,
$$ values (5, 'Source caption')
, (7, 'N/A')
$$,
'Should have removed the slide'
);
select bag_eq(
$$ select media_id, lang_tag, caption from home_carousel_i18n $$,
$$ values (5, 'en', 'Target caption')
, (5, 'es', 'Target caption (spanish)')
, (7, 'en', 'Target caption')
, (7, 'es', 'Target caption (spanish)')
$$,
'Should have removed the slides translations'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,78 @@
-- Test translate_home_carousel_slide
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(13);
set search_path to camper, public;
select has_function('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text']);
select function_lang_is('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'sql');
select function_returns('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'void');
select isnt_definer('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text']);
select volatility_is('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'volatile');
select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'guest', array[]::text[]);
select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'employee', array[]::text[]);
select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate company cascade;
reset client_min_messages;
insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag)
values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (5, 1, 'text.txt', 'text/plain', 'hello, world!')
, (6, 1, 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into home_carousel (media_id, caption)
values (5, 'Source caption')
, (6, 'Another caption')
, (7, 'N/A')
;
insert into home_carousel_i18n (media_id, lang_tag, caption)
values (5, 'en', 'Target caption')
;
select lives_ok(
$$ select translate_home_carousel_slide(5, 'ca', 'Traducció') $$,
'Should be able to translate a carousel slide'
);
select lives_ok(
$$ select translate_home_carousel_slide(6, 'es', null) $$,
'Should be able to “translate” a carousel slide to the empty string'
);
select lives_ok(
$$ select translate_home_carousel_slide(5, 'en', 'Not anymore') $$,
'Should be able to overwrite a slides translation'
);
select bag_eq(
$$ select media_id, lang_tag, caption from home_carousel_i18n $$,
$$ values (5, 'ca', 'Traducció')
, (5, 'en', 'Not anymore')
, (6, 'es', '')
$$,
'Should have all three slides'
);
select *
from finish();
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:add_home_carousel_slide on pg
begin;
select has_function_privilege('camper.add_home_carousel_slide(integer, text)', 'execute');
rollback;

16
verify/home_carousel.sql Normal file
View File

@ -0,0 +1,16 @@
-- Verify camper:home_carousel on pg
begin;
select media_id
, caption
from camper.home_carousel
where false;
select 1 / count(*) from pg_class where oid = 'camper.home_carousel'::regclass and relrowsecurity;
select 1 / count(*) from pg_policy where polname = 'guest_ok' and polrelid = 'camper.home_carousel'::regclass;
select 1 / count(*) from pg_policy where polname = 'insert_to_company' and polrelid = 'camper.home_carousel'::regclass;
select 1 / count(*) from pg_policy where polname = 'update_company' and polrelid = 'camper.home_carousel'::regclass;
select 1 / count(*) from pg_policy where polname = 'delete_from_company' and polrelid = 'camper.home_carousel'::regclass;
rollback;

View File

@ -0,0 +1,11 @@
-- Verify camper:home_carousel_i18n on pg
begin;
select media_id
, lang_tag
, caption
from camper.home_carousel_i18n
where false;
rollback;

7
verify/media_path.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify camper:media_path on pg
begin;
select has_function_privilege('camper.path(camper.media)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:remove_home_carousel_slide on pg
begin;
select has_function_privilege('camper.remove_home_carousel_slide(integer)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:translate_home_carousel_slide on pg
begin;
select has_function_privilege('camper.translate_home_carousel_slide(integer, text, text)', 'execute');
rollback;

View File

@ -432,12 +432,12 @@ nav .has-submenu:hover ul, nav .has-submenu:focus-within ul {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.surroundings figure { .surroundings figure, .surroundings .slick-track > img {
margin-right: 5rem; margin-right: 5rem;
position: relative; position: relative;
} }
.surroundings figure img { .surroundings img {
height: 40rem; height: 40rem;
width: 100%; width: 100%;
border-radius: 5px; border-radius: 5px;

View File

@ -53,7 +53,7 @@
{{- end }} {{- end }}
{{ with .Media -}} {{ with .Media -}}
{{ if .Val -}} {{ if .Val -}}
<img src="/media/{{ .Val }}" alt=""> <img src="{{ .Val }}" alt="">
{{- end }} {{- end }}
<label> <label>
{{( pgettext "Cover image" "input" )}} {{( pgettext "Cover image" "input" )}}

View File

@ -0,0 +1,65 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.slideForm*/ -}}
{{ if .ID}}
{{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }}
{{( pgettext "New Carousel Slide" "title" )}}
{{ end }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.slideForm*/ -}}
{{ template "settings-tabs" "home" }}
<form
enctype="multipart/form-data"
{{ if .ID }}
data-hx-put="/admin/home/slides/{{ .ID }}"
{{ else }}
action="/admin/home/slides" method="post"
{{ end }}
>
<h2>
{{ if .ID }}
{{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }}
{{( pgettext "New Carousel Slide" "title" )}}
{{ end }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Media -}}
{{ if .Val -}}
<img src="{{ .Val }}" alt="">
{{- end }}
<label>
{{( pgettext "Cover image" "input" )}}
<input type="file" name="{{ .Name }}"
{{ if not $.ID }}required{{ end }}
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Caption -}}
<label>
{{( pgettext "Caption" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">
{{ if .ID }}
{{( pgettext "Update" "action" )}}
{{ else }}
{{( pgettext "Add" "action" )}}
{{ end }}
</button>
</footer>
</form>
{{- end }}

View File

@ -0,0 +1,36 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.slideL10nForm*/ -}}
{{printf (pgettext "Translate Carousel Slide to %s" "title") .Locale.Endonym }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.slideL10nForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form data-hx-put="/admin/home/slides/{{ .ID }}/{{ .Locale.Language }}">
<h2>
{{printf (pgettext "Translate Carousel Slide to %s" "title") .Locale.Endonym }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Caption -}}
<fieldset>
<legend>{{( pgettext "Caption" "input")}}</legend>
{{( gettext "Source:" )}} {{ .Source }}<br>
<label>
{{( pgettext "Translation:" "input" )}}
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
</fieldset>
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Translate" "action" )}}</button>
</footer>
</form>
{{- end }}

View File

@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Home Page" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/home.homeIndex*/ -}}
{{ template "settings-tabs" "home" }}
<h2>{{( pgettext "Carousel" "title" )}}</h2>
<a href="/admin/home/slides/new">{{( pgettext "Add slide" "action" )}}</a>
{{ if .Slides -}}
<table>
<thead>
<tr>
<th scope="col">{{( pgettext "Image" "header" )}}</th>
<th scope="col">{{( pgettext "Caption" "header" )}}</th>
<th scope="col">{{( pgettext "Translations" "campsite type" )}}</th>
<th scope="col">{{( pgettext "Actions" "campsite type" )}}</th>
</tr>
</thead>
<tbody>
{{ range $slide := .Slides -}}
<tr>
<td><a href="/admin/home/slides/{{ .ID }}"><img src="{{ .Media }}" alt=""></a></td>
<td><a href="/admin/home/slides/{{ .ID }}">{{ .Caption }}</a></td>
<td>
{{ range .Translations }}
<a
{{ if .Missing }}
class="missing-translation"
{{ end }}
href="/admin/home/slides/{{ $slide.ID }}/{{ .Language }}">{{ .Endonym }}</a>
{{ end }}
</td>
<td>
<form data-hx-delete="/admin/home/slides/{{ .ID }}" data-hx-headers='{ {{ CSRFHeader }} }'>
<button type="submit">{{( pgettext "Delete" "action" )}}</button>
</form>
</td>
</tr>
{{- end }}
</tbody>
</table>
{{ else -}}
<p>{{( gettext "No slides added yet." )}}</p>
{{- end }}
{{- end }}

View File

@ -72,6 +72,9 @@
<li> <li>
<a {{ if ne . "seasons"}}href="/admin/seasons"{{ end }}>{{( pgettext "Seasons" "title" )}}</a> <a {{ if ne . "seasons"}}href="/admin/seasons"{{ end }}>{{( pgettext "Seasons" "title" )}}</a>
</li> </li>
<li>
<a {{ if ne . "home"}}href="/admin/home"{{ end }}>{{( pgettext "Home Page" "title" )}}</a>
</li>
</ul> </ul>
</nav> </nav>
{{- end }} {{- end }}

View File

@ -39,42 +39,16 @@
<p>{{(gettext "Less than an hour from <strong>Girona</strong>, one from <strong>La Bisbal dEmpordà</strong>, and two from <strong>Barcelona</strong>.") | raw}}</p> <p>{{(gettext "Less than an hour from <strong>Girona</strong>, one from <strong>La Bisbal dEmpordà</strong>, and two from <strong>Barcelona</strong>.") | raw}}</p>
<p><a href="/{{currentLocale}}/surroundings">{{( gettext "Discover the surroundings" )}} <span>→</span></a></p> <p><a href="/{{currentLocale}}/surroundings">{{( gettext "Discover the surroundings" )}} <span>→</span></a></p>
</div> </div>
<figure> {{ range .Carousel -}}
<img src="/static/images/Volca_de_Santa_Margarida.jpg" alt=""/> {{ if .Caption -}}
<figcaption>{{( gettext "Legend" )}}</figcaption> <figure>
</figure> <img src="{{ .Media }}" alt=""/>
<figure> <figcaption>{{ .Caption }}</figcaption>
<img src="/static/images/Gorga_fosca_Sadernes.jpg" alt=""/> </figure>
<figcaption>{{( gettext "Legend" )}}</figcaption> {{- else -}}
</figure> <img src="{{ .Media }}" alt=""/>
<figure> {{- end }}
<img src="/static/images/castellfolit_de_la_roca.jpg" alt=""/> {{- end }}
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/besalu.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/santa_pau.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/banyoles.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/girn-a.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/costa_brava.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
<figure>
<img src="/static/images/barcelona-1.jpg" alt=""/>
<figcaption>{{( gettext "Legend" )}}</figcaption>
</figure>
</div> </div>
</section> </section>
<p class="enjoy">{{( gettext "Come and enjoy!")}}</p> <p class="enjoy">{{( gettext "Come and enjoy!")}}</p>