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:
parent
afe77f2296
commit
97cf117da3
2
Makefile
2
Makefile
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 {
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2012–2015 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)
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2012–2015 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)
|
||||
}
|
||||
}
|
|
@ -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
259
po/ca.po
|
@ -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 s’ha 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 d’allotjament"
|
||||
|
||||
#: 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 d’allotjament"
|
||||
|
||||
#: 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 d’allotjament 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 d’inici"
|
||||
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 d’inici"
|
||||
|
||||
#: 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 s’ha 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 s’ha 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 d’imatge."
|
||||
|
||||
#: 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 "L’idioma 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 d’allotjament"
|
||||
|
||||
#: 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 "S’ha 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
260
po/es.po
|
@ -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 d’en 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 d’en 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"
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
-- Revert camper:media_content from pg
|
||||
|
||||
begin;
|
||||
|
||||
drop table if exists camper.media_content;
|
||||
|
||||
commit;
|
|
@ -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 user’s 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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
|
@ -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)
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
|
@ -4,10 +4,8 @@ begin;
|
|||
|
||||
select media_id
|
||||
, company_id
|
||||
, hash
|
||||
, content_hash
|
||||
, original_filename
|
||||
, media_type
|
||||
, content
|
||||
from camper.media
|
||||
where false;
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
-- Verify camper:media_content on pg
|
||||
|
||||
begin;
|
||||
|
||||
select content_hash
|
||||
, media_type
|
||||
, bytes
|
||||
from camper.media_content
|
||||
where false;
|
||||
|
||||
rollback;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
{{ template "media-picker" . }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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>
|
|
@ -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 }}
|
|
@ -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 }}
|
Loading…
Reference in New Issue