Allow users to update their profile images
I do not see the profile image as an “integral part” of the user (i.e., no need for constraints), hence i do not want to store it in the database, as would do for the identification image during check-in. By default, i store the avatars in /var/lib/camper/avatars, but it is a variable to allow packagers change this value using the linker. This is also served as a test bed for uploading files to the server, that now has a better interface and uses less resources that what i did to Numerus. Now the profile handler needs to keep a variable to know the path to the avatars’ directory, thus i had to change it to a struct nested in app, much like the fileHandler does. It still has to return the HandlerFunc, however, as this function needs to close over the user and connection variables. Part of #7.
This commit is contained in:
parent
bf0c86a796
commit
b03ef35a95
|
@ -18,6 +18,8 @@ import (
|
||||||
"dev.tandem.ws/tandem/camper/pkg/database"
|
"dev.tandem.ws/tandem/camper/pkg/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var avatarsDir = "/var/lib/camper/avatars"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
|
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -25,9 +27,13 @@ func main() {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
handler, err := app.New(db, avatarsDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
srv := http.Server{
|
srv := http.Server{
|
||||||
Addr: ":8080",
|
Addr: ":8080",
|
||||||
Handler: app.New(db),
|
Handler: handler,
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
IdleTimeout: 2 * time.Minute,
|
IdleTimeout: 2 * time.Minute,
|
||||||
|
|
|
@ -36,16 +36,23 @@ func methodNotAllowed(w http.ResponseWriter, _ *http.Request, allowed ...string)
|
||||||
type App struct {
|
type App struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
fileHandler http.Handler
|
fileHandler http.Handler
|
||||||
|
profile *profileHandler
|
||||||
locales locale.Locales
|
locales locale.Locales
|
||||||
defaultLocale *locale.Locale
|
defaultLocale *locale.Locale
|
||||||
languageMatcher language.Matcher
|
languageMatcher language.Matcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *database.DB) http.Handler {
|
func New(db *database.DB, avatarsDir string) (http.Handler, error) {
|
||||||
locales := locale.MustGetAll(db)
|
locales := locale.MustGetAll(db)
|
||||||
|
static := http.FileServer(http.Dir("web/static"))
|
||||||
|
profile, err := newProfileHandler(static, avatarsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
app := &App{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
fileHandler: http.FileServer(http.Dir("web/static")),
|
fileHandler: static,
|
||||||
|
profile: profile,
|
||||||
locales: locales,
|
locales: locales,
|
||||||
defaultLocale: locales[language.Catalan],
|
defaultLocale: locales[language.Catalan],
|
||||||
languageMatcher: language.NewMatcher(locales.Tags()),
|
languageMatcher: language.NewMatcher(locales.Tags()),
|
||||||
|
@ -54,7 +61,7 @@ func New(db *database.DB) http.Handler {
|
||||||
var handler http.Handler = app
|
var handler http.Handler = app
|
||||||
handler = httplib.RecoverPanic(handler)
|
handler = httplib.RecoverPanic(handler)
|
||||||
handler = httplib.LogRequest(handler)
|
handler = httplib.LogRequest(handler)
|
||||||
return handler
|
return handler, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -97,7 +104,7 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
switch head {
|
switch head {
|
||||||
case "me":
|
case "me":
|
||||||
profileHandler(user, conn)(w, r)
|
h.profile.Handler(user, conn).ServeHTTP(w, r)
|
||||||
case "":
|
case "":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|
110
pkg/app/user.go
110
pkg/app/user.go
|
@ -7,7 +7,12 @@ package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
@ -30,9 +35,9 @@ func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error)
|
||||||
LoggedIn: false,
|
LoggedIn: false,
|
||||||
Role: "guest",
|
Role: "guest",
|
||||||
}
|
}
|
||||||
row := conn.QueryRow(r.Context(), "select coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
|
row := conn.QueryRow(r.Context(), "select user_id, coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
|
||||||
var langTag string
|
var langTag string
|
||||||
if err := row.Scan(&user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
|
if err := row.Scan(&user.ID, &user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if lang, err := language.Parse(langTag); err == nil {
|
if lang, err := language.Parse(langTag); err == nil {
|
||||||
|
@ -57,12 +62,37 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
type profileHandler struct {
|
||||||
|
fileHandle http.Handler
|
||||||
|
avatarHandle http.Handler
|
||||||
|
avatarsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProfileHandler(static http.Handler, avatarsDir string) (*profileHandler, error) {
|
||||||
|
if err := os.MkdirAll(avatarsDir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handler := &profileHandler{
|
||||||
|
fileHandle: static,
|
||||||
|
avatarsDir: avatarsDir,
|
||||||
|
avatarHandle: http.FileServer(http.Dir(avatarsDir)),
|
||||||
|
}
|
||||||
|
return handler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *profileHandler) Handler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var head string
|
var head string
|
||||||
head, r.URL.Path = shiftPath(r.URL.Path)
|
head, r.URL.Path = shiftPath(r.URL.Path)
|
||||||
|
|
||||||
switch head {
|
switch head {
|
||||||
|
case "avatar":
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.serveAvatar(w, r, user)
|
||||||
|
default:
|
||||||
|
methodNotAllowed(w, r, http.MethodGet)
|
||||||
|
}
|
||||||
case "session":
|
case "session":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
|
@ -75,7 +105,7 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
serveProfileForm(w, r, user, conn)
|
serveProfileForm(w, r, user, conn)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
updateProfile(w, r, user, conn)
|
h.updateProfile(w, r, user, conn)
|
||||||
default:
|
default:
|
||||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
|
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
|
||||||
}
|
}
|
||||||
|
@ -85,6 +115,21 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *profileHandler) serveAvatar(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||||
|
avatarPath := h.avatarPath(user)
|
||||||
|
if _, err := os.Stat(avatarPath); err == nil {
|
||||||
|
r.URL.Path = path.Base(avatarPath)
|
||||||
|
h.avatarHandle.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
r.URL.Path = "default_avatar.svg"
|
||||||
|
h.fileHandle.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *profileHandler) avatarPath(user *auth.User) string {
|
||||||
|
return filepath.Join(h.avatarsDir, strconv.Itoa(user.ID))
|
||||||
|
}
|
||||||
|
|
||||||
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
|
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
|
||||||
profile := newProfileForm(r.Context(), user.Locale, conn)
|
profile := newProfileForm(r.Context(), user.Locale, conn)
|
||||||
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
|
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
|
||||||
|
@ -93,12 +138,13 @@ func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, c
|
||||||
profile.MustRender(w, r, user)
|
profile.MustRender(w, r, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
|
func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
|
||||||
profile := newProfileForm(r.Context(), user.Locale, conn)
|
profile := newProfileForm(r.Context(), user.Locale, conn)
|
||||||
if err := profile.Parse(r); err != nil {
|
if err := profile.Parse(w, r); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer profile.Close()
|
||||||
if err := user.VerifyCSRFToken(r); err != nil {
|
if err := user.VerifyCSRFToken(r); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusForbidden)
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
@ -116,10 +162,18 @@ func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn
|
||||||
if profile.Password.Val != "" {
|
if profile.Password.Val != "" {
|
||||||
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
|
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
|
||||||
}
|
}
|
||||||
if user.Language.String() == profile.Language.String() {
|
redirect := user.Language.String() != profile.Language.String()
|
||||||
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
|
if profile.HasAvatarFile() {
|
||||||
} else {
|
redirect = true
|
||||||
|
if err := profile.SaveAvatarTo(h.avatarPath(user)); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if redirect {
|
||||||
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
|
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
|
||||||
|
} else {
|
||||||
|
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,6 +183,7 @@ type profileForm struct {
|
||||||
Password *form.Input
|
Password *form.Input
|
||||||
PasswordConfirm *form.Input
|
PasswordConfirm *form.Input
|
||||||
Language *form.Select
|
Language *form.Select
|
||||||
|
Avatar *form.File
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
|
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
|
||||||
|
@ -151,11 +206,17 @@ func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn)
|
||||||
Name: "language",
|
Name: "language",
|
||||||
Options: languages,
|
Options: languages,
|
||||||
},
|
},
|
||||||
|
Avatar: &form.File{
|
||||||
|
Name: "avatar",
|
||||||
|
MaxSize: 1 << 20,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *profileForm) Parse(r *http.Request) error {
|
func (f *profileForm) Parse(w http.ResponseWriter, r *http.Request) error {
|
||||||
if err := r.ParseForm(); err != nil {
|
maxSize := f.Avatar.MaxSize + 1024
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||||
|
if err := r.ParseMultipartForm(maxSize); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.Email.FillValue(r)
|
f.Email.FillValue(r)
|
||||||
|
@ -163,9 +224,16 @@ func (f *profileForm) Parse(r *http.Request) error {
|
||||||
f.Password.FillValue(r)
|
f.Password.FillValue(r)
|
||||||
f.PasswordConfirm.FillValue(r)
|
f.PasswordConfirm.FillValue(r)
|
||||||
f.Language.FillValue(r)
|
f.Language.FillValue(r)
|
||||||
|
if err := f.Avatar.FillValue(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *profileForm) Close() error {
|
||||||
|
return f.Avatar.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (f *profileForm) Valid(l *locale.Locale) bool {
|
func (f *profileForm) Valid(l *locale.Locale) bool {
|
||||||
v := form.NewValidator(l)
|
v := form.NewValidator(l)
|
||||||
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
|
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
|
||||||
|
@ -174,9 +242,29 @@ func (f *profileForm) Valid(l *locale.Locale) bool {
|
||||||
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
|
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
|
||||||
v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password."))
|
v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password."))
|
||||||
v.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid."))
|
v.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid."))
|
||||||
|
if f.HasAvatarFile() {
|
||||||
|
v.CheckImageFile(f.Avatar, l.GettextNoop("File must be a valid PNG or JPEG image."))
|
||||||
|
}
|
||||||
return v.AllOK
|
return v.AllOK
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
||||||
template.MustRender(w, r, user, "profile.gohtml", f)
|
template.MustRender(w, r, user, "profile.gohtml", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *profileForm) HasAvatarFile() bool {
|
||||||
|
return f.Avatar.HasData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *profileForm) SaveAvatarTo(avatarPath string) error {
|
||||||
|
if !f.HasAvatarFile() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
avatar, err := os.OpenFile(avatarPath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer avatar.Close()
|
||||||
|
_, err = io.Copy(avatar, f.Avatar)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
ID int
|
||||||
Email string
|
Email string
|
||||||
LoggedIn bool
|
LoggedIn bool
|
||||||
Role string
|
Role string
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Name string
|
||||||
|
MaxSize int64
|
||||||
|
Val string
|
||||||
|
Error error
|
||||||
|
ContentType string
|
||||||
|
file multipart.File
|
||||||
|
header *multipart.FileHeader
|
||||||
|
buffer *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) setError(err error) {
|
||||||
|
f.Error = err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) FillValue(r *http.Request) error {
|
||||||
|
var err error
|
||||||
|
f.file, f.header, err = r.FormFile(f.Name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, http.ErrMissingFile) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.buffer = bufio.NewReader(f.file)
|
||||||
|
sniff, _ := f.buffer.Peek(512)
|
||||||
|
f.ContentType = http.DetectContentType(sniff)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Close() error {
|
||||||
|
if !f.HasData() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) HasData() bool {
|
||||||
|
return f.file != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Read(p []byte) (int, error) {
|
||||||
|
return f.buffer.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) WriteTo(w io.Writer) (int64, error) {
|
||||||
|
return f.buffer.WriteTo(w)
|
||||||
|
}
|
|
@ -40,6 +40,10 @@ func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
|
||||||
return v.check(field, field.validOptionsSelected(), message)
|
return v.check(field, field.validOptionsSelected(), message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *Validator) CheckImageFile(field *File, message string) bool {
|
||||||
|
return v.check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
|
||||||
|
}
|
||||||
|
|
||||||
type field interface {
|
type field interface {
|
||||||
setError(error)
|
setError(error)
|
||||||
}
|
}
|
||||||
|
|
40
po/ca.po
40
po/ca.po
|
@ -8,7 +8,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: camper\n"
|
"Project-Id-Version: camper\n"
|
||||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||||
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
|
"POT-Creation-Date: 2023-07-28 20:01+0200\n"
|
||||||
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
|
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
|
||||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||||
"Language-Team: Catalan <ca@dodds.net>\n"
|
"Language-Team: Catalan <ca@dodds.net>\n"
|
||||||
|
@ -28,12 +28,12 @@ msgctxt "title"
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "Entrada"
|
msgstr "Entrada"
|
||||||
|
|
||||||
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
|
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "Correu-e"
|
msgstr "Correu-e"
|
||||||
|
|
||||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
|
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Contrasenya"
|
msgstr "Contrasenya"
|
||||||
|
@ -50,21 +50,31 @@ msgid "Profile"
|
||||||
msgstr "Perfil"
|
msgstr "Perfil"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:17
|
#: web/templates/profile.gohtml:17
|
||||||
|
msgctxt "inut"
|
||||||
|
msgid "Profile Image"
|
||||||
|
msgstr "Imatge del perfil"
|
||||||
|
|
||||||
|
#: web/templates/profile.gohtml:26
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Nom"
|
msgstr "Nom"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:45
|
#: web/templates/profile.gohtml:43
|
||||||
|
msgctxt "legend"
|
||||||
|
msgid "Change password"
|
||||||
|
msgstr "Canvi de contrasenya"
|
||||||
|
|
||||||
|
#: web/templates/profile.gohtml:55
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Password Confirmation"
|
msgid "Password Confirmation"
|
||||||
msgstr "Confirmació de la contrasenya"
|
msgstr "Confirmació de la contrasenya"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:55
|
#: web/templates/profile.gohtml:65
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Idioma"
|
msgstr "Idioma"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:65
|
#: web/templates/profile.gohtml:75
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
msgid "Save changes"
|
msgid "Save changes"
|
||||||
msgstr "Desa els canvis"
|
msgstr "Desa els canvis"
|
||||||
|
@ -83,11 +93,11 @@ msgctxt "action"
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
msgstr "Surt"
|
msgstr "Surt"
|
||||||
|
|
||||||
#: pkg/app/login.go:56 pkg/app/user.go:170
|
#: pkg/app/login.go:56 pkg/app/user.go:239
|
||||||
msgid "Email can not be empty."
|
msgid "Email can not be empty."
|
||||||
msgstr "No podeu deixar el correu en blanc."
|
msgstr "No podeu deixar el correu en blanc."
|
||||||
|
|
||||||
#: pkg/app/login.go:57 pkg/app/user.go:171
|
#: pkg/app/login.go:57 pkg/app/user.go:240
|
||||||
msgid "This email is not valid. It should be like name@domain.com."
|
msgid "This email is not valid. It should be like name@domain.com."
|
||||||
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
|
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
|
||||||
|
|
||||||
|
@ -99,23 +109,27 @@ msgstr "No podeu deixar la contrasenya en blanc."
|
||||||
msgid "Invalid user or password."
|
msgid "Invalid user or password."
|
||||||
msgstr "Nom d’usuari o contrasenya incorrectes."
|
msgstr "Nom d’usuari o contrasenya incorrectes."
|
||||||
|
|
||||||
#: pkg/app/user.go:135
|
#: pkg/app/user.go:190
|
||||||
msgctxt "language option"
|
msgctxt "language option"
|
||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr "Automàtic"
|
msgstr "Automàtic"
|
||||||
|
|
||||||
#: pkg/app/user.go:173
|
#: pkg/app/user.go:242
|
||||||
msgid "Name can not be empty."
|
msgid "Name can not be empty."
|
||||||
msgstr "No podeu deixar el nom en blanc."
|
msgstr "No podeu deixar el nom en blanc."
|
||||||
|
|
||||||
#: pkg/app/user.go:174
|
#: pkg/app/user.go:243
|
||||||
msgid "Confirmation does not match password."
|
msgid "Confirmation does not match password."
|
||||||
msgstr "La confirmació no es correspon amb la contrasenya."
|
msgstr "La confirmació no es correspon amb la contrasenya."
|
||||||
|
|
||||||
#: pkg/app/user.go:175
|
#: pkg/app/user.go:244
|
||||||
msgid "Selected language is not valid."
|
msgid "Selected language is not valid."
|
||||||
msgstr "L’idioma escollit no és vàlid."
|
msgstr "L’idioma escollit no és vàlid."
|
||||||
|
|
||||||
#: pkg/auth/user.go:39
|
#: pkg/app/user.go:246
|
||||||
|
msgid "File must be a valid PNG or JPEG image."
|
||||||
|
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
|
||||||
|
|
||||||
|
#: pkg/auth/user.go:40
|
||||||
msgid "Cross-site request forgery detected."
|
msgid "Cross-site request forgery detected."
|
||||||
msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats."
|
msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats."
|
||||||
|
|
40
po/es.po
40
po/es.po
|
@ -8,7 +8,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: camper\n"
|
"Project-Id-Version: camper\n"
|
||||||
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
|
||||||
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
|
"POT-Creation-Date: 2023-07-28 20:02+0200\n"
|
||||||
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
|
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
|
||||||
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
|
||||||
"Language-Team: Spanish <es@tp.org.es>\n"
|
"Language-Team: Spanish <es@tp.org.es>\n"
|
||||||
|
@ -28,12 +28,12 @@ msgctxt "title"
|
||||||
msgid "Login"
|
msgid "Login"
|
||||||
msgstr "Entrada"
|
msgstr "Entrada"
|
||||||
|
|
||||||
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
|
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "Correo-e"
|
msgstr "Correo-e"
|
||||||
|
|
||||||
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
|
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Contraseña"
|
msgstr "Contraseña"
|
||||||
|
@ -50,21 +50,31 @@ msgid "Profile"
|
||||||
msgstr "Perfil"
|
msgstr "Perfil"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:17
|
#: web/templates/profile.gohtml:17
|
||||||
|
msgctxt "inut"
|
||||||
|
msgid "Profile Image"
|
||||||
|
msgstr "Imagen del perfil"
|
||||||
|
|
||||||
|
#: web/templates/profile.gohtml:26
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Nombre"
|
msgstr "Nombre"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:45
|
#: web/templates/profile.gohtml:43
|
||||||
|
msgctxt "legend"
|
||||||
|
msgid "Change password"
|
||||||
|
msgstr "Cambio de contraseña"
|
||||||
|
|
||||||
|
#: web/templates/profile.gohtml:55
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Password Confirmation"
|
msgid "Password Confirmation"
|
||||||
msgstr "Confirmación de la contraseña"
|
msgstr "Confirmación de la contraseña"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:55
|
#: web/templates/profile.gohtml:65
|
||||||
msgctxt "input"
|
msgctxt "input"
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
msgstr "Idioma"
|
msgstr "Idioma"
|
||||||
|
|
||||||
#: web/templates/profile.gohtml:65
|
#: web/templates/profile.gohtml:75
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
msgid "Save changes"
|
msgid "Save changes"
|
||||||
msgstr "Guardar los cambios"
|
msgstr "Guardar los cambios"
|
||||||
|
@ -83,11 +93,11 @@ msgctxt "action"
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
msgstr "Salir"
|
msgstr "Salir"
|
||||||
|
|
||||||
#: pkg/app/login.go:56 pkg/app/user.go:170
|
#: pkg/app/login.go:56 pkg/app/user.go:239
|
||||||
msgid "Email can not be empty."
|
msgid "Email can not be empty."
|
||||||
msgstr "No podéis dejar el correo-e en blanco."
|
msgstr "No podéis dejar el correo-e en blanco."
|
||||||
|
|
||||||
#: pkg/app/login.go:57 pkg/app/user.go:171
|
#: pkg/app/login.go:57 pkg/app/user.go:240
|
||||||
msgid "This email is not valid. It should be like name@domain.com."
|
msgid "This email is not valid. It should be like name@domain.com."
|
||||||
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
|
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
|
||||||
|
|
||||||
|
@ -99,23 +109,27 @@ msgstr "No podéis dejar la contraseña en blanco."
|
||||||
msgid "Invalid user or password."
|
msgid "Invalid user or password."
|
||||||
msgstr "Usuario o contraseña incorrectos."
|
msgstr "Usuario o contraseña incorrectos."
|
||||||
|
|
||||||
#: pkg/app/user.go:135
|
#: pkg/app/user.go:190
|
||||||
msgctxt "language option"
|
msgctxt "language option"
|
||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr "Automático"
|
msgstr "Automático"
|
||||||
|
|
||||||
#: pkg/app/user.go:173
|
#: pkg/app/user.go:242
|
||||||
msgid "Name can not be empty."
|
msgid "Name can not be empty."
|
||||||
msgstr "No podéis dejar el nombre en blanco."
|
msgstr "No podéis dejar el nombre en blanco."
|
||||||
|
|
||||||
#: pkg/app/user.go:174
|
#: pkg/app/user.go:243
|
||||||
msgid "Confirmation does not match password."
|
msgid "Confirmation does not match password."
|
||||||
msgstr "La confirmación no se corresponde con la contraseña."
|
msgstr "La confirmación no se corresponde con la contraseña."
|
||||||
|
|
||||||
#: pkg/app/user.go:175
|
#: pkg/app/user.go:244
|
||||||
msgid "Selected language is not valid."
|
msgid "Selected language is not valid."
|
||||||
msgstr "El idioma escogido no es válido."
|
msgstr "El idioma escogido no es válido."
|
||||||
|
|
||||||
#: pkg/auth/user.go:39
|
#: pkg/app/user.go:246
|
||||||
|
msgid "File must be a valid PNG or JPEG image."
|
||||||
|
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
|
||||||
|
|
||||||
|
#: pkg/auth/user.go:40
|
||||||
msgid "Cross-site request forgery detected."
|
msgid "Cross-site request forgery detected."
|
||||||
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
|
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<nav>
|
<nav>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<img src="/static/default_avatar.svg" alt="{{( gettext "Avatar" )}}" width="70" height="70">
|
<img src="/me/avatar" alt="{{( gettext "Avatar" )}}" width="70" height="70">
|
||||||
<span>{{( pgettext "User Menu" "title" )}}</span>
|
<span>{{( pgettext "User Menu" "title" )}}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<ul role="menu">
|
<ul role="menu">
|
||||||
|
|
|
@ -8,10 +8,19 @@
|
||||||
|
|
||||||
{{ define "content" -}}
|
{{ define "content" -}}
|
||||||
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
|
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
|
||||||
<form data-hx-put="/me">
|
<form data-hx-put="/me" enctype="multipart/form-data">
|
||||||
<h2>{{( pgettext "Profile" "title" )}}</h2>
|
<h2>{{( pgettext "Profile" "title" )}}</h2>
|
||||||
{{ CSRFInput }}
|
{{ CSRFInput }}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
{{ with .Avatar -}}
|
||||||
|
<label>
|
||||||
|
{{( pgettext "Profile Image" "inut" )}}<br>
|
||||||
|
<input type="file" name="{{ .Name }}"
|
||||||
|
accept="image/png image/jpeg"
|
||||||
|
{{ template "error-attrs" . }}><br>
|
||||||
|
</label>
|
||||||
|
{{ template "error-message" . }}
|
||||||
|
{{- end }}
|
||||||
{{ with .Name -}}
|
{{ with .Name -}}
|
||||||
<label>
|
<label>
|
||||||
{{( pgettext "Name" "input" )}}<br>
|
{{( pgettext "Name" "input" )}}<br>
|
||||||
|
@ -31,6 +40,7 @@
|
||||||
{{ template "error-message" . }}
|
{{ template "error-message" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
<legend>{{( pgettext "Change password" "legend" )}}</legend>
|
||||||
{{ with .Password -}}
|
{{ with .Password -}}
|
||||||
<label>
|
<label>
|
||||||
{{( pgettext "Password" "input" )}}<br>
|
{{( pgettext "Password" "input" )}}<br>
|
||||||
|
|
Loading…
Reference in New Issue