Manage all media uploads in a single place

It made no sense to have a file upload in each form that needs a media,
because to reuse an existing media users would need to upload the exact
same file again; this is very unusual and unfriendly.

A better option is to have a “centralized” media section, where people
can upload files there, and then have a picker to select from there.
Ideally, there would be an upload option in the picker, but i did not
add it yet.

I’ve split the content from the media because i want users to have the
option to update a media, for instance when they need to upload a
reduced or cropped version of the same photo, without an edit they would
need to upload the file as a new media and then update all places where
the old version was used.  And i did not want to trouble people that
uploads the same photo twice: without the separate relation, doing so
would throw a constraint error.

I do not believe there is any security problem to have all companies
link their media to the same file, as they were already readable by
everyone and could upload the data from a different company to their
own; in other words, it is not worse than it was now.
This commit is contained in:
jordi fita mas 2023-09-21 01:56:44 +02:00
parent afe77f2296
commit 97cf117da3
69 changed files with 1625 additions and 1086 deletions

View File

@ -20,7 +20,7 @@ po/%.po: $(POT_FILE)
$(POT_FILE): $(HTML_FILES) $(GO_FILES)
xgettext $(XGETTEXTFLAGS) --language=Scheme --output=$@ --keyword=pgettext:1,2c $(HTML_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --keyword=Pgettext:1,2c --join-existing $(GO_FILES)
xgettext $(XGETTEXTFLAGS) --language=C --output=$@ --keyword=Gettext:1 --keyword=GettextNoop:1 --keyword=Pgettext:1,2c --keyword=PgettextNoop:1,2c --join-existing $(GO_FILES)
test-deploy:
sqitch deploy --db-name $(PGDATABASE)

View File

@ -7,6 +7,7 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"os"
@ -42,7 +43,7 @@ func main() {
go func() {
log.Printf("INFO - listening on %s\n", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err)
}
}()

View File

@ -24,24 +24,23 @@ values (52, 42, 'employee')
;
alter sequence media_media_id_seq restart with 62;
insert into media (company_id, original_filename, media_type, content)
values (52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plots.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, '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'))
, (52, 'services_carousel0.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel0.avif]])', 'base64'))
, (52, 'services_carousel1.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel1.avif]])', 'base64'))
, (52, 'services_carousel2.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel2.avif]])', 'base64'))
, (52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64'))
select add_media(52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plots.avif]])', 'base64'));
select add_media(52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64'));
select add_media(52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64'));
select add_media(52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64'));
select add_media(52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64'));
select add_media(52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64'));
select add_media(52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64'));
select add_media(52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64'));
select add_media(52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64'));
select add_media(52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64'));
select add_media(52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64'));
select add_media(52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64'));
select add_media(52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64'));
select add_media(52, 'services_carousel0.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel0.avif]])', 'base64'));
select add_media(52, 'services_carousel1.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel1.avif]])', 'base64'));
select add_media(52, 'services_carousel2.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel2.avif]])', 'base64'));
select add_media(52, 'services_carousel3.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/services_carousel3.avif]])', 'base64'));
;
insert into home_carousel (media_id, caption)

View File

@ -2,6 +2,7 @@
-- requires: roles
-- requires: schema_camper
-- requires: media
-- requires: media_content
-- requires: media_type
begin;
@ -10,15 +11,26 @@ set search_path to camper, public;
create or replace function add_media(company integer, filename text, media_type media_type, content bytea) returns integer as
$$
insert into media (company_id, original_filename, media_type, content)
values (company, filename, media_type, content)
on conflict (company_id, hash) do update
set original_filename = excluded.original_filename
, media_type = excluded.media_type
returning media_id
declare
hash bytea;
mid integer;
begin
insert into media_content (media_type, bytes)
values (media_type, content)
on conflict (content_hash) do update
set media_type = excluded.media_type
returning content_hash into hash
;
insert into media (company_id, original_filename, content_hash)
values (company, filename, hash)
returning media_id into mid
;
return mid;
end
$$
language sql
language plpgsql
;
revoke execute on function add_media(integer, text, media_type, bytea) from public;

41
deploy/edit_media.sql Normal file
View File

@ -0,0 +1,41 @@
-- Deploy camper:edit_media to pg
-- requires: roles
-- requires: schema_camper
-- requires: media_content
-- requires: media
-- requires: media_type
begin;
set search_path to camper, public;
create or replace function edit_media(media_id integer, filename text, media_type media_type default null, content bytea default null) returns integer as
$$
declare
hash bytea;
begin
if content is not null then
insert into media_content (media_type, bytes)
values (media_type, content)
on conflict (content_hash) do update
set media_type = excluded.media_type
returning content_hash into hash
;
end if;
update media
set original_filename = filename
, content_hash = coalesce(hash, content_hash)
where media.media_id = edit_media.media_id
;
return media_id;
end
$$
language plpgsql
;
revoke execute on function edit_media(integer, text, media_type, bytea) from public;
grant execute on function edit_media(integer, text, media_type, bytea) to admin;
commit;

View File

@ -2,8 +2,8 @@
-- requires: roles
-- requires: schema_camper
-- requires: company
-- requires: media_content
-- requires: user_profile
-- requires: media_type
begin;
@ -12,11 +12,8 @@ set search_path to camper, public;
create table media (
media_id serial not null primary key,
company_id integer not null references company,
hash bytea not null generated always as (sha256(content)) stored,
original_filename text not null constraint original_filename_not_empty check(length(trim(original_filename)) > 0),
media_type media_type not null,
content bytea not null,
unique (company_id, hash)
content_hash bytea not null references media_content,
original_filename text not null constraint original_filename_not_empty check(length(trim(original_filename)) > 0)
);
grant select on table media to guest;

20
deploy/media_content.sql Normal file
View File

@ -0,0 +1,20 @@
-- Deploy camper:media_content to pg
-- requires: roles
-- requires: schema_camper
-- requires: media_type
begin;
set search_path to camper, public;
create table media_content (
content_hash bytea not null generated always as (sha256(bytes)) stored primary key,
media_type media_type not null,
bytes bytea not null
);
grant select on table media_content to guest;
grant select on table media_content to employee;
grant select, insert, delete, update on table media_content to admin;
commit;

View File

@ -9,7 +9,7 @@ set search_path to camper, public;
create or replace function path(media) returns text as
$$
select '/media/' || encode($1.hash, 'hex') || '/' || $1.original_filename;
select '/media/' || encode($1.content_hash, 'hex') || '/' || $1.original_filename;
$$
language sql
stable

View File

@ -6,6 +6,7 @@
package app
import (
"dev.tandem.ws/tandem/camper/pkg/media"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
@ -24,15 +25,17 @@ type adminHandler struct {
campsite *campsite.AdminHandler
company *company.AdminHandler
home *home.AdminHandler
media *media.AdminHandler
season *season.AdminHandler
services *services.AdminHandler
}
func newAdminHandler(locales locale.Locales) *adminHandler {
func newAdminHandler(locales locale.Locales, mediaDir string) *adminHandler {
return &adminHandler{
campsite: campsite.NewAdminHandler(locales),
company: company.NewAdminHandler(),
home: home.NewAdminHandler(locales),
media: media.NewAdminHandler(mediaDir),
season: season.NewAdminHandler(),
services: services.NewAdminHandler(locales),
}
@ -60,6 +63,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "home":
h.home.Handler(user, company, conn).ServeHTTP(w, r)
case "media":
h.media.Handler(user, company, conn).ServeHTTP(w, r)
case "seasons":
h.season.Handler(user, company, conn).ServeHTTP(w, r)
case "services":

View File

@ -7,6 +7,7 @@ package app
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/media"
"net/http"
"golang.org/x/text/language"
@ -23,7 +24,7 @@ type App struct {
profile *profileHandler
admin *adminHandler
public *publicHandler
media *mediaHandler
media *media.PublicHandler
locales locale.Locales
defaultLocale *locale.Locale
languageMatcher language.Matcher
@ -39,7 +40,7 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err
if err != nil {
return nil, err
}
media, err := newMediaHandler(mediaDir)
mediaHandler, err := media.NewPublicHandler(mediaDir)
if err != nil {
return nil, err
}
@ -47,9 +48,9 @@ func New(db *database.DB, avatarsDir string, mediaDir string) (http.Handler, err
db: db,
fileHandler: static,
profile: profile,
admin: newAdminHandler(locales),
admin: newAdminHandler(locales, mediaDir),
public: newPublicHandler(),
media: media,
media: mediaHandler,
locales: locales,
defaultLocale: locales[language.Catalan],
languageMatcher: language.NewMatcher(locales.Tags()),

View File

@ -7,7 +7,6 @@ package types
import (
"context"
"io"
"net/http"
"github.com/jackc/pgx/v4"
@ -183,42 +182,29 @@ func (page *typeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *
func addType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newTypeForm()
processTypeForm(w, r, user, company, true, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, mediaID, f.Name, f.Description)
tx.MustCommit(ctx)
processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) {
conn.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4)", company.ID, f.Media, f.Name, f.Description)
})
}
func editType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) {
processTypeForm(w, r, user, company, false, f, func(ctx context.Context) {
bytes := f.MustReadAllMedia()
if bytes == nil {
conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, nil, f.Name, f.Description, f.Active)
} else {
tx := conn.MustBegin(ctx)
defer tx.Rollback(ctx)
mediaID := tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes)
tx.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, mediaID, f.Name, f.Description, f.Active)
tx.MustCommit(ctx)
}
processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) {
conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5)", f.Slug, f.Media, f.Name, f.Description, f.Active)
})
}
func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, f *typeForm, act func(ctx context.Context)) {
if err := f.Parse(w, r); err != nil {
func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm, act func(ctx context.Context)) {
if err := f.Parse(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 ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
@ -232,7 +218,7 @@ func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, co
type typeForm struct {
Slug string
Active *form.Checkbox
Media *form.File
Media *form.Media
Name *form.Input
Description *form.Input
}
@ -243,9 +229,12 @@ func newTypeForm() *typeForm {
Name: "active",
Checked: true,
},
Media: &form.File{
Name: "media",
MaxSize: 1 << 20,
Media: &form.Media{
Input: &form.Input{
Name: "media",
},
Label: locale.PgettextNoop("Cover image", "input"),
Prompt: locale.PgettextNoop("Set campsite type cover", "action"),
},
Name: &form.Input{
Name: "name",
@ -261,60 +250,36 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl
row := conn.QueryRow(ctx, `
select name
, description
, media.path
, media_id::text
, active
from campsite_type
join media using (media_id)
where slug = $1
`, slug)
return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.Active.Checked)
}
func (f *typeForm) 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 {
func (f *typeForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Active.FillValue(r)
f.Name.FillValue(r)
f.Description.FillValue(r)
if err := f.Media.FillValue(r); err != nil {
return err
}
f.Media.FillValue(r)
return nil
}
func (f *typeForm) Close() error {
return f.Media.Close()
}
func (f *typeForm) Valid(l *locale.Locale, mediaRequired bool) bool {
func (f *typeForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
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("Cover image can not be empty."))
if v.CheckRequired(f.Media.Input, l.GettextNoop("Cover image can not be empty.")) {
if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil {
return false, err
}
}
return v.AllOK
return v.AllOK, nil
}
func (f *typeForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "campsite/type/form.gohtml", f)
}
func (f *typeForm) HasMediaFile() bool {
return f.Media.HasData()
}
func (f *typeForm) MustReadAllMedia() []byte {
if !f.HasMediaFile() {
return nil
}
bytes, err := io.ReadAll(f.Media)
if err != nil {
panic(err)
}
return bytes
}

View File

@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package home
package carousel
import (
"context"
"io"
"fmt"
"net/http"
"strconv"
@ -21,7 +21,19 @@ import (
"dev.tandem.ws/tandem/camper/pkg/template"
)
func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
type AdminHandler struct {
name string
locales locale.Locales
}
func NewAdminHandler(name string, locales locale.Locales) *AdminHandler {
return &AdminHandler{
name: name,
locales: locales,
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
@ -30,14 +42,14 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
case "":
switch r.Method {
case http.MethodPost:
addSlide(w, r, user, company, conn)
addSlide(w, r, user, company, conn, h.name)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "new":
switch r.Method {
case http.MethodGet:
f := newSlideForm()
f := newSlideForm(h.name)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
@ -47,7 +59,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
if err != nil {
http.NotFound(w, r)
}
f := newSlideForm()
f := newSlideForm(h.name)
if err := f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
@ -67,7 +79,7 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
case http.MethodPut:
editSlide(w, r, user, company, conn, f)
case http.MethodDelete:
deleteSlide(w, r, user, conn, id)
h.deleteSlide(w, r, user, conn, id)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
@ -94,28 +106,28 @@ func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, c
})
}
type carouselSlide struct {
type Slide 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, `
func MustCollectSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale, carouselName string) []*Slide {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select coalesce(i18n.caption, slide.caption) as l10_caption
, media.path
from home_carousel as slide
from %[1]s_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
left join %[1]s_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1
where media.company_id = $2
`, loc.Language, company.ID)
`, carouselName), loc.Language, company.ID)
if err != nil {
panic(err)
}
defer rows.Close()
var carousel []*carouselSlide
var carousel []*Slide
for rows.Next() {
slide := &carouselSlide{}
slide := &Slide{}
err = rows.Scan(&slide.Caption, &slide.Media)
if err != nil {
panic(err)
@ -129,8 +141,8 @@ func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn
return carousel
}
type slideEntry struct {
carouselSlide
type SlideEntry struct {
Slide
ID int
Translations []*translation
}
@ -141,13 +153,13 @@ type translation struct {
Missing bool
}
func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) {
rows, err := conn.Query(ctx, `
func CollectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn, carouselName string) ([]*SlideEntry, error) {
rows, err := conn.Query(ctx, fmt.Sprintf(`
select media_id
, media.path
, caption
, 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
, array_agg((lang_tag, endonym, not exists (select 1 from %[1]s_carousel_i18n as i18n where i18n.media_id = carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from %[1]s_carousel as carousel
join media using (media_id)
join company using (company_id)
, language
@ -158,15 +170,15 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab
, media.path
, caption
order by caption
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
`, carouselName), pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var slides []*slideEntry
var slides []*SlideEntry
for rows.Next() {
slide := &slideEntry{}
slide := &SlideEntry{}
var translations database.RecordArray
if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil {
return nil, err
@ -184,46 +196,42 @@ func collectSlideEntries(ctx context.Context, company *auth.Company, conn *datab
return slides, nil
}
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newSlideForm()
func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, carouselName string) {
f := newSlideForm(carouselName)
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)
}
f.process(w, r, user, company, conn, func(ctx context.Context) {
conn.MustExec(ctx, fmt.Sprintf("select add_%s_carousel_slide($1, $2)", f.CarouselName), f.Media, f.Caption)
})
}
func deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
func (h *AdminHandler) deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) {
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
conn.MustExec(r.Context(), "select remove_home_carousel_slide($1)", id)
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
conn.MustExec(r.Context(), fmt.Sprintf("select remove_%s_carousel_slide($1)", h.name), id)
httplib.Redirect(w, r, "/admin/"+h.name, http.StatusSeeOther)
}
type slideForm struct {
ID int
Media *form.File
Caption *form.Input
CarouselName string
ID int
Media *form.Media
Caption *form.Input
}
func newSlideForm() *slideForm {
func newSlideForm(carouselName string) *slideForm {
return &slideForm{
Media: &form.File{
Name: "media",
MaxSize: 1 << 20,
CarouselName: carouselName,
Media: &form.Media{
Input: &form.Input{
Name: "media",
},
Label: locale.PgettextNoop("Cover image", "input"),
Prompt: locale.PgettextNoop("Set cover image", "action"),
},
Caption: &form.Input{
Name: "caption",
@ -233,27 +241,27 @@ func newSlideForm() *slideForm {
func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, `
row := conn.QueryRow(ctx, fmt.Sprintf(`
select caption
, media.path
from home_carousel
join media using (media_id)
, media_id::text
from %s_carousel
where media_id = $1
`, id)
`, f.CarouselName), 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 {
func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, act func(ctx context.Context)) {
if err := f.Parse(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 ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
@ -261,51 +269,28 @@ func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.U
return
}
act(r.Context())
httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther)
httplib.Redirect(w, r, "/admin/"+f.CarouselName, 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 {
func (f *slideForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Caption.FillValue(r)
if err := f.Media.FillValue(r); err != nil {
return err
}
f.Media.FillValue(r)
return nil
}
func (f *slideForm) Close() error {
return f.Media.Close()
}
func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool {
func (f *slideForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
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."))
if v.CheckRequired(f.Media.Input, l.GettextNoop("Slide image can not be empty.")) {
if _, err := v.CheckImageMedia(ctx, conn, f.Media.Input, l.GettextNoop("Cover image must be a image media.")); err != nil {
return false, err
}
}
return v.AllOK
return v.AllOK, nil
}
func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "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
template.MustRenderAdmin(w, r, user, company, "carousel/form.gohtml", f)
}

View File

@ -3,10 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package services
package carousel
import (
"context"
"fmt"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
@ -18,31 +19,33 @@ import (
)
type slideL10nForm struct {
Locale *locale.Locale
ID int
Caption *form.L10nInput
Locale *locale.Locale
CarouselName string
ID int
Caption *form.L10nInput
}
func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm {
return &slideL10nForm{
Locale: loc,
ID: f.ID,
Caption: f.Caption.L10nInput(),
Locale: loc,
CarouselName: f.CarouselName,
ID: f.ID,
Caption: f.Caption.L10nInput(),
}
}
func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error {
row := conn.QueryRow(ctx, `
row := conn.QueryRow(ctx, fmt.Sprintf(`
select coalesce(i18n.caption, '') as l10n_caption
from services_carousel
left join services_carousel_i18n as i18n on services_carousel.media_id = i18n.media_id and i18n.lang_tag = $1
where services_carousel.media_id = $2
`, l10n.Locale.Language, l10n.ID)
from %[1]s_carousel as carousel
left join %[1]s_carousel_i18n as i18n on carousel.media_id = i18n.media_id and i18n.lang_tag = $1
where carousel.media_id = $2
`, l10n.CarouselName), 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, "services/carousel/l10n.gohtml", l10n)
template.MustRenderAdmin(w, r, user, company, "carousel/l10n.gohtml", l10n)
}
func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) {
@ -61,8 +64,8 @@ func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, comp
l10n.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), "select translate_services_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption)
httplib.Redirect(w, r, "/admin/services", http.StatusSeeOther)
conn.MustExec(r.Context(), fmt.Sprintf("select translate_%s_carousel_slide($1, $2, $3)", l10n.CarouselName), l10n.ID, l10n.Locale.Language, l10n.Caption)
httplib.Redirect(w, r, "/admin/"+l10n.CarouselName, http.StatusSeeOther)
}
func (l10n *slideL10nForm) Parse(r *http.Request) error {

12
pkg/form/media.go Normal file
View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package form
type Media struct {
*Input
Label string
Prompt string
}

View File

@ -11,6 +11,7 @@ import (
"net/mail"
"net/url"
"regexp"
"strconv"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/locale"
@ -94,6 +95,15 @@ func (v *Validator) CheckImageFile(field *File, message string) bool {
return v.Check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
}
func (v *Validator) CheckImageMedia(ctx context.Context, conn *database.Conn, input *Input, message string) (bool, error) {
id, err := strconv.Atoi(input.Val)
if err != nil {
return v.Check(input, false, message), nil
}
exists, err := conn.GetBool(ctx, "select exists(select 1 from media join media_content using (content_hash) where media_id = $1 and media_type like 'image/%')", id)
return v.Check(input, err == nil && exists, message), err
}
type field interface {
setError(error)
}

View File

@ -9,18 +9,25 @@ import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"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"
)
const carouselName = "home"
type AdminHandler struct {
locales locale.Locales
locales locale.Locales
carousel *carousel.AdminHandler
}
func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{locales}
return &AdminHandler{
locales: locales,
carousel: carousel.NewAdminHandler(carouselName, locales),
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "slides":
h.carouselHandler(user, company, conn).ServeHTTP(w, r)
h.carousel.Handler(user, company, conn).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -45,7 +52,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := collectSlideEntries(r.Context(), company, conn)
slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName)
if err != nil {
panic(err)
}
@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com
}
type homeIndex struct {
Slides []*slideEntry
Slides []*carousel.SlideEntry
}
func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {

View File

@ -1,79 +0,0 @@
/*
* 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
}

View File

@ -10,6 +10,7 @@ import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
@ -38,7 +39,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type homePage struct {
*template.PublicPage
CampsiteTypes []*campsiteType
Carousel []*carouselSlide
Carousel []*carousel.Slide
}
func newHomePage() *homePage {
@ -48,7 +49,7 @@ func newHomePage() *homePage {
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)
p.Carousel = carousel.MustCollectSlides(r.Context(), company, conn, user.Locale, carouselName)
template.MustRenderPublic(w, r, user, company, "home.gohtml", p)
}

View File

@ -68,6 +68,10 @@ func (l *Locale) GettextNoop(str string) string {
return str
}
func PgettextNoop(str string, ctx string) string {
return str
}
func Match(acceptLanguage string, locales Locales, matcher language.Matcher) *Locale {
t, _, err := language.ParseAcceptLanguage(acceptLanguage)
if err != nil {

341
pkg/media/admin.go Normal file
View File

@ -0,0 +1,341 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package media
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
mediaDir string
}
func NewAdminHandler(mediaDir string) *AdminHandler {
// mediaDir is already created in public handler
return &AdminHandler{
mediaDir: mediaDir,
}
}
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:
serveMediaIndex(w, r, user, company, conn)
case http.MethodPost:
uploadMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "picker":
switch r.Method {
case http.MethodGet:
serveMediaPicker(w, r, user, company, conn)
case http.MethodPost:
pickMedia(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "upload":
switch r.Method {
case http.MethodGet:
f := newUploadForm()
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)
return
}
f := newMediaForm()
if err = f.FillFromDatabase(r.Context(), conn, id); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editMedia(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "content":
switch r.Method {
case http.MethodGet:
httplib.Redirect(w, r, f.Path, http.StatusFound)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
})
}
func serveMediaIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
page := &mediaIndex{
Media: media,
}
page.MustRender(w, r, user, company)
}
func collectMediaEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*mediaEntry, error) {
rows, err := conn.Query(ctx, `
select media_id
, media.path
from media
where company_id = $1
order by media_id
`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var media []*mediaEntry
for rows.Next() {
entry := &mediaEntry{}
if err = rows.Scan(&entry.ID, &entry.Path); err != nil {
return nil, err
}
media = append(media, entry)
}
return media, nil
}
type mediaEntry struct {
ID uint
Path string
}
type mediaIndex struct {
Media []*mediaEntry
}
func (page *mediaIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/index.gohtml", page)
}
func serveMediaPicker(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
media, err := collectMediaEntries(r.Context(), company, conn)
if err != nil {
panic(err)
}
query := r.URL.Query()
page := &mediaPicker{
Media: media,
Field: &form.Media{
Input: &form.Input{
Name: query.Get("name"),
Val: query.Get("value"),
},
Label: query.Get("label"),
Prompt: query.Get("prompt"),
},
}
page.MustRender(w, r, user, company)
}
type mediaPicker struct {
Media []*mediaEntry
Field *form.Media
}
func (page *mediaPicker) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderNoLayout(w, r, user, company, "media/picker.gohtml", page)
}
func pickMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
input := &form.Media{
Input: &form.Input{
Name: strings.TrimSpace(r.FormValue("name")),
Val: strings.TrimSpace(r.FormValue("value")),
},
Label: strings.TrimSpace(r.FormValue("label")),
Prompt: strings.TrimSpace(r.FormValue("prompt")),
}
template.MustRenderNoLayout(w, r, user, company, "media/field.gohtml", input)
}
func uploadMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newUploadForm()
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) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select add_media($1, $2, $3, $4)", company.ID, f.File.Filename(), f.File.ContentType, bytes)
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
type uploadForm struct {
File *form.File
}
func newUploadForm() *uploadForm {
return &uploadForm{
File: &form.File{
Name: "media",
MaxSize: 10 * 1 << 20,
},
}
}
func (f *uploadForm) Parse(w http.ResponseWriter, r *http.Request) error {
maxSize := f.File.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err
}
if err := f.File.FillValue(r); err != nil {
return err
}
return nil
}
func (f *uploadForm) Close() error {
return f.File.Close()
}
func (f *uploadForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.Check(f.File, f.HasFile(), l.GettextNoop("Uploaded file can not be empty."))
return v.AllOK
}
func (f *uploadForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/upload.gohtml", f)
}
func (f *uploadForm) HasFile() bool {
return f.File.HasData()
}
func (f *uploadForm) MustReadAllFile() []byte {
if !f.HasFile() {
return nil
}
bytes, err := io.ReadAll(f.File)
if err != nil {
panic(err)
}
return bytes
}
type mediaForm struct {
*uploadForm
ID int
Path string
OriginalFilename *form.Input
}
func newMediaForm() *mediaForm {
return &mediaForm{
uploadForm: newUploadForm(),
OriginalFilename: &form.Input{
Name: "original_filename",
},
}
}
func (f *mediaForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error {
f.ID = id
row := conn.QueryRow(ctx, "select original_filename, media.path from media where media_id = $1", id)
return row.Scan(&f.OriginalFilename.Val, &f.Path)
}
func (f *mediaForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "media/form.gohtml", f)
}
func (f *mediaForm) Parse(w http.ResponseWriter, r *http.Request) error {
if err := f.uploadForm.Parse(w, r); err != nil {
return err
}
f.OriginalFilename.FillValue(r)
return nil
}
func (f *mediaForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckRequired(f.OriginalFilename, l.GettextNoop("Filename can not be empty."))
return v.AllOK
}
func editMedia(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *mediaForm) {
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) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
if f.HasFile() {
bytes := f.MustReadAllFile()
conn.MustExec(r.Context(), "select edit_media($1, $2, $3, $4)", f.ID, f.OriginalFilename, f.File.ContentType, bytes)
} else {
conn.MustExec(r.Context(), "select edit_media($1, $2)", f.ID, f.OriginalFilename)
}
httplib.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app
package media
import (
"net/http"
@ -18,45 +18,45 @@ import (
httplib "dev.tandem.ws/tandem/camper/pkg/http"
)
type mediaHandler struct {
type PublicHandler struct {
mediaDir string
fileHandler http.Handler
}
func newMediaHandler(mediaDir string) (*mediaHandler, error) {
func NewPublicHandler(mediaDir string) (*PublicHandler, error) {
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return nil, err
}
handler := &mediaHandler{
handler := &PublicHandler{
mediaDir: mediaDir,
fileHandler: http.FileServer(http.Dir(mediaDir)),
}
return handler, nil
}
func (h *mediaHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
if !mediaHashValid(head) {
if !hashValid(head) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
h.serveMedia(w, r, company, conn, strings.ToLower(head))
h.serveMedia(w, r, conn, strings.ToLower(head))
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
}
})
}
func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, company *auth.Company, conn *database.Conn, hash string) {
func (h *PublicHandler) serveMedia(w http.ResponseWriter, r *http.Request, conn *database.Conn, hash string) {
mediaPath := h.mediaPath(hash)
var err error
if _, err = os.Stat(mediaPath); err != nil {
bytes, err := conn.GetBytes(r.Context(), "select content from media where company_id = $1 and hash = decode($2, 'hex')", company.ID, hash)
bytes, err := conn.GetBytes(r.Context(), "select bytes from media_content where content_hash = decode($1, 'hex')", hash)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
@ -78,11 +78,11 @@ func (h *mediaHandler) serveMedia(w http.ResponseWriter, r *http.Request, compan
h.fileHandler.ServeHTTP(w, r)
}
func (h *mediaHandler) mediaPath(hash string) string {
func (h *PublicHandler) mediaPath(hash string) string {
return filepath.Join(h.mediaDir, hash[:2], hash[2:])
}
func mediaHashValid(s string) bool {
func hashValid(s string) bool {
if len(s) != 64 {
return false
}

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app
package media
import (
"strings"
@ -24,7 +24,7 @@ var tests = []test{
}
func testValid(t *testing.T, in string, isHash bool) {
if ok := mediaHashValid(in); ok != isHash {
if ok := hashValid(in); ok != isHash {
t.Errorf("Valid(%s) got %v expected %v", in, ok, isHash)
}
}

View File

@ -9,18 +9,25 @@ import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"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"
)
const carouselName = "services"
type AdminHandler struct {
locales locale.Locales
locales locale.Locales
carousel *carousel.AdminHandler
}
func NewAdminHandler(locales locale.Locales) *AdminHandler {
return &AdminHandler{locales}
return &AdminHandler{
locales: locales,
carousel: carousel.NewAdminHandler(carouselName, locales),
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
@ -37,7 +44,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "slides":
h.carouselHandler(user, company, conn).ServeHTTP(w, r)
h.carousel.Handler(user, company, conn).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@ -45,7 +52,7 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
slides, err := collectSlideEntries(r.Context(), company, conn)
slides, err := carousel.CollectSlideEntries(r.Context(), company, conn, carouselName)
if err != nil {
panic(err)
}
@ -56,7 +63,7 @@ func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, com
}
type servicesIndex struct {
Slides []*slideEntry
Slides []*carousel.SlideEntry
}
func (page *servicesIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {

View File

@ -1,311 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package services
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 services_carousel as slide
join media using (media_id)
left join services_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 services_carousel_i18n as i18n where i18n.media_id = services_carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym)
from services_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_services_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_services_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_services_carousel_slide($1)", id)
httplib.Redirect(w, r, "/admin/services", 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 services_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/services", 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, "services/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
}

View File

@ -10,6 +10,7 @@ import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/carousel"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
@ -45,7 +46,7 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type servicesPage struct {
*template.PublicPage
Services []*service
Carousel []*carouselSlide
Carousel []*carousel.Slide
}
func newServicesPage() *servicesPage {
@ -55,7 +56,7 @@ func newServicesPage() *servicesPage {
func (p *servicesPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
p.Services = mustCollectServices(r.Context(), company, conn, user.Locale)
p.Carousel = mustCollectCarouselSlides(r.Context(), company, conn, user.Locale)
p.Carousel = carousel.MustCollectSlides(r.Context(), company, conn, user.Locale, carouselName)
template.MustRenderPublic(w, r, user, company, "services.gohtml", p)
}

30
pkg/template/humanize.go Normal file
View File

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 20122015 Dustin Sallings <dustin@spy.net>
* SPDX-License-Identifier: MIT
*/
package template
import (
"fmt"
"math"
)
func humanizeBytes(s int64) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
base := 1024.0
e := math.Floor(logn(float64(s), base))
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return fmt.Sprintf(f, val, sizes[int(e)])
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}

View File

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 20122015 Dustin Sallings <dustin@spy.net>
* SPDX-License-Identifier: MIT
*/
package template
import "testing"
func testHumanizeBytes(t *testing.T, in int64, exp string) {
if actual := humanizeBytes(in); actual != exp {
t.Errorf("humanizeBytes(%d) got %v expected %v", in, actual, exp)
}
}
func TestBytes(t *testing.T) {
const (
IByte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
tests := []struct {
in int64
exp string
}{
{0, "0 B"},
{1, "1 B"},
{803, "803 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{MiByte - IByte, "1024 KiB"},
{1024 * 1024, "1.0 MiB"},
{GiByte - KiByte, "1024 MiB"},
{GiByte, "1.0 GiB"},
{TiByte - MiByte, "1024 GiB"},
{TiByte, "1.0 TiB"},
{PiByte - TiByte, "1023 TiB"},
{PiByte, "1.0 PiB"},
{EiByte - PiByte, "1023 PiB"},
{EiByte, "1.0 EiB"},
{5.5 * GiByte, "5.5 GiB"},
}
for _, tt := range tests {
testHumanizeBytes(t, tt.in, tt.exp)
}
}

View File

@ -10,6 +10,7 @@ import (
"html/template"
"io"
"net/http"
"path"
"dev.tandem.ws/tandem/camper/pkg/auth"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
@ -28,16 +29,20 @@ func MustRenderAdmin(w io.Writer, r *http.Request, user *auth.User, company *aut
if httplib.IsHTMxRequest(r) {
layout = "htmx.gohtml"
}
mustRenderLayout(w, user, company, adminTemplateFile, layout, filename, data)
mustRenderLayout(w, user, company, adminTemplateFile, data, layout, filename)
}
func MustRenderNoLayout(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
mustRenderLayout(w, user, company, adminTemplateFile, data, filename)
}
func MustRenderPublic(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, filename string, data interface{}) {
layout := "layout.gohtml"
mustRenderLayout(w, user, company, publicTemplateFile, layout, filename, data)
mustRenderLayout(w, user, company, publicTemplateFile, data, layout, filename)
}
func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templateFile func(string) string, layout string, filename string, data interface{}) {
t := template.New(filename)
func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templateFile func(string) string, data interface{}, templates ...string) {
t := template.New(templates[len(templates)-1])
t.Funcs(template.FuncMap{
"gettext": user.Locale.Get,
"pgettext": user.Locale.GetC,
@ -57,14 +62,22 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"raw": func(s string) template.HTML {
return template.HTML(s)
},
"humanizeBytes": func(bytes int64) string {
return humanizeBytes(bytes)
},
})
if _, err := t.ParseFiles(templateFile(layout), templateFile("form.gohtml"), templateFile(filename)); err != nil {
templates = append(templates, "form.gohtml")
files := make([]string, len(templates))
for i, tmpl := range templates {
files[i] = templateFile(tmpl)
}
if _, err := t.ParseFiles(files...); err != nil {
panic(err)
}
if rw, ok := w.(http.ResponseWriter); ok {
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
}
if err := t.ExecuteTemplate(w, layout, data); err != nil {
if err := t.ExecuteTemplate(w, path.Base(templates[0]), data); err != nil {
panic(err)
}
}

259
po/ca.po
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-17 03:28+0200\n"
"POT-Creation-Date: 2023-09-21 01:41+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"
@ -28,7 +28,7 @@ msgstr "Serveis"
msgid "The campsite offers many different services."
msgstr "El càmping disposa de diversos serveis."
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:29
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title"
msgid "Home"
msgstr "Inici"
@ -131,8 +131,8 @@ msgstr "Caiac"
msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…."
msgstr "Hi ha diversos punts on poder anar amb caiac, des de trams del riu Ter com també a la costa…."
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:24
#: web/templates/public/layout.gohtml:59
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut"
msgstr "Càmping Montagut"
@ -140,10 +140,70 @@ msgstr "Càmping Montagut"
msgid "Skip to main content"
msgstr "Salta al contingut principal"
#: web/templates/public/layout.gohtml:33
#: web/templates/public/layout.gohtml:32
msgid "Singular Lodges"
msgstr "Allotjaments singulars"
#: web/templates/admin/carousel/form.gohtml:8
#: web/templates/admin/carousel/form.gohtml:26
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edició de la diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:10
#: web/templates/admin/carousel/form.gohtml:28
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nova diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:38
#: web/templates/admin/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Llegenda"
#: web/templates/admin/carousel/form.gohtml:48
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:67
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/media/form.gohtml:36
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/carousel/form.gohtml:50
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:69
#: web/templates/admin/season/form.gohtml:67
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/carousel/l10n.gohtml:7
#: web/templates/admin/carousel/l10n.gohtml:15
msgctxt "title"
msgid "Translate Carousel Slide to %s"
msgstr "Traducció de la diapositiva del carrusel a %s"
#: web/templates/admin/carousel/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/carousel/l10n.gohtml:24
#: 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/carousel/l10n.gohtml:33
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Tradueix"
#: web/templates/admin/campsite/form.gohtml:8
#: web/templates/admin/campsite/form.gohtml:26
msgctxt "title"
@ -176,24 +236,6 @@ msgctxt "input"
msgid "Label"
msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/services/carousel/form.gohtml:58
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action"
msgid "Update"
msgstr "Actualitza"
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/services/carousel/form.gohtml:60
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action"
msgid "Add"
msgstr "Afegeix"
#: web/templates/admin/campsite/index.gohtml:6
#: web/templates/admin/campsite/index.gohtml:13
#: web/templates/admin/layout.gohtml:70
@ -233,24 +275,24 @@ msgid "No campsites added yet."
msgstr "No sha afegit cap allotjament encara."
#: web/templates/admin/campsite/type/form.gohtml:8
#: web/templates/admin/campsite/type/form.gohtml:27
#: web/templates/admin/campsite/type/form.gohtml:26
msgctxt "title"
msgid "Edit Campsite Type"
msgstr "Edició del tipus dallotjament"
#: web/templates/admin/campsite/type/form.gohtml:10
#: web/templates/admin/campsite/type/form.gohtml:29
#: web/templates/admin/campsite/type/form.gohtml:28
msgctxt "title"
msgid "New Campsite Type"
msgstr "Nou tipus dallotjament"
#: web/templates/admin/campsite/type/form.gohtml:39
#: web/templates/admin/campsite/type/form.gohtml:38
#: 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/form.gohtml:47
#: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26
@ -258,14 +300,7 @@ msgctxt "input"
msgid "Name"
msgstr "Nom"
#: web/templates/admin/campsite/type/form.gohtml:59
#: web/templates/admin/services/carousel/form.gohtml:39
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input"
msgid "Cover image"
msgstr "Imatge de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/form.gohtml:58
#: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input"
msgid "Description"
@ -306,28 +341,6 @@ 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
#: web/templates/admin/services/carousel/l10n.gohtml:22
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/services/carousel/l10n.gohtml:24
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input"
msgid "Translation:"
msgstr "Traducció:"
#: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/services/carousel/l10n.gohtml:33
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action"
msgid "Translate"
msgstr "Tradueix"
#: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26
msgctxt "title"
@ -399,43 +412,11 @@ msgctxt "action"
msgid "Login"
msgstr "Entra"
#: web/templates/admin/services/carousel/form.gohtml:8
#: web/templates/admin/services/carousel/form.gohtml:27
#: 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/services/carousel/form.gohtml:10
#: web/templates/admin/services/carousel/form.gohtml:29
#: 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/services/carousel/form.gohtml:48
#: web/templates/admin/services/carousel/l10n.gohtml:21
#: 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/services/carousel/l10n.gohtml:7
#: web/templates/admin/services/carousel/l10n.gohtml:15
#: 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/services/index.gohtml:6
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
#: web/templates/admin/layout.gohtml:79
msgctxt "title"
msgid "Home Page"
msgstr "Pàgina dinici"
msgid "Services Page"
msgstr "Pàgina de serveis"
#: web/templates/admin/services/index.gohtml:12
#: web/templates/admin/home/index.gohtml:12
@ -598,27 +579,59 @@ msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: web/templates/admin/layout.gohtml:79
#, fuzzy
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
msgctxt "title"
msgid "Services Page"
msgstr "Serveis"
msgid "Home Page"
msgstr "Pàgina dinici"
#: web/templates/admin/layout.gohtml:82
#: web/templates/admin/media/index.gohtml:6
#: web/templates/admin/media/index.gohtml:12
msgctxt "title"
msgid "Media"
msgstr "Mèdia"
#: web/templates/admin/media/picker.gohtml:7
msgctxt "title"
msgid "Media Picker"
msgstr "Selector de mèdia"
#: web/templates/admin/media/picker.gohtml:26
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No sha pujat cap mèdia encara."
#: web/templates/admin/media/picker.gohtml:29
msgctxt "action"
msgid "Cancel"
msgstr "Canceŀla"
#: web/templates/admin/media/form.gohtml:6
#: web/templates/admin/media/form.gohtml:13
msgctxt "title"
msgid "Edit Media"
msgstr "Edició de mèdia"
#: web/templates/admin/media/form.gohtml:19
msgctxt "input"
msgid "Updated file"
msgstr "Fitxer actualitzat"
#: web/templates/admin/media/form.gohtml:23
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Mida màxima del fitxer a pujar: %s"
#: web/templates/admin/media/form.gohtml:28
msgctxt "input"
msgid "Filename"
msgstr "Nom del fitxer"
#: web/templates/admin/media/index.gohtml:13
msgctxt "action"
msgid "Upload media"
msgstr "Puja mèdia"
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No sha pujat cap mèdia encara."
#: web/templates/admin/media/upload.gohtml:6
#: web/templates/admin/media/upload.gohtml:13
msgctxt "title"
@ -630,15 +643,29 @@ msgctxt "input"
msgid "File"
msgstr "Fitxer"
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Mida màxima del fitxer a pujar: %s"
#: web/templates/admin/media/upload.gohtml:27
msgctxt "action"
msgid "Upload"
msgstr "Puja"
#: pkg/carousel/admin.go:233 pkg/campsite/types/admin.go:236
msgctxt "input"
msgid "Cover image"
msgstr "Imatge de portada"
#: pkg/carousel/admin.go:234
msgctxt "action"
msgid "Set cover image"
msgstr "Estableix la imatge de portada"
#: pkg/carousel/admin.go:286
msgid "Slide image can not be empty."
msgstr "No podeu deixar la imatge de la diapositiva en blanc."
#: pkg/carousel/admin.go:287 pkg/campsite/types/admin.go:276
msgid "Cover image must be a image media."
msgstr "La imatge de portada ha de ser un mèdia dimatge."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc."
@ -661,7 +688,7 @@ msgid "Automatic"
msgstr "Automàtic"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82
#: pkg/campsite/types/admin.go:294 pkg/season/admin.go:203
#: pkg/campsite/types/admin.go:274 pkg/season/admin.go:203
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
@ -673,16 +700,20 @@ 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:296
#: pkg/services/carousel.go:287 pkg/home/carousel.go:287
#: pkg/app/user.go:253
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:50
#: pkg/app/admin.go:53
msgid "Access forbidden"
msgstr "Accés prohibit"
#: pkg/campsite/types/admin.go:298
#: pkg/campsite/types/admin.go:237
msgctxt "action"
msgid "Set campsite type cover"
msgstr "Estableix la portada del tipus dallotjament"
#: pkg/campsite/types/admin.go:275
msgid "Cover image can not be empty."
msgstr "No podeu deixar la imatge de portada en blanc."
@ -702,10 +733,6 @@ msgstr "No podeu deixar el color en blanc."
msgid "This color is not valid. It must be like #123abc."
msgstr "Aquest color no és vàlid. Hauria de ser similar a #123abc."
#: pkg/services/carousel.go:289 pkg/home/carousel.go:289
msgid "Slide image can not be empty."
msgstr "No podeu deixar la imatge de la diapositiva en blanc."
#: pkg/company/admin.go:186
msgid "Selected country is not valid."
msgstr "El país escollit no és vàlid."
@ -770,10 +797,14 @@ msgstr "No podeu deixar el format del número de factura en blanc."
msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."
#: pkg/media/admin.go:164
#: pkg/media/admin.go:255
msgid "Uploaded file can not be empty."
msgstr "No podeu deixar el fitxer del mèdia en blanc."
#: pkg/media/admin.go:314
msgid "Filename can not be empty."
msgstr "No podeu deixar el nom del fitxer."
#~ msgid "Surroundings"
#~ msgstr "Entorn"

260
po/es.po
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-17 03:28+0200\n"
"POT-Creation-Date: 2023-09-21 01:41+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"
@ -28,7 +28,7 @@ msgstr "Servicios"
msgid "The campsite offers many different services."
msgstr "El camping dispone de varios servicios."
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:29
#: web/templates/public/home.gohtml:6 web/templates/public/layout.gohtml:28
msgctxt "title"
msgid "Home"
msgstr "Inicio"
@ -79,7 +79,6 @@ msgid "What to Do Outside the Campsite?"
msgstr "¿Qué hacer desde el camping?"
#: web/templates/public/surroundings.gohtml:15
#, fuzzy
msgid "Campsite Montagut is an ideal starting point for quiet outings, climbing, swimming in the river and gorges, volcanoes, the Fageda den Jordà, cycle tours for all ages…."
msgstr "El Camping Montagut es ideal como punto de salida de excursiones tranquilas, escalada, bañarse en el río y piletones, volcanes, la Fageda den Jordà, salidas en bicicleta para todos los niveles…."
@ -132,8 +131,8 @@ msgstr "Kayak"
msgid "There are several points where you can go by kayak, from sections of the Ter river as well as on the coast…."
msgstr "Hay diversos puntos dónde podéis ir en kayak, desde tramos del río Ter como también en la costa…."
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:24
#: web/templates/public/layout.gohtml:59
#: web/templates/public/layout.gohtml:11 web/templates/public/layout.gohtml:23
#: web/templates/public/layout.gohtml:58
msgid "Campsite Montagut"
msgstr "Camping Montagut"
@ -141,10 +140,70 @@ msgstr "Camping Montagut"
msgid "Skip to main content"
msgstr "Saltar al contenido principal"
#: web/templates/public/layout.gohtml:33
#: web/templates/public/layout.gohtml:32
msgid "Singular Lodges"
msgstr "Alojamientos singulares"
#: web/templates/admin/carousel/form.gohtml:8
#: web/templates/admin/carousel/form.gohtml:26
msgctxt "title"
msgid "Edit Carousel Slide"
msgstr "Edición de la diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:10
#: web/templates/admin/carousel/form.gohtml:28
msgctxt "title"
msgid "New Carousel Slide"
msgstr "Nueva diapositiva del carrusel"
#: web/templates/admin/carousel/form.gohtml:38
#: web/templates/admin/carousel/l10n.gohtml:21
msgctxt "input"
msgid "Caption"
msgstr "Leyenda"
#: web/templates/admin/carousel/form.gohtml:48
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:67
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/media/form.gohtml:36
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/templates/admin/carousel/form.gohtml:50
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:69
#: web/templates/admin/season/form.gohtml:67
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/carousel/l10n.gohtml:7
#: web/templates/admin/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/carousel/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:22
#: web/templates/admin/campsite/type/l10n.gohtml:34
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/carousel/l10n.gohtml:24
#: 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/carousel/l10n.gohtml:33
#: web/templates/admin/campsite/type/l10n.gohtml:46
msgctxt "action"
msgid "Translate"
msgstr "Traducir"
#: web/templates/admin/campsite/form.gohtml:8
#: web/templates/admin/campsite/form.gohtml:26
msgctxt "title"
@ -177,24 +236,6 @@ msgctxt "input"
msgid "Label"
msgstr "Etiqueta"
#: web/templates/admin/campsite/form.gohtml:71
#: web/templates/admin/campsite/type/form.gohtml:77
#: web/templates/admin/season/form.gohtml:65
#: web/templates/admin/services/carousel/form.gohtml:58
#: web/templates/admin/home/carousel/form.gohtml:58
msgctxt "action"
msgid "Update"
msgstr "Actualizar"
#: web/templates/admin/campsite/form.gohtml:73
#: web/templates/admin/campsite/type/form.gohtml:79
#: web/templates/admin/season/form.gohtml:67
#: web/templates/admin/services/carousel/form.gohtml:60
#: web/templates/admin/home/carousel/form.gohtml:60
msgctxt "action"
msgid "Add"
msgstr "Añadir"
#: web/templates/admin/campsite/index.gohtml:6
#: web/templates/admin/campsite/index.gohtml:13
#: web/templates/admin/layout.gohtml:70
@ -234,24 +275,24 @@ msgid "No campsites added yet."
msgstr "No se ha añadido ningún alojamiento todavía."
#: web/templates/admin/campsite/type/form.gohtml:8
#: web/templates/admin/campsite/type/form.gohtml:27
#: web/templates/admin/campsite/type/form.gohtml:26
msgctxt "title"
msgid "Edit Campsite Type"
msgstr "Edición del tipo de alojamientos"
#: web/templates/admin/campsite/type/form.gohtml:10
#: web/templates/admin/campsite/type/form.gohtml:29
#: web/templates/admin/campsite/type/form.gohtml:28
msgctxt "title"
msgid "New Campsite Type"
msgstr "Nuevo tipo de alojamiento"
#: web/templates/admin/campsite/type/form.gohtml:39
#: web/templates/admin/campsite/type/form.gohtml:38
#: 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/form.gohtml:47
#: web/templates/admin/campsite/type/l10n.gohtml:21
#: web/templates/admin/season/form.gohtml:47
#: web/templates/admin/profile.gohtml:26
@ -259,14 +300,7 @@ msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: web/templates/admin/campsite/type/form.gohtml:59
#: web/templates/admin/services/carousel/form.gohtml:39
#: web/templates/admin/home/carousel/form.gohtml:39
msgctxt "input"
msgid "Cover image"
msgstr "Imagen de portada"
#: web/templates/admin/campsite/type/form.gohtml:68
#: web/templates/admin/campsite/type/form.gohtml:58
#: web/templates/admin/campsite/type/l10n.gohtml:33
msgctxt "input"
msgid "Description"
@ -307,28 +341,6 @@ 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
#: web/templates/admin/services/carousel/l10n.gohtml:22
#: web/templates/admin/home/carousel/l10n.gohtml:22
msgid "Source:"
msgstr "Origen:"
#: web/templates/admin/campsite/type/l10n.gohtml:24
#: web/templates/admin/campsite/type/l10n.gohtml:37
#: web/templates/admin/services/carousel/l10n.gohtml:24
#: web/templates/admin/home/carousel/l10n.gohtml:24
msgctxt "input"
msgid "Translation:"
msgstr "Traducción"
#: web/templates/admin/campsite/type/l10n.gohtml:46
#: web/templates/admin/services/carousel/l10n.gohtml:33
#: web/templates/admin/home/carousel/l10n.gohtml:33
msgctxt "action"
msgid "Translate"
msgstr "Traducir"
#: web/templates/admin/season/form.gohtml:8
#: web/templates/admin/season/form.gohtml:26
msgctxt "title"
@ -400,43 +412,11 @@ msgctxt "action"
msgid "Login"
msgstr "Entrar"
#: web/templates/admin/services/carousel/form.gohtml:8
#: web/templates/admin/services/carousel/form.gohtml:27
#: 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/services/carousel/form.gohtml:10
#: web/templates/admin/services/carousel/form.gohtml:29
#: 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/services/carousel/form.gohtml:48
#: web/templates/admin/services/carousel/l10n.gohtml:21
#: 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/services/carousel/l10n.gohtml:7
#: web/templates/admin/services/carousel/l10n.gohtml:15
#: 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/services/index.gohtml:6
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
#: web/templates/admin/layout.gohtml:79
msgctxt "title"
msgid "Home Page"
msgstr "Página de inicio"
msgid "Services Page"
msgstr "Página de servicios"
#: web/templates/admin/services/index.gohtml:12
#: web/templates/admin/home/index.gohtml:12
@ -599,27 +579,59 @@ msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: web/templates/admin/layout.gohtml:79
#, fuzzy
#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6
msgctxt "title"
msgid "Services Page"
msgstr "Servicios"
msgid "Home Page"
msgstr "Página de inicio"
#: web/templates/admin/layout.gohtml:82
#: web/templates/admin/media/index.gohtml:6
#: web/templates/admin/media/index.gohtml:12
msgctxt "title"
msgid "Media"
msgstr "Medios"
#: web/templates/admin/media/picker.gohtml:7
msgctxt "title"
msgid "Media Picker"
msgstr "Selector de medio"
#: web/templates/admin/media/picker.gohtml:26
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No se ha subido ningún medio todavía."
#: web/templates/admin/media/picker.gohtml:29
msgctxt "action"
msgid "Cancel"
msgstr "Cancelar"
#: web/templates/admin/media/form.gohtml:6
#: web/templates/admin/media/form.gohtml:13
msgctxt "title"
msgid "Edit Media"
msgstr "Edición de medio"
#: web/templates/admin/media/form.gohtml:19
msgctxt "input"
msgid "Updated file"
msgstr "Archivo actualizado"
#: web/templates/admin/media/form.gohtml:23
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Tamaño máximo del archivos a subir: %s"
#: web/templates/admin/media/form.gohtml:28
msgctxt "input"
msgid "Filename"
msgstr "Nombre de archivo"
#: web/templates/admin/media/index.gohtml:13
msgctxt "action"
msgid "Upload media"
msgstr "Subir medio"
#: web/templates/admin/media/index.gohtml:21
msgid "No media uploaded yet."
msgstr "No se ha subido ningún medio todavía."
#: web/templates/admin/media/upload.gohtml:6
#: web/templates/admin/media/upload.gohtml:13
msgctxt "title"
@ -631,15 +643,29 @@ msgctxt "input"
msgid "File"
msgstr "Archivo"
#: web/templates/admin/media/upload.gohtml:22
msgid "Maximum upload file size: %s"
msgstr "Tamaño máximo del archivos a subir: %s"
#: web/templates/admin/media/upload.gohtml:27
msgctxt "action"
msgid "Upload"
msgstr "Subir"
#: pkg/carousel/admin.go:233 pkg/campsite/types/admin.go:236
msgctxt "input"
msgid "Cover image"
msgstr "Imagen de portada"
#: pkg/carousel/admin.go:234
msgctxt "action"
msgid "Set cover image"
msgstr "Establecer la imagen de portada"
#: pkg/carousel/admin.go:286
msgid "Slide image can not be empty."
msgstr "No podéis dejar la imagen de la diapositiva en blanco."
#: pkg/carousel/admin.go:287 pkg/campsite/types/admin.go:276
msgid "Cover image must be a image media."
msgstr "La imagen de portada tiene que ser un medio de imagen."
#: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
@ -662,7 +688,7 @@ msgid "Automatic"
msgstr "Automático"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82
#: pkg/campsite/types/admin.go:294 pkg/season/admin.go:203
#: pkg/campsite/types/admin.go:274 pkg/season/admin.go:203
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
@ -674,16 +700,20 @@ 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:296
#: pkg/services/carousel.go:287 pkg/home/carousel.go:287
#: pkg/app/user.go:253
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:50
#: pkg/app/admin.go:53
msgid "Access forbidden"
msgstr "Acceso prohibido"
#: pkg/campsite/types/admin.go:298
#: pkg/campsite/types/admin.go:237
msgctxt "action"
msgid "Set campsite type cover"
msgstr "Establecer la portada del tipo de alojamiento"
#: pkg/campsite/types/admin.go:275
msgid "Cover image can not be empty."
msgstr "No podéis dejar la imagen de portada en blanco."
@ -703,10 +733,6 @@ msgstr "No podéis dejar el color en blanco."
msgid "This color is not valid. It must be like #123abc."
msgstr "Este color no es válido. Tiene que ser parecido a #123abc."
#: pkg/services/carousel.go:289 pkg/home/carousel.go:289
msgid "Slide image can not be empty."
msgstr "No podéis dejar la imagen de la diapositiva en blanco."
#: pkg/company/admin.go:186
msgid "Selected country is not valid."
msgstr "El país escogido no es válido."
@ -771,10 +797,14 @@ msgstr "No podéis dejar el formato de número de factura en blanco."
msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
#: pkg/media/admin.go:164
#: pkg/media/admin.go:255
msgid "Uploaded file can not be empty."
msgstr "No podéis dejar el archivo del medio en blanco."
#: pkg/media/admin.go:314
msgid "Filename can not be empty."
msgstr "No podéis dejar el nombre del archivo en blanco."
#~ msgid "Surroundings"
#~ msgstr "Entorno"

7
revert/edit_media.sql Normal file
View File

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

7
revert/media_content.sql Normal file
View File

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

View File

@ -40,8 +40,10 @@ user_profile [roles schema_camper user company_user current_user_email current_u
policies_company [company user_profile] 2023-08-07T20:04:26Z jordi fita mas <jordi@tandem.blog> # Add row-level security profiles to company
change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jordi fita mas <jordi@tandem.blog> # Add function to change the current users password
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
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_content [roles schema_camper media_type] 2023-09-19T23:21:22Z jordi fita mas <jordi@tandem.blog> # Add relation for media content bytes
media [roles schema_camper company media_content user_profile] 2023-09-08T16:50:55Z jordi fita mas <jordi@tandem.blog> # Add relation of uploaded media
add_media [roles schema_camper media media_content media_type] 2023-09-08T17:40:28Z jordi fita mas <jordi@tandem.blog> # Add function to create media
edit_media [roles schema_camper media_content media media_type] 2023-09-20T15:46:53Z jordi fita mas <jordi@tandem.blog> # Add function to edit a 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_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas <jordi@tandem.blog> # Add relation for campsite_type translations

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -33,9 +34,13 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (4, 2, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate campsite_type_i18n cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -33,9 +34,13 @@ values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '
, (2, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (3, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, 2, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (4, 2, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
select lives_ok(

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -30,10 +31,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into home_carousel (media_id, caption)

View File

@ -10,7 +10,7 @@ set search_path to camper, public;
select plan(13);
select has_function('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']);
select function_lang_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'sql');
select function_lang_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'plpgsql');
select function_returns('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'integer');
select isnt_definer('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea']);
select volatility_is('camper', 'add_media', array ['integer', 'text', 'media_type', 'bytea'], 'volatile');
@ -22,6 +22,7 @@ select function_privs_are('camper', 'add_media', array ['integer', 'text', 'medi
set client_min_messages to warning;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -46,7 +47,7 @@ select lives_ok(
);
select bag_eq(
$$ select company_id, hash, original_filename, media_type, convert_from(content, 'utf-8') from media $$,
$$ select company_id, content_hash, original_filename, media_type, convert_from(bytes, 'utf-8') from media join media_content using (content_hash) $$,
$$ values (1, sha256('hello, world!'), 'text.txt', 'text/plain', 'hello, world!')
, (2, sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'), 'image.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, (2, sha256('hello, world!'), 'world.txt', 'text/plain', 'hello, world!')

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade;
truncate services_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -31,10 +32,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into services_carousel (media_id, caption)

View File

@ -66,6 +66,7 @@ set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -93,9 +94,13 @@ values (2, 'co2')
, (4, 'co4')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (8, 4, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -71,6 +71,7 @@ select col_default_is('campsite_type', 'active', 'true');
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -97,9 +98,13 @@ values (2, 'co2')
, (4, 'co4')
;
insert into media (media_id, company_id, original_filename, media_type, content)
values (6, 2, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (8, 4, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (8, 4, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into campsite_type (company_id, name, media_id)

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate campsite cascade;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -31,8 +32,12 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into campsite_type (campsite_type_id, company_id, media_id, name)

View File

@ -22,6 +22,7 @@ select function_privs_are('camper', 'edit_campsite_type', array ['uuid', 'intege
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -30,10 +31,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (3, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
, (4, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffff00","a"};'))
;
insert into campsite_type (company_id, slug, media_id, name, description, active)

71
test/edit_media.sql Normal file
View File

@ -0,0 +1,71 @@
-- Test edit_media
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', 'edit_media', array['integer', 'text', 'media_type', 'bytea']);
select function_lang_is('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'plpgsql');
select function_returns('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'integer');
select isnt_definer('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea']);
select volatility_is('camper', 'edit_media', array['integer', 'text', 'media_type', 'bytea'], 'volatile');
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'employee', array[]::text[]);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_media', array ['integer', 'text', 'media_type', 'bytea'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate media cascade;
truncate media_content 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_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
;
select lives_ok(
$$ select edit_media(2, 'cover_2.xpm') $$,
'Should be able to change the filename of the first media'
);
select lives_ok(
$$ select edit_media(3, 'cover_3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') $$,
'Should be able to change the filename and hash of the second media to point to a different content'
);
select lives_ok(
$$ select edit_media(4, 'cover4.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>') $$,
'Should be able to change the filename and hash of the thirs media to point to a new content'
);
select bag_eq(
$$ select media_id, content_hash, original_filename, media_type, convert_from(bytes, 'utf-8') from media join media_content using (content_hash) $$,
$$ values (2, sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'), 'cover_2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (3, sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'), 'cover_3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (4, sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'), 'cover4.svg', 'image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
$$,
'Should have added all three media'
);
select *
from finish();
rollback;

View File

@ -32,6 +32,7 @@ select col_hasnt_default('home_carousel', 'caption');
set client_min_messages to warning;
truncate home_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -59,11 +60,18 @@ 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 media_content (media_type, bytes)
values ('text/plain', 'content2')
, ('text/plain', 'content3')
, ('text/plain', 'content4')
, ('text/plain', 'content5')
;
insert into media (media_id, company_id, original_filename, content_hash)
values ( 7, 2, 'text2.txt', sha256('content2'))
, ( 8, 2, 'text3.txt', sha256('content3'))
, ( 9, 4, 'text4.txt', sha256('content4'))
, (10, 4, 'text5.txt', sha256('content5'))
;
insert into home_carousel (media_id, caption)

View File

@ -5,13 +5,12 @@ reset client_min_messages;
begin;
select plan(55);
select plan(47);
set search_path to camper, public;
select has_table('media');
select has_pk('media');
select col_is_unique('media', array['company_id', 'hash']);
select table_privs_are('media', 'guest', array['SELECT']);
select table_privs_are('media', 'employee', array['SELECT']);
select table_privs_are('media', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
@ -37,30 +36,22 @@ select col_type_is('media', 'company_id', 'integer');
select col_not_null('media', 'company_id');
select col_hasnt_default('media', 'company_id');
select has_column('media', 'hash');
select col_type_is('media', 'hash', 'bytea');
select col_not_null('media', 'hash');
select col_has_default('media', 'hash');
select col_default_is('media', 'hash', 'sha256(content)');
select has_column('media', 'content_hash');
select col_is_fk('media', 'content_hash');
select fk_ok('media', 'content_hash', 'media_content', 'content_hash');
select col_type_is('media', 'content_hash', 'bytea');
select col_not_null('media', 'content_hash');
select col_hasnt_default('media', 'content_hash');
select has_column('media', 'original_filename');
select col_type_is('media', 'original_filename', 'text');
select col_not_null('media', 'original_filename');
select col_hasnt_default('media', 'original_filename');
select has_column('media', 'media_type');
select col_type_is('media', 'media_type', 'media_type');
select col_not_null('media', 'media_type');
select col_hasnt_default('media', 'media_type');
select has_column('media', 'content');
select col_type_is('media', 'content', 'bytea');
select col_not_null('media', 'content');
select col_hasnt_default('media', 'content');
set client_min_messages to warning;
truncate media cascade;
truncate media_content cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -87,9 +78,15 @@ values (2, 'co2')
, (4, 'co4')
;
insert into media (company_id, original_filename, media_type, content)
values (2, 'text2.txt', 'text/plain', 'content2')
, (4, 'text4.txt', 'text/plain', 'content4')
insert into media_content (media_type, bytes)
values ('text/plain', 'content2')
, ('text/plain', 'content4')
, ('text/plain', 'content6')
;
insert into media (company_id, original_filename, content_hash)
values (2, 'text2.txt', sha256('content2'))
, (4, 'text4.txt', sha256('content4'))
;
prepare media_data as
@ -110,36 +107,36 @@ reset role;
select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2');
select lives_ok(
$$ insert into media(company_id, original_filename, media_type, content)
values (2, 'text2-2.txt', 'text/plain', sha256('Another media')) $$,
$$ insert into media(company_id, original_filename, content_hash)
values (2, 'text2-6.txt', sha256('content6')) $$,
'Admin from company 2 should be able to insert a new media to that company.'
);
select bag_eq(
'media_data',
$$ values (2, 'text2.txt')
, (2, 'text2-2.txt')
, (2, 'text2-6.txt')
, (4, 'text4.txt')
$$,
'The new row should have been added'
);
select lives_ok(
$$ update media set original_filename = 'text2_2.txt' where company_id = 2 and original_filename = 'text2-2.txt' $$,
$$ update media set original_filename = 'text2_6.txt' where company_id = 2 and original_filename = 'text2-6.txt' $$,
'Admin from company 2 should be able to update media of that company.'
);
select bag_eq(
'media_data',
$$ values (2, 'text2.txt')
, (2, 'text2_2.txt')
, (2, 'text2_6.txt')
, (4, 'text4.txt')
$$,
'The row should have been updated.'
);
select lives_ok(
$$ delete from media where company_id = 2 and original_filename = 'text2_2.txt' $$,
$$ delete from media where company_id = 2 and original_filename = 'text2_6.txt' $$,
'Admin from company 2 should be able to delete media from that company.'
);
@ -152,8 +149,8 @@ select bag_eq(
);
select throws_ok(
$$ insert into media (company_id, original_filename, media_type, content)
values (4, 'text4-2.txt', 'text/plain', 'Another media') $$,
$$ insert into media (company_id, original_filename, content_hash)
values (4, 'text4-2.txt', sha256('content6')) $$,
'42501', 'new row violates row-level security policy for table "media"',
'Admin from company 2 should NOT be able to insert new media to company 4.'
);
@ -191,7 +188,7 @@ select bag_eq(
);
select throws_ok(
$$ insert into media (company_id, original_filename, media_type, content) values (2, ' ', 'text/plain', 'content') $$,
$$ insert into media (company_id, original_filename, content_hash) values (2, ' ', sha256('content6')) $$,
'23514', 'new row for relation "media" violates check constraint "original_filename_not_empty"',
'Should not be able to insert media with a blank filename.'
);

59
test/media_content.sql Normal file
View File

@ -0,0 +1,59 @@
-- Test media_content
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(21);
set search_path to camper, public;
select has_table('media_content');
select has_pk('media_content');
select table_privs_are('media_content', 'guest', array['SELECT']);
select table_privs_are('media_content', 'employee', array['SELECT']);
select table_privs_are('media_content', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']);
select table_privs_are('media_content', 'authenticator', array[]::text[]);
select has_column('media_content', 'content_hash');
select col_is_pk('media_content', 'content_hash');
select col_type_is('media_content', 'content_hash', 'bytea');
select col_not_null('media_content', 'content_hash');
select col_has_default('media_content', 'content_hash');
select col_default_is('media_content', 'content_hash', 'sha256(bytes)');
select has_column('media_content', 'media_type');
select col_type_is('media_content', 'media_type', 'media_type');
select col_not_null('media_content', 'media_type');
select col_hasnt_default('media_content', 'media_type');
select has_column('media_content', 'bytes');
select col_type_is('media_content', 'bytes', 'bytea');
select col_not_null('media_content', 'bytes');
select col_hasnt_default('media_content', 'bytes');
set client_min_messages to warning;
truncate media_content cascade;
reset client_min_messages;
insert into media_content (media_type, bytes)
values ('text/plain', 'content2')
, ('text/plain', 'content4')
;
select bag_eq(
$$ select encode(content_hash, 'hex'), convert_from(bytes, 'utf-8') from media_content $$,
$$ values ('dab741b6289e7dccc1ed42330cae1accc2b755ce8079c2cd5d4b5366c9f769a6', 'content2')
, ('b04813d4f04a27cbd8a5d7828344a0c7d206a486343f503cb7e3d53e1d8e95a0', 'content4')
$$,
'Should automatically compute the content_hash'
);
select *
from finish();
rollback;

View File

@ -21,6 +21,7 @@ select function_privs_are('camper', 'path', array['media'], 'authenticator', arr
set client_min_messages to warning;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -28,12 +29,20 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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!')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('text/plain', 'hello, world!')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (3, 1, 'cover1.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff0000","a"};'))
, (4, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","b c #00ff00","b"};'))
, (5, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","c c #0000ff","c"};'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'text.txt', sha256('hello, world!'))
;
select bag_eq(

View File

@ -23,6 +23,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -30,10 +31,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into home_carousel (media_id, caption)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade;
truncate services_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -31,10 +32,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into services_carousel (media_id, caption)

View File

@ -32,6 +32,7 @@ select col_hasnt_default('services_carousel', 'caption');
set client_min_messages to warning;
truncate services_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company_host cascade;
truncate company_user cascade;
truncate company cascade;
@ -58,11 +59,18 @@ 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 media_content (media_type, bytes)
values ('text/plain', 'content2')
, ('text/plain', 'content3')
, ('text/plain', 'content4')
, ('text/plain', 'content5')
;
insert into media (media_id, company_id, original_filename, content_hash)
values ( 7, 2, 'text2.txt', sha256('content2'))
, ( 8, 2, 'text3.txt', sha256('content3'))
, ( 9, 4, 'text4.txt', sha256('content4'))
, (10, 4, 'text5.txt', sha256('content5'))
;
insert into services_carousel (media_id, caption)

View File

@ -23,6 +23,7 @@ select function_privs_are('camper', 'translate_campsite_type', array['uuid', 'te
set client_min_messages to warning;
truncate campsite_type cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -30,10 +31,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 (2, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, (3, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
, (4, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
insert into media_content (media_type, bytes)
values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff00ff","a"};')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffff00","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
, (3, 1, 'cover3.xpm', sha256('static char *s[]={"1 1 1 1","a c #ff00ff","a"};'))
, (4, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffff00","a"};'))
;
insert into campsite_type (company_id, slug, media_id, name, description, active)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate home_carousel_i18n cascade;
truncate home_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -31,10 +32,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into home_carousel (media_id, caption)

View File

@ -24,6 +24,7 @@ set client_min_messages to warning;
truncate services_carousel_i18n cascade;
truncate services_carousel cascade;
truncate media cascade;
truncate media_content cascade;
truncate company cascade;
reset client_min_messages;
@ -31,10 +32,16 @@ insert into company (company_id, business_name, vatin, trade_name, phone, email,
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 media_content (media_type, bytes)
values ('text/plain', 'hello, world!')
, ('image/svg+xml', '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
, ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};')
;
insert into media (media_id, company_id, original_filename, content_hash)
values (5, 1, 'text.txt', sha256('hello, world!'))
, (6, 1, 'image.svg', sha256('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'))
, (7, 1, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};'))
;
insert into services_carousel (media_id, caption)

7
verify/edit_media.sql Normal file
View File

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

View File

@ -4,10 +4,8 @@ begin;
select media_id
, company_id
, hash
, content_hash
, original_filename
, media_type
, content
from camper.media
where false;

11
verify/media_content.sql Normal file
View File

@ -0,0 +1,11 @@
-- Verify camper:media_content on pg
begin;
select content_hash
, media_type
, bytes
from camper.media_content
where false;
rollback;

View File

@ -48,3 +48,22 @@ p, h1, h2, h3, h4, h5, h6 {
a.missing-translation {
color: #ff0000;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr));
grid-auto-rows: 1fr;
list-style: none;
gap: 1rem;
padding: 0;
}
.media-grid img, .media-grid button {
width: 100%;
height: 100%;
max-height: 26rem;
}
.media-grid img {
object-fit: cover;
}

View File

@ -15,7 +15,6 @@
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.typeForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form
enctype="multipart/form-data"
{{ if .Slug }}
data-hx-put="/admin/campsites/types/{{ .Slug }}"
{{ else }}
@ -52,16 +51,7 @@
{{ template "error-message" . }}
{{- end }}
{{ with .Media -}}
{{ if .Val -}}
<img src="{{ .Val }}" alt="">
{{- end }}
<label>
{{( pgettext "Cover image" "input" )}}
<input type="file" name="{{ .Name }}"
{{ if not $.Slug }}required{{ end }}
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{ template "media-picker" . }}
{{- end }}
{{ with .Description -}}
<label>

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideForm*/ -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/carousel.slideForm*/ -}}
{{ if .ID}}
{{( pgettext "Edit Carousel Slide" "title" )}}
{{ else }}
@ -12,14 +12,13 @@
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideForm*/ -}}
{{ template "settings-tabs" "services" }}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/carousel.slideForm*/ -}}
{{ template "settings-tabs" .CarouselName }}
<form
enctype="multipart/form-data"
{{ if .ID }}
data-hx-put="/admin/services/slides/{{ .ID }}"
data-hx-put="/admin/{{ .CarouselName }}/slides/{{ .ID }}"
{{ else }}
action="/admin/services/slides" method="post"
action="/admin/{{ .CarouselName }}/slides" method="post"
{{ end }}
>
<h2>
@ -32,22 +31,13 @@
{{ 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" . }}
{{ template "media-picker" . }}
{{- end }}
{{ with .Caption -}}
<label>
{{( pgettext "Caption" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}

View File

@ -15,3 +15,19 @@
<option value="{{ .Value }}" {{ if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
{{- end }}
{{ define "media-picker" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/form.Media*/ -}}
<fieldset data-hx-target="this" data-hx-swap="outerHTML" data-hx-vals='{"name": "{{ .Name }}", "value": "{{ .Val }}", "label": "{{ .Label }}", "prompt": "{{ .Prompt }}"}'>
<legend>{{( pgettext .Label "input" )}}</legend>
<input type="hidden" name="{{ .Name }}" value="{{ .Val }}">
<button class="media-picker" type="button" data-hx-get="/admin/media/picker">
{{ if .Val -}}
<img src="/admin/media/{{ .Val }}/content" alt="">
{{ else -}}
{{( pgettext .Prompt "action" )}}
{{- end }}
</button>
{{ template "error-message" . }}
</fieldset>
{{- end }}

View File

@ -1,65 +0,0 @@
<!--
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

@ -78,6 +78,9 @@
<li>
<a {{ if ne . "services"}}href="/admin/services"{{ end }}>{{( pgettext "Services Page" "title" )}}</a>
</li>
<li>
<a {{ if ne . "media"}}href="/admin/media"{{ end }}>{{( pgettext "Media" "title" )}}</a>
</li>
</ul>
</nav>
{{- end }}

View File

@ -0,0 +1,5 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ template "media-picker" . }}

View File

@ -0,0 +1,60 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Edit Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaForm*/ -}}
{{ template "settings-tabs" "media" }}
<form id="upload" enctype="multipart/form-data" data-hx-put="/admin/media/{{ .ID }}">
<h2>{{( pgettext "Edit Media" "title" )}}</h2>
<img src="{{ .Path }}" alt="">
{{ CSRFInput }}
<fieldset>
{{ with .File -}}
<label>
{{( pgettext "Updated file" "input" )}}
<input type="file" name="{{ .Name }}"
{{ template "error-attrs" . }}><br>
</label>
<p>{{ printf (gettext "Maximum upload file size: %s") (.MaxSize | humanizeBytes) }}</p>
{{ template "error-message" . }}
{{- end }}
{{ with .OriginalFilename -}}
<label>
{{( pgettext "Filename" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Update" "action" )}}</button>
<progress value="0" max="100"></progress>
</footer>
</form>
<script>
htmx.on('#upload', 'drop', function (evt) {
evt.preventDefault();
[...evt.dataTransfer.items].forEach(function (i) {
console.log(i);
i.getAsString(console.log)
});
});
htmx.on('#upload', 'dragover', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'dragleave', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'htmx:xhr:progress', function (evt) {
if (evt.detail.lengthComputable) {
htmx.find('progress').setAttribute('value', evt.detail.loaded / evt.detail.total * 100);
}
});
</script>
{{- end }}

View File

@ -0,0 +1,23 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaIndex*/ -}}
{{ template "settings-tabs" "media" }}
<h2>{{( pgettext "Media" "title" )}}</h2>
<a href="/admin/media/upload">{{( pgettext "Upload media" "action" )}}</a>
{{ if .Media -}}
<ul class="media-grid">
{{ range .Media -}}
<li><a href="/admin/media/{{ .ID }}"><img src="{{ .Path }}" alt=""></a></li>
{{- end }}
</ul>
{{ else -}}
<p>{{( gettext "No media uploaded yet." )}}</p>
{{- end }}
{{- end }}

View File

@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<div data-hx-target="this" data-hx-swap="outerHTML">
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.mediaPicker*/ -}}
<h2>{{( pgettext "Media Picker" "title" )}}</h2>
<form action="/admin/media/picker" method="post" data-hx-boost="true">
{{ with .Field -}}
<input type="hidden" name="name" value="{{ .Name }}"/>
<input type="hidden" name="label" value="{{ .Label }}"/>
<input type="hidden" name="prompt" value="{{ .Prompt }}"/>
{{- end }}
{{ if .Media -}}
<fieldset>
<ul class="media-grid">
{{ range .Media -}}
<li>
<button name="value" value="{{ .ID }}" type="submit"><img src="{{ .Path }}" alt="">
</button>
</li>
{{- end }}
</ul>
</fieldset>
{{ else -}}
<p>{{( gettext "No media uploaded yet." )}}</p>
{{- end }}
<footer>
<button name="value" value="{{ .Field.Val }}" type="submit">{{( pgettext "Cancel" "action" )}}</button>
</footer>
</form>
</div>

View File

@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Upload Media" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/media.uploadForm*/ -}}
{{ template "settings-tabs" "media" }}
<form id="upload" enctype="multipart/form-data" action="/admin/media" method="post" data-hx-boost="true">
<h2>{{( pgettext "Upload Media" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .File -}}
<label>
{{( pgettext "File" "input" )}}
<input type="file" name="{{ .Name }}"
required {{ template "error-attrs" . }}><br>
</label>
<p>{{ printf (gettext "Maximum upload file size: %s") (.MaxSize | humanizeBytes) }}</p>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Upload" "action" )}}</button>
<progress value="0" max="100"></progress>
</footer>
</form>
<script>
htmx.on('#upload', 'drop', function (evt) {
evt.preventDefault();
[...evt.dataTransfer.items].forEach(function (i) {
console.log(i);
i.getAsString(console.log)
});
});
htmx.on('#upload', 'dragover', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'dragleave', function (evt) {
evt.preventDefault();
});
htmx.on('#upload', 'htmx:xhr:progress', function (evt) {
if (evt.detail.lengthComputable) {
htmx.find('progress').setAttribute('value', evt.detail.loaded / evt.detail.total * 100);
}
});
</script>
{{- end }}

View File

@ -1,36 +0,0 @@
<!--
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/services.slideL10nForm*/ -}}
{{printf (pgettext "Translate Carousel Slide to %s" "title") .Locale.Endonym }}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/services.slideL10nForm*/ -}}
{{ template "settings-tabs" "campsiteTypes" }}
<form data-hx-put="/admin/services/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 }}