Add profile form, inside a user menu

This is the first form that uses HTMx, and can not return a 400 error
code because otherwise HTMx does not render the content.

Since now there are pages that do not render the whole html, with header
and body, i need a different layout for these, and moved the common code
to handle forms and such a new template file that both layouts can use.
I also need the request in template.MustRender to check which layout i
should use.

Closes #7.
This commit is contained in:
jordi fita mas 2023-07-26 20:46:09 +02:00
parent 341520def3
commit f963f54839
16 changed files with 468 additions and 35 deletions

View File

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

View File

@ -112,6 +112,6 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
func (h *App) serveDashboard(w http.ResponseWriter, _ *http.Request, user *auth.User) { func (h *App) serveDashboard(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, user, "dashboard.gohtml", nil) template.MustRender(w, r, user, "dashboard.gohtml", nil)
} }

View File

@ -60,10 +60,14 @@ func (f *loginForm) Valid(l *locale.Locale) bool {
return v.AllOK return v.AllOK
} }
func serveLoginForm(w http.ResponseWriter, _ *http.Request, user *auth.User, redirectPath string) { func (f *loginForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "login.gohtml", f)
}
func serveLoginForm(w http.ResponseWriter, r *http.Request, user *auth.User, redirectPath string) {
login := newLoginForm() login := newLoginForm()
login.Redirect.Val = redirectPath login.Redirect.Val = redirectPath
template.MustRender(w, user, "login.gohtml", login) login.MustRender(w, r, user)
} }
func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
@ -84,7 +88,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, user *auth.User, conn *
} else { } else {
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
} }
template.MustRender(w, user, "login.gohtml", login) login.MustRender(w, r, user)
} }
func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) { func handleLogout(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {

View File

@ -1,13 +1,22 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package app package app
import ( import (
"context"
"net/http" "net/http"
"golang.org/x/text/language" "golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/database"
"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/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
) )
func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error) { func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error) {
@ -61,8 +70,113 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
default: default:
methodNotAllowed(w, r, http.MethodDelete) methodNotAllowed(w, r, http.MethodDelete)
} }
case "":
switch r.Method {
case http.MethodGet:
serveProfileForm(w, r, user, conn)
case http.MethodPut:
updateProfile(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
} }
} }
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn)
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
profile.Email.Val = user.Email
profile.Language.Selected = []string{user.Language.String()}
profile.MustRender(w, r, user)
}
func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn)
if err := profile.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 !profile.Valid(user.Locale) {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
profile.MustRender(w, r, user)
return
}
//goland:noinspection SqlWithoutWhere
cookie := conn.MustGetText(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", profile.Name, profile.Email, profile.Language)
auth.SetSessionCookie(w, cookie)
if profile.Password.Val != "" {
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
}
if user.Language.String() == profile.Language.String() {
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
} else {
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
}
}
type profileForm struct {
Name *form.Input
Email *form.Input
Password *form.Input
PasswordConfirm *form.Input
Language *form.Select
}
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
automaticOption := l.Pgettext("Automatic", "language option")
languages := form.MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
return &profileForm{
Name: &form.Input{
Name: "name",
},
Email: &form.Input{
Name: "email",
},
Password: &form.Input{
Name: "password",
},
PasswordConfirm: &form.Input{
Name: "password_confirm",
},
Language: &form.Select{
Name: "language",
Options: languages,
},
}
}
func (f *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Email.FillValue(r)
f.Name.FillValue(r)
f.Password.FillValue(r)
f.PasswordConfirm.FillValue(r)
f.Language.FillValue(r)
return nil
}
func (f *profileForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
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.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid."))
return v.AllOK
}
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "profile.gohtml", f)
}

View File

@ -17,6 +17,10 @@ type Input struct {
Error error Error error
} }
func (input *Input) setError(err error) {
input.Error = err
}
func (input *Input) FillValue(r *http.Request) { func (input *Input) FillValue(r *http.Request) {
input.Val = strings.TrimSpace(r.FormValue(input.Name)) input.Val = strings.TrimSpace(r.FormValue(input.Name))
} }

95
pkg/form/select.go Normal file
View File

@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package form
import (
"context"
"database/sql/driver"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/database"
)
type Select struct {
Name string
Selected []string
Options []*Option
Error error
}
func (s *Select) setError(err error) {
s.Error = err
}
func (s *Select) Value() (driver.Value, error) {
return s.String(), nil
}
func (s *Select) String() string {
if s.Selected == nil {
return ""
}
return s.Selected[0]
}
func (s *Select) FillValue(r *http.Request) {
s.Selected = r.Form[s.Name]
}
func (s *Select) validOptionsSelected() bool {
for _, selected := range s.Selected {
if !s.isValidOption(selected) {
return false
}
}
return true
}
func (s *Select) isValidOption(selected string) bool {
for _, option := range s.Options {
if option.Value == selected {
return true
}
}
return false
}
func (s *Select) IsSelected(v string) bool {
for _, selected := range s.Selected {
if selected == v {
return true
}
}
return false
}
type Option struct {
Value string
Label string
}
func MustGetOptions(ctx context.Context, conn *database.Conn, sql string, args ...interface{}) []*Option {
rows, err := conn.Query(ctx, sql, args...)
if err != nil {
panic(err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
option := &Option{}
err = rows.Scan(&option.Value, &option.Label)
if err != nil {
panic(err)
}
options = append(options, option)
}
if rows.Err() != nil {
panic(rows.Err())
}
return options
}

View File

@ -32,9 +32,21 @@ func (v *Validator) CheckValidEmail(input *Input, message string) bool {
return v.check(input, err == nil, message) return v.check(input, err == nil, message)
} }
func (v *Validator) check(field *Input, ok bool, message string) bool { func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool {
return v.check(confirm, password.Val == confirm.Val, message)
}
func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
return v.check(field, field.validOptionsSelected(), message)
}
type field interface {
setError(error)
}
func (v *Validator) check(field field, ok bool, message string) bool {
if !ok { if !ok {
field.Error = errors.New(v.l.Get(message)) field.setError(errors.New(v.l.Get(message)))
v.AllOK = false v.AllOK = false
} }
return ok return ok

View File

@ -54,6 +54,10 @@ func (l *Locale) Gettext(str string) string {
return l.GetD(l.GetDomain(), str) return l.GetD(l.GetDomain(), str)
} }
func (l *Locale) Pgettext(str string, ctx string) string {
return l.GetDC(l.GetDomain(), str, ctx)
}
func (l *Locale) GettextNoop(str string) string { func (l *Locale) GettextNoop(str string) string {
return str return str
} }

View File

@ -12,14 +12,18 @@ import (
"net/http" "net/http"
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
) )
func templateFile(name string) string { func templateFile(name string) string {
return "web/templates/" + name return "web/templates/" + name
} }
func MustRender(w io.Writer, user *auth.User, filename string, data interface{}) { func MustRender(w io.Writer, r *http.Request, user *auth.User, filename string, data interface{}) {
layout := "layout.gohtml" layout := "layout.gohtml"
if httplib.IsHTMxRequest(r) {
layout = "htmx.gohtml"
}
mustRenderLayout(w, user, layout, filename, data) mustRenderLayout(w, user, layout, filename, data)
} }
@ -41,7 +45,7 @@ func mustRenderLayout(w io.Writer, user *auth.User, layout string, filename stri
return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, auth.CSRFTokenField, user.CSRFToken)) return template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`, auth.CSRFTokenField, user.CSRFToken))
}, },
}) })
if _, err := t.ParseFiles(templateFile(layout), templateFile(filename)); err != nil { if _, err := t.ParseFiles(templateFile(layout), templateFile("form.gohtml"), templateFile(filename)); err != nil {
panic(err) panic(err)
} }
if rw, ok := w.(http.ResponseWriter); ok { if rw, ok := w.(http.ResponseWriter); ok {

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 13:28+0200\n" "POT-Creation-Date: 2023-07-26 20:36+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/login.gohtml:22 web/templates/profile.gohtml:26
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu-e" msgstr "Correu-e"
#: web/templates/login.gohtml:31 #: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
@ -43,20 +43,51 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entra" msgstr "Entra"
#: web/templates/layout.gohtml:16 #: web/templates/profile.gohtml:6 web/templates/profile.gohtml:12
#: web/templates/layout.gohtml:28
msgctxt "title"
msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "input"
msgid "Name"
msgstr "Nom"
#: web/templates/profile.gohtml:45
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació de la contrasenya"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
msgctxt "action"
msgid "Save changes"
msgstr "Desa els canvis"
#: web/templates/layout.gohtml:17
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Salta al contingut principal" msgstr "Salta al contingut principal"
#: web/templates/layout.gohtml:20 #: web/templates/layout.gohtml:24
msgctxt "title"
msgid "User Menu"
msgstr "Menú dusuari"
#: web/templates/layout.gohtml:32
msgctxt "action" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" msgstr "Surt"
#: pkg/app/login.go:56 #: pkg/app/login.go:56 pkg/app/user.go:170
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/login.go:57 pkg/app/user.go:171
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."
@ -64,10 +95,27 @@ msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
msgid "Password can not be empty." msgid "Password can not be empty."
msgstr "No podeu deixar la contrasenya en blanc." msgstr "No podeu deixar la contrasenya en blanc."
#: pkg/app/login.go:82 #: pkg/app/login.go:86
msgid "Invalid user or password." msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes." msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/app/user.go:135
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/app/user.go:173
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/app/user.go:174
msgid "Confirmation does not match password."
msgstr "La confirmació no es correspon amb la contrasenya."
#: pkg/app/user.go:175
msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid."
#: pkg/auth/user.go:39 #: pkg/auth/user.go:39
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats." msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 13:28+0200\n" "POT-Creation-Date: 2023-07-26 20:36+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/login.gohtml:22 web/templates/profile.gohtml:26
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo-e" msgstr "Correo-e"
#: web/templates/login.gohtml:31 #: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
msgctxt "input" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
@ -43,20 +43,51 @@ msgctxt "action"
msgid "Login" msgid "Login"
msgstr "Entrar" msgstr "Entrar"
#: web/templates/layout.gohtml:16 #: web/templates/profile.gohtml:6 web/templates/profile.gohtml:12
#: web/templates/layout.gohtml:28
msgctxt "title"
msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: web/templates/profile.gohtml:45
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación de la contraseña"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
msgctxt "action"
msgid "Save changes"
msgstr "Guardar los cambios"
#: web/templates/layout.gohtml:17
msgid "Skip to main content" msgid "Skip to main content"
msgstr "Saltar al contenido principal" msgstr "Saltar al contenido principal"
#: web/templates/layout.gohtml:20 #: web/templates/layout.gohtml:24
msgctxt "title"
msgid "User Menu"
msgstr "Menú de usuario"
#: web/templates/layout.gohtml:32
msgctxt "action" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: pkg/app/login.go:56 #: pkg/app/login.go:56 pkg/app/user.go:170
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/login.go:57 pkg/app/user.go:171
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."
@ -64,10 +95,27 @@ msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com
msgid "Password can not be empty." msgid "Password can not be empty."
msgstr "No podéis dejar la contraseña en blanco." msgstr "No podéis dejar la contraseña en blanco."
#: pkg/app/login.go:82 #: pkg/app/login.go:86
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
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/app/user.go:173
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/app/user.go:174
msgid "Confirmation does not match password."
msgstr "La confirmación no se corresponde con la contraseña."
#: pkg/app/user.go:175
msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido."
#: pkg/auth/user.go:39 #: pkg/auth/user.go:39
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."

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.0009 17C15.6633 17 18.8659 18.5751 20.608 20.9247L18.766 21.796C17.3482 20.1157 14.8483 19 12.0009 19C9.15346 19 6.6535 20.1157 5.23577 21.796L3.39453 20.9238C5.13673 18.5747 8.33894 17 12.0009 17ZM12.0009 2C14.7623 2 17.0009 4.23858 17.0009 7V10C17.0009 12.6888 14.8786 14.8818 12.2178 14.9954L12.0009 15C9.23945 15 7.00087 12.7614 7.00087 10V7C7.00087 4.31125 9.12318 2.11818 11.784 2.00462L12.0009 2ZM12.0009 4C10.4032 4 9.09721 5.24892 9.00596 6.82373L9.00087 7V10C9.00087 11.6569 10.344 13 12.0009 13C13.5986 13 14.9045 11.7511 14.9958 10.1763L15.0009 10V7C15.0009 5.34315 13.6577 4 12.0009 4Z"/></svg>

After

Width:  |  Height:  |  Size: 681 B

17
web/templates/form.gohtml Normal file
View File

@ -0,0 +1,17 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }}
{{ define "error-message" -}}
{{ if .Error -}}
<span id="{{ .Name }}-error" class="error">{{ .Error }}</span><br>
{{- end }}
{{- end }}
{{ define "list-options" -}}
{{- range .Options }}
<option value="{{ .Value }}" {{ if $.IsSelected .Value }} selected="selected"{{ end }}>{{ .Label }}</option>
{{- end }}
{{- end }}

View File

@ -0,0 +1,6 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
<title>{{ template "title" . }} — Camper</title>
{{ template "content" . }}

View File

@ -17,8 +17,23 @@
<a href="#main">{{( gettext "Skip to main content" )}}</a> <a href="#main">{{( gettext "Skip to main content" )}}</a>
<h1>camper _ws</h1> <h1>camper _ws</h1>
{{ if isLoggedIn -}} {{ if isLoggedIn -}}
<button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }' <nav>
>{{( pgettext "Logout" "action" )}}</button> <details>
<summary>
<img src="/static/default_avatar.svg" alt="{{( gettext "Avatar" )}}" width="70" height="70">
<span>{{( pgettext "User Menu" "title" )}}</span>
</summary>
<ul role="menu">
<li role="presentation" class="icon_profile">
<a role="menuitem" href="/me">{{( pgettext "Profile" "title" )}}</a>
</li>
<li role="presentation" class="icon_logout">
<button role="menuitem" data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
>{{( pgettext "Logout" "action" )}}</button>
</li>
</ul>
</details>
</nav>
{{- end }} {{- end }}
</header> </header>
<main id="main"> <main id="main">
@ -26,10 +41,3 @@
</main> </main>
</body> </body>
</html> </html>
{{ define "error-attrs" }}{{ if .Error }}aria-invalid="true" aria-errormessage="{{ .Name }}-error"{{ end }}{{ end }}
{{ define "error-message" -}}
{{ if .Error -}}
<span id="{{ .Name }}-error" class="error">{{ .Error }}</span><br>
{{- end }}
{{- end }}

View File

@ -0,0 +1,68 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Profile" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
<form data-hx-put="/me">
<h2>{{( pgettext "Profile" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Name -}}
<label>
{{( pgettext "Name" "input" )}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="name" required
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Email -}}
<label>
{{( pgettext "Email" "input" )}}<br>
<input type="email" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="username" required
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
<fieldset>
{{ with .Password -}}
<label>
{{( pgettext "Password" "input" )}}<br>
<input type="password" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="new-password"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .PasswordConfirm -}}
<label>
{{( pgettext "Password Confirmation" "input" )}}<br>
<input type="password" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="new-password"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
{{ with .Language -}}
<label>
{{( pgettext "Language" "input" )}}<br>
<select name="{{ .Name }}"
required
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</footer>
</form>
{{- end }}