Add internationalization and localization of campsite types

I am not happy with the localization interface for admins, but it is the
easier that i could think of (for me, i guess), with a separate for
each language.

I am not at all proud of the use of RecordArray, but i did not see the
need to create and register a type just to show the translation links.
I might change my mind when i need to add more and more translation
links, but only it the current interface remains, which i am not that
sure at the moment.
This commit is contained in:
jordi fita mas 2023-09-12 20:20:23 +02:00
parent 7d8cf5439b
commit f48936f800
20 changed files with 478 additions and 42 deletions

View File

@ -35,8 +35,18 @@ alter sequence campsite_type_campsite_type_id_seq restart with 72;
insert into campsite_type (company_id, name, media_id, description)
values (52, 'Parceŀles', 62, '')
, (52, 'Safari Tents', 63, '')
, (52, 'Bungalows', 64, '')
, (52, 'Bungalous', 64, '')
, (52, 'Cabanes de fusta', 65, '')
;
insert into campsite_type_i18n (campsite_type_id, lang_tag, name, description)
values (72, 'en', 'Plots', '')
, (72, 'es', 'Parcelas', '')
, (73, 'en', 'Safari Tents', '')
, (73, 'es', 'Tiendas Safari', '')
, (74, 'en', 'Bungalows', '')
, (74, 'es', 'Bungalós', '')
, (75, 'en', 'Wooden Lodges', '')
, (75, 'es', 'Cabañas de madera', '')
;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:campsite_type_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: campsite_type
-- requires: language
begin;
set search_path to camper, public;
create table campsite_type_i18n (
campsite_type_id integer not null references campsite_type,
lang_tag text not null references language,
name text not null,
description xml not null,
primary key (campsite_type_id, lang_tag)
);
grant select on table campsite_type_i18n to guest;
grant select on table campsite_type_i18n to employee;
grant select, insert, update, delete on table campsite_type_i18n to admin;
commit;

View File

@ -13,6 +13,7 @@ import (
"dev.tandem.ws/tandem/camper/pkg/company"
"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/season"
"dev.tandem.ws/tandem/camper/pkg/template"
)
@ -23,9 +24,9 @@ type adminHandler struct {
season *season.AdminHandler
}
func newAdminHandler() *adminHandler {
func newAdminHandler(locales locale.Locales) *adminHandler {
return &adminHandler{
campsite: campsite.NewAdminHandler(),
campsite: campsite.NewAdminHandler(locales),
company: company.NewAdminHandler(),
season: season.NewAdminHandler(),
}

View File

@ -47,7 +47,7 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err
db: db,
fileHandler: static,
profile: profile,
admin: newAdminHandler(),
admin: newAdminHandler(locales),
public: newPublicHandler(),
media: media,
locales: locales,

View File

@ -65,7 +65,16 @@ type campsiteType struct {
}
func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType {
rows, err := conn.Query(ctx, "select name, '/campsites/types/' || slug, '/media/' || encode(hash, 'hex') || '/' || original_filename from campsite_type join media using (media_id) where campsite_type.company_id = $1 and campsite_type.active", company.ID)
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)
}

View File

@ -23,9 +23,9 @@ type AdminHandler struct {
types *types.AdminHandler
}
func NewAdminHandler() *AdminHandler {
func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{
types: &types.AdminHandler{},
types: types.NewAdminHandler(locales),
}
}

View File

@ -10,6 +10,10 @@ import (
"io"
"net/http"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
@ -20,6 +24,11 @@ import (
)
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 {
@ -58,6 +67,18 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
panic(err)
}
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 {
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:
f.MustRender(w, r, user, company)
@ -66,6 +87,29 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
tag, err := language.Parse(head)
if err != nil {
http.NotFound(w, r)
return
}
loc, ok := h.locales[tag]
if !ok {
http.NotFound(w, r)
return
}
l10n := newTypeL10nForm(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:
editTypeL10n(w, r, user, company, conn, l10n)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
}
})
}
@ -82,13 +126,35 @@ func serveTypeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com
}
type typeEntry struct {
Slug string
Name string
Active bool
Slug string
Name string
Active bool
Translations []*translation
}
type translation struct {
Language string
Endonym string
Missing bool
}
func collectTypeEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*typeEntry, error) {
rows, err := conn.Query(ctx, "select slug, name, active from campsite_type where company_id = $1 order by name", company.ID)
rows, err := conn.Query(ctx, `
select campsite_type.slug
, campsite_type.name
, campsite_type.active
, array_agg((lang_tag, endonym, not exists (select 1 from campsite_type_i18n as i18n where i18n.campsite_type_id = campsite_type.campsite_type_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from campsite_type
join company using (company_id)
, language
where lang_tag <> default_lang_tag
and language.selectable
and campsite_type.company_id = $1
group by campsite_type.slug
, campsite_type.name
, campsite_type.active
order by name
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil {
return nil, err
}
@ -97,9 +163,17 @@ func collectTypeEntries(ctx context.Context, company *auth.Company, conn *databa
var types []*typeEntry
for rows.Next() {
entry := &typeEntry{}
if err = rows.Scan(&entry.Slug, &entry.Name, &entry.Active); err != nil {
var translations pgtype.RecordArray
if err = rows.Scan(&entry.Slug, &entry.Name, &entry.Active, &translations); err != nil {
return nil, err
}
for _, el := range translations.Elements {
entry.Translations = append(entry.Translations, &translation{
el.Fields[0].Get().(string),
el.Fields[1].Get().(string),
el.Fields[2].Get().(bool),
})
}
types = append(types, entry)
}

107
pkg/campsite/types/l10n.go Normal file
View File

@ -0,0 +1,107 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package types
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 L10nInput struct {
form.Input
Source string
}
type typeL10nForm struct {
Locale *locale.Locale
Slug string
Name *L10nInput
Description *L10nInput
}
func newTypeL10nForm(f *typeForm, loc *locale.Locale) *typeL10nForm {
return &typeL10nForm{
Locale: loc,
Slug: f.Slug,
Name: &L10nInput{
Input: form.Input{
Name: f.Name.Name,
},
Source: f.Name.Val,
},
Description: &L10nInput{
Input: form.Input{
Name: f.Description.Name,
},
Source: f.Description.Val,
},
}
}
func (l10n *typeL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, `
select coalesce(i18n.name, '') as l10n_name
, coalesce(i18n.description, '') as l10n_description
from campsite_type
left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1
where slug = $2
`, l10n.Locale.Language, l10n.Slug)
return row.Scan(&l10n.Name.Val, &l10n.Description.Val)
}
func (l10n *typeL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "campsite/type/l10n.gohtml", l10n)
}
func editTypeL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *typeL10nForm) {
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(), `
insert into campsite_type_i18n (campsite_type_id, lang_tag, name, description)
select campsite_type_id, $1, $2, $3
from campsite_type
where slug = $4
on conflict (campsite_type_id, lang_tag) do update
set name = excluded.name
, description = excluded.description
`, l10n.Locale.Language, l10n.Name, l10n.Description, l10n.Slug)
httplib.Redirect(w, r, "/admin/campsites/types", http.StatusSeeOther)
}
func (l10n *typeL10nForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
l10n.Name.FillValue(r)
l10n.Description.FillValue(r)
return nil
}
func (l10n *typeL10nForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(&l10n.Name.Input, l.GettextNoop("Name can not be empty."))
return v.AllOK
}

View File

@ -31,7 +31,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
http.NotFound(w, r)
return
}
page, err := newPublicPage(r.Context(), conn, head)
page, err := newPublicPage(r.Context(), user, conn, head)
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
@ -51,11 +51,18 @@ type publicPage struct {
Description gotemplate.HTML
}
func newPublicPage(ctx context.Context, conn *database.Conn, slug string) (*publicPage, error) {
func newPublicPage(ctx context.Context, user *auth.User, conn *database.Conn, slug string) (*publicPage, error) {
page := &publicPage{
PublicPage: template.NewPublicPage(),
}
row := conn.QueryRow(ctx, "select name, description::text from campsite_type where slug = $1 and active", slug)
row := conn.QueryRow(ctx, `
select coalesce(i18n.name, campsite_type.name) as l10n_name
, coalesce(i18n.description, campsite_type.description)::text as l10n_description
from campsite_type
left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1
where slug = $2
and active
`, user.Locale.Language, slug)
if err := row.Scan(&page.Name, &page.Description); err != nil {
return nil, err
}

View File

@ -40,7 +40,14 @@ func (p *PublicPage) Setup(r *http.Request, user *auth.User, company *auth.Compa
sort.Slice(p.LocalizedAlternates, func(i, j int) bool { return p.LocalizedAlternates[i].Lang < p.LocalizedAlternates[j].Lang })
p.Menu = &siteMenu{
CampsiteTypes: mustCollectMenuItems(r.Context(), conn, user.Locale, "select name, '/campsites/types/' || slug from campsite_type where company_id = $1 and active", company.ID),
CampsiteTypes: mustCollectMenuItems(r.Context(), conn, user.Locale, `
select coalesce(i18n.name, campsite_type.name) as l10n_name
, '/campsites/types/' || slug
from campsite_type
left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1
where company_id = $2
and active
`, user.Locale.Language, company.ID),
}
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-11 04:08+0200\n"
"POT-Creation-Date: 2023-09-12 20:18+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:39
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title"
msgid "Home"
msgstr "Inici"
@ -69,7 +69,7 @@ msgid "Come and enjoy!"
msgstr "Vine a gaudir!"
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:60
#: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut"
msgstr "Càmping Montagut"
@ -77,7 +77,7 @@ msgstr "Càmping Montagut"
msgid "Skip to main content"
msgstr "Salta al contingut principal"
#: web/templates/public/layout.gohtml:44
#: web/templates/public/layout.gohtml:32
msgid "Singular Lodges"
msgstr "Allotjaments singulars"
@ -150,13 +150,13 @@ msgid "Type"
msgstr "Tipus"
#: web/templates/admin/campsite/index.gohtml:28
#: web/templates/admin/campsite/type/index.gohtml:26
#: web/templates/admin/campsite/type/index.gohtml:36
#: web/templates/admin/season/index.gohtml:32
msgid "Yes"
msgstr "Sí"
#: web/templates/admin/campsite/index.gohtml:28
#: web/templates/admin/campsite/type/index.gohtml:26
#: web/templates/admin/campsite/type/index.gohtml:36
#: web/templates/admin/season/index.gohtml:32
msgid "No"
msgstr "No"
@ -178,12 +178,13 @@ msgid "New Campsite Type"
msgstr "Nou tipus dallotjament"
#: web/templates/admin/campsite/type/form.gohtml:39
#: web/templates/admin/campsite/type/index.gohtml:19
#: web/templates/admin/campsite/type/index.gohtml:20
msgctxt "campsite type"
msgid "Active"
msgstr "Actiu"
#: web/templates/admin/campsite/type/form.gohtml:48
#: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26
msgctxt "input"
@ -196,6 +197,7 @@ msgid "Cover image"
msgstr "Imatge de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input"
msgid "Description"
msgstr "Descripció"
@ -218,10 +220,37 @@ msgctxt "header"
msgid "Name"
msgstr "Nom"
#: web/templates/admin/campsite/type/index.gohtml:32
#: web/templates/admin/campsite/type/index.gohtml:19
msgctxt "campsite type"
msgid "Translations"
msgstr "Traduccions"
#: web/templates/admin/campsite/type/index.gohtml:42
msgid "No campsite types added yet."
msgstr "No sha afegit cap tipus dallotjament encara."
#: web/templates/admin/campsite/type/l10n.gohtml:7
#: web/templates/admin/campsite/type/l10n.gohtml:15
msgctxt "title"
msgid "Translate Campsite Type to %s"
msgstr "Traducció del tipus dallotjament a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
msgctxt "input"
msgid "Translation:"
msgstr "Traducció:"
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Tradueix"
#: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26
msgctxt "title"
@ -434,7 +463,8 @@ msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/app/user.go:249 pkg/campsite/types/admin.go:227 pkg/season/admin.go:203
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105
#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
@ -446,15 +476,15 @@ msgstr "La confirmació no es correspon amb la contrasenya."
msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:229
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303
msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/app/admin.go:43
#: pkg/app/admin.go:44
msgid "Access forbidden"
msgstr "Accés prohibit"
#: pkg/campsite/types/admin.go:231
#: pkg/campsite/types/admin.go:305
msgid "Cover image can not be empty."
msgstr "No podeu deixar la imatge de portada en blanc."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-09-11 04:08+0200\n"
"POT-Creation-Date: 2023-09-12 20:18+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:39
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title"
msgid "Home"
msgstr "Inicio"
@ -69,7 +69,7 @@ msgid "Come and enjoy!"
msgstr "¡Ven a disfrutar!"
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:60
#: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut"
msgstr "Camping Montagut"
@ -77,7 +77,7 @@ msgstr "Camping Montagut"
msgid "Skip to main content"
msgstr "Saltar al contenido principal"
#: web/templates/public/layout.gohtml:44
#: web/templates/public/layout.gohtml:32
msgid "Singular Lodges"
msgstr "Alojamientos singulares"
@ -150,13 +150,13 @@ msgid "Type"
msgstr "Tipo"
#: web/templates/admin/campsite/index.gohtml:28
#: web/templates/admin/campsite/type/index.gohtml:26
#: web/templates/admin/campsite/type/index.gohtml:36
#: web/templates/admin/season/index.gohtml:32
msgid "Yes"
msgstr "Sí"
#: web/templates/admin/campsite/index.gohtml:28
#: web/templates/admin/campsite/type/index.gohtml:26
#: web/templates/admin/campsite/type/index.gohtml:36
#: web/templates/admin/season/index.gohtml:32
msgid "No"
msgstr "No"
@ -178,12 +178,13 @@ msgid "New Campsite Type"
msgstr "Nuevo tipo de alojamiento"
#: web/templates/admin/campsite/type/form.gohtml:39
#: web/templates/admin/campsite/type/index.gohtml:19
#: web/templates/admin/campsite/type/index.gohtml:20
msgctxt "campsite type"
msgid "Active"
msgstr "Activo"
#: web/templates/admin/campsite/type/form.gohtml:48
#: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26
msgctxt "input"
@ -196,6 +197,7 @@ msgid "Cover image"
msgstr "Imagen de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input"
msgid "Description"
msgstr "Descripción"
@ -218,10 +220,37 @@ msgctxt "header"
msgid "Name"
msgstr "Nombre"
#: web/templates/admin/campsite/type/index.gohtml:32
#: web/templates/admin/campsite/type/index.gohtml:19
msgctxt "campsite type"
msgid "Translations"
msgstr "Traducciones"
#: web/templates/admin/campsite/type/index.gohtml:42
msgid "No campsite types added yet."
msgstr "No se ha añadido ningún tipo de alojamiento todavía."
#: web/templates/admin/campsite/type/l10n.gohtml:7
#: web/templates/admin/campsite/type/l10n.gohtml:15
msgctxt "title"
msgid "Translate Campsite Type to %s"
msgstr "Traducción de tipo de alojamiento a %s"
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
msgctxt "input"
msgid "Translation:"
msgstr "Traducción"
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Traducir"
#: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26
msgctxt "title"
@ -434,7 +463,8 @@ msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/app/user.go:249 pkg/campsite/types/admin.go:227 pkg/season/admin.go:203
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105
#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
@ -446,15 +476,15 @@ msgstr "La confirmación no se corresponde con la contraseña."
msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido."
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:229
#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303
msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/app/admin.go:43
#: pkg/app/admin.go:44
msgid "Access forbidden"
msgstr "Acceso prohibido"
#: pkg/campsite/types/admin.go:231
#: pkg/campsite/types/admin.go:305
msgid "Cover image can not be empty."
msgstr "No podéis dejar la imagen de portada en blanco."

View File

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

View File

@ -43,6 +43,7 @@ media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas <jordi@tandem.blo
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
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
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
edit_campsite_type [roles schema_camper campsite_type company] 2023-08-07T22:21:34Z jordi fita mas <jordi@tandem.blog> # Add function to edit campsite types
campsite [roles schema_camper company campsite_type user_profile] 2023-08-14T10:11:51Z jordi fita mas <jordi@tandem.blog> # Add campsite relation

View File

@ -7,7 +7,7 @@ begin;
set search_path to camper, public;
select plan(12);
select plan(13);
select has_function('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text']);
select function_lang_is('camper', 'add_campsite_type', array ['integer', 'integer', 'text', 'text'], 'sql');
@ -21,6 +21,7 @@ select function_privs_are('camper', 'add_campsite_type', array ['integer', 'inte
set client_min_messages to warning;
truncate campsite_type_i18n cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate company cascade;
@ -55,6 +56,11 @@ select bag_eq(
'Should have added all two campsite type'
);
select is_empty(
$$ select * from campsite_type_i18n $$,
'Should not have added any translation for campsite types.'
);
select *
from finish();

View File

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

View File

@ -0,0 +1,12 @@
-- Verify camper:campsite_type_i18n on pg
begin;
select campsite_type_id
, lang_tag
, name
, description
from camper.campsite_type_i18n
where false;
rollback;

View File

@ -44,3 +44,7 @@ p, h1, h2, h3, h4, h5, h6 {
:any-link {
color: #0000ff;
}
a.missing-translation {
color: #ff0000;
}

View File

@ -16,13 +16,23 @@
<thead>
<tr>
<th scope="col">{{( pgettext "Name" "header" )}}</th>
<th scope="col">{{( pgettext "Translations" "campsite type" )}}</th>
<th scope="col">{{( pgettext "Active" "campsite type" )}}</th>
</tr>
</thead>
<tbody>
{{ range .Types -}}
{{ range $type := .Types -}}
<tr>
<td><a href="/admin/campsites/types/{{ .Slug }}">{{ .Name }}</a></td>
<td>
{{ range .Translations }}
<a
{{ if .Missing }}
class="missing-translation"
{{ end }}
href="/admin/campsites/types/{{ $type.Slug }}/{{ .Language }}">{{ .Endonym }}</a>
{{ end }}
</td>
<td>{{ if .Active }}{{( gettext "Yes" )}}{{ else }}{{( gettext "No" )}}{{ end }}</td>
</tr>
{{- end }}

View File

@ -0,0 +1,49 @@
<!--
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/campsite/types.typeL10nForm*/ -}}
{{printf (pgettext "Translate Campsite Type to %s" "title") .Locale.Endonym }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeL10nForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form data-hx-put="/admin/campsites/types/{{ .Slug }}/{{ .Locale.Language }}">
<h2>
{{printf (pgettext "Translate Campsite Type to %s" "title") .Locale.Endonym }}
</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Name -}}
<fieldset>
<legend>{{( pgettext "Name" "input")}}</legend>
{{( gettext "Source:" )}} {{ .Source }}<br>
<label>
{{( pgettext "Translation:" "input" )}}
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
</fieldset>
{{- end }}
{{ with .Description -}}
<fieldset>
<legend{{( pgettext "Description" "input")}}></legend>
{{( gettext "Source:" )}}<br>
{{ .Source | raw }}<br>
<label>
{{( pgettext "Translation:" "input" )}}
<textarea class="html"
name="{{ .Name }}" {{ template "error-attrs" . }}>{{ .Val }}</textarea><br>
</label>
{{ template "error-message" . }}
</fieldset>
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Translate" "action" )}}</button>
</footer>
</form>
{{- end }}