diff --git a/errors.go b/errors.go index 235a67e..fa0f0fa 100644 --- a/errors.go +++ b/errors.go @@ -9,7 +9,9 @@ // Package main contains the high-level Amsterdam logic. package main -import "git.erbosoft.com/amy/amsterdam/ui" +import ( + "git.erbosoft.com/amy/amsterdam/ui" +) /* NotImplPage is used for all TODO links, to show that something hasn't yet been implemented. * Parameters: diff --git a/go.mod b/go.mod index 5ce402e..b807fbd 100644 --- a/go.mod +++ b/go.mod @@ -13,19 +13,19 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect - github.com/alexflint/go-arg v1.6.0 // indirect + github.com/alexflint/go-arg v1.6.0 github.com/alexflint/go-scalar v1.2.0 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/biter777/countries v1.7.5 + github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.4.0 // indirect + github.com/gorilla/sessions v1.4.0 github.com/hashicorp/golang-lru v1.0.2 - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/labstack/echo-contrib v0.17.4 // indirect + github.com/jmoiron/sqlx v1.4.0 + github.com/labstack/echo-contrib v0.17.4 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect + github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.38.0 // indirect diff --git a/go.sum b/go.sum index 1c7f520..ed05acd 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,16 @@ github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRS github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= +github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= @@ -22,8 +26,6 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= @@ -32,11 +34,13 @@ github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcX github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/login.go b/login.go index fb23845..cd4e384 100644 --- a/login.go +++ b/login.go @@ -40,3 +40,21 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) { ctxt.VarMap().Set("amsterdam_pageTitle", "New Account User Agreement") return "framed_template", "agreement.jet", nil } + +/* NewAccountUserAgreement renders the Amsterdam account creation form. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func NewAccountForm(ctxt ui.AmContext) (string, any, error) { + dlg, err := ui.AmLoadDialog("newaccount") + if err == nil { + dlg.Field("country").Value = "XX" + ctxt.VarMap().Set("amsterdam_pageTitle", "Create New Account") + return dlg.Render(ctxt) + } + return ui.ErrorPage(ctxt, err) +} diff --git a/main.go b/main.go index b2e2dd4..8ecc6ed 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func setupEcho() *echo.Echo { e.GET("/about", ui.AmWrap(AboutPage)) e.GET("/login", ui.AmWrap(LoginForm)) e.GET("/newacct", ui.AmWrap(NewAccountUserAgreement)) + e.GET("/newacct2", ui.AmWrap(NewAccountForm)) return e } diff --git a/ui/dialog.go b/ui/dialog.go index ea6baad..22d93fe 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -19,13 +19,15 @@ import ( // DialogItem holds the dialog item definition. type DialogItem struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - Caption string `yaml:"caption,omitempty"` - Size int `yaml:"size,omitempty"` - MaxLength int `yaml:"maxlength,omitempty"` - Value string `yaml:"value,omitempty"` - Tone string `yaml:"tone,omitempty"` + Type string `yaml:"type"` + Name string `yaml:"name"` + Caption string `yaml:"caption,omitempty"` + Subcaption string `yaml:"subcaption,omitempty"` + Required bool `yaml:"required,omitempty"` + Size int `yaml:"size,omitempty"` + MaxLength int `yaml:"maxlength,omitempty"` + Value string `yaml:"value,omitempty"` + Param string `yaml:"param,omitempty"` } // Dialog holds the dialog definition. @@ -52,15 +54,39 @@ func AmLoadDialog(name string) (*Dialog, error) { var d Dialog err = yaml.Unmarshal(b, &d) if err == nil { + // "nil-patch" certain fields if d.MenuSelector == "" { d.MenuSelector = "nochange" } + for _, fld := range d.Fields { + if fld.Type == "button" && fld.Param == "" { + fld.Param = "blue" + } + if fld.Type == "date" && fld.Param == "" { + fld.Param = "year:-100" + } + } return &d, nil } } return nil, err } +/* Field returns a pointer to a dialog's field, given its name. + * Parameters: + * name - The name of the field to find. + * Returns: + * Pointer to the field, or nil. + */ +func (d *Dialog) Field(name string) *DialogItem { + for i := 0; i < len(d.Fields); i++ { + if d.Fields[i].Name == name { + return &(d.Fields[i]) + } + } + return nil +} + /* Render sets up the rendering parameters to send this dialog to the output. * Parameters: * ctxt - The AmContext for this request. @@ -70,6 +96,14 @@ func AmLoadDialog(name string) (*Dialog, error) { * Standard Go error status. */ func (d *Dialog) Render(ctxt AmContext) (string, any, error) { + required := false + for _, fld := range d.Fields { + if fld.Required { + required = true + break + } + } + ctxt.VarMap().Set("amsterdam_required", required) ctxt.VarMap().Set("amsterdam_dialog", d) return "framed_template", "dialog.jet", nil } diff --git a/ui/dialogs/login.yaml b/ui/dialogs/login.yaml index aec6b24..29c99f8 100644 --- a/ui/dialogs/login.yaml +++ b/ui/dialogs/login.yaml @@ -18,7 +18,7 @@ fields: - type: "hidden" name: "tgt" value: "" - - type: "veniceid" + - type: "ams_id" name: "user" caption: "User Name" size: 32 @@ -34,12 +34,12 @@ fields: - type: "button" name: "login" caption: "Log In" - tone: "blue" + param: "blue" - type: "button" name: "remind" caption: "Password Reminder" - tone: "gray" + param: "gray" - type: "button" name: "cancel" caption: "Cancel" - tone: "red" + param: "red" diff --git a/ui/dialogs/newaccount.yaml b/ui/dialogs/newaccount.yaml new file mode 100644 index 0000000..7755fe1 --- /dev/null +++ b/ui/dialogs/newaccount.yaml @@ -0,0 +1,127 @@ +# +# Amsterdam Web Communities System +# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# +name: "newaccount" +formName: "createform" +menuSelector: "top" +title: "Create Account" +action: "/TODO/newacct2" +instructions: > + To create a new account, please enter your information below. +fields: + - type: "hidden" + name: "tgt" + value: "" + - type: "header" + name: "header1" + caption: "Name" + - type: "text" + name: "prefix" + caption: "Prefix" + subcaption: "(Mr., Ms., etc.)" + size: 8 + maxlength: 8 + - type: "text" + name: "first" + caption: "First Name" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "mid" + caption: "Middle Initial" + size: 1 + maxlength: 1 + - type: "text" + name: "last" + caption: "Last Name" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "suffix" + caption: "Suffix" + subcaption: "(Jr., III, etc.)" + size: 8 + maxlength: 8 + - type: "header" + name: "header2" + caption: "Location" + - type: "text" + name: "loc" + caption: "City" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "reg" + caption: "State/Province" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "pcode" + caption: "Zip/Postal Code" + required: true + size: 32 + maxlength: 64 + - type: "countrylist" + name: "country" + caption: "Country" + required: true + - type: "header" + name: "header3" + caption: "E-Mail" + - type: "email" + name: "email" + caption: "E-Mail Address" + required: true + size: 32 + maxlength: 255 + - type: "header" + name: "header4" + caption: "Other Information" + - type: "date" + name: "dob" + caption: "Date of Birth" + param: "year:-100" + - type: "header" + name: "header5" + caption: "Account Information" + - type: "ams_id" + name: "user" + caption: "User Name" + required: true + size: 32 + maxlength: 64 + - type: "password" + name: "pass1" + caption: "Password" + required: true + size: 32 + maxlength: 128 + - type: "password" + name: "pass2" + caption: "Password" + subcaption: "(retype)" + required: true + size: 32 + maxlength: 128 + - type: "text" + name: "remind" + caption: "Password reminder phrase" + size: 32 + maxlength: 255 + - type: "button" + name: "create" + caption: "Create" + param: "blue" + - type: "button" + name: "cancel" + caption: "Cancel" + param: "red" diff --git a/ui/templates.go b/ui/templates.go index 4233149..75153cf 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -12,12 +12,21 @@ package ui import ( "embed" + "fmt" "io" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" "git.erbosoft.com/amy/amsterdam/config" "github.com/CloudyKit/jet/v6" "github.com/CloudyKit/jet/v6/loaders/embedfs" "github.com/CloudyKit/jet/v6/loaders/multi" + "github.com/biter777/countries" "github.com/labstack/echo/v4" ) @@ -27,6 +36,96 @@ var static_views embed.FS // views is the main Jet template repository. var views *jet.Set +var cachedCountryList []countries.CountryCode = nil + +var countryListMutex sync.Mutex + +func internalGetCountryList() []countries.CountryCode { + countryListMutex.Lock() + defer countryListMutex.Unlock() + if cachedCountryList == nil { + c := countries.All() + slices.SortFunc(c, func(a countries.CountryCode, b countries.CountryCode) int { + return strings.Compare(a.Info().Name, b.Info().Name) + }) + cachedCountryList = c + } + return cachedCountryList +} + +// getCountryList is a wrapper around countries.All() to be added to the template context. +func getCountryList(a jet.Arguments) reflect.Value { + return reflect.ValueOf(internalGetCountryList()) +} + +// getMonthList is a simple wrapper that returns the names of the months to the template context. +func getMonthList(a jet.Arguments) reflect.Value { + rc := make([]string, 12) + for m := time.January; m <= time.December; m++ { + rc[m-time.January] = m.String() + } + return reflect.ValueOf(rc) +} + +// countRanger is a Ranger that can count from one value to another with a certain step. +type countRanger struct { + i int + val int64 + step int64 + to int64 +} + +/* Range (from Ranger) returns the "next" value of this iterator. + * Returns: + * Next index of the returned value + * Next returned value + * true if this is the last iteration, false if not + */ +func (r *countRanger) Range() (reflect.Value, reflect.Value, bool) { + r.i++ + r.val += r.step + var end bool + if r.step < 0 { + end = r.val <= r.to + } else { + end = r.val >= r.to + } + return reflect.ValueOf(r.i), reflect.ValueOf(r.val), end +} + +// ProvidesIndex (from Ranger) returns true to indicate that this Ranger has indexes. +func (r *countRanger) ProvidesIndex() bool { + return true +} + +// makeIntRange creates and returns a countRanger. +func makeIntRange(a jet.Arguments) reflect.Value { + from := a.Get(0).Convert(reflect.TypeFor[int64]()).Int() + to := a.Get(1).Convert(reflect.TypeFor[int64]()).Int() + step := a.Get(2).Convert(reflect.TypeFor[int64]()).Int() + rc := &countRanger{i: -1, val: from - step, step: step, to: to} + return reflect.ValueOf(rc).Convert(reflect.TypeFor[jet.Ranger]()) +} + +// makeYearRange parses a year parameter and creates a countRanger that reflects it. +func makeYearRange(a jet.Arguments) reflect.Value { + param := a.Get(0).Convert(reflect.TypeFor[string]()).String() + yearRegex, _ := regexp.Compile(`year:(\S+)(\s+.+)?$`) + m := yearRegex.FindStringSubmatch(param) + if m != nil { + count, err := strconv.Atoi(m[1]) + if err == nil { + start_year := time.Now().Year() + rc := &countRanger{i: -1, val: int64(start_year) + 1, step: -1, to: int64(start_year + count - 1)} + return reflect.ValueOf(rc).Convert(reflect.TypeFor[jet.Ranger]()) + } else { + return reflect.ValueOf(err) + } + } else { + return reflect.ValueOf(fmt.Errorf("cannot locate year: marker in param")) + } +} + // SetupTemplates is called to set up the template renderer after the configuration is loaded. func SetupTemplates() { views = jet.NewSet( @@ -39,6 +138,13 @@ func SetupTemplates() { views.AddGlobal("AmsterdamVersion", config.AMSTERDAM_VERSION) views.AddGlobal("AmsterdamCopyright", config.AMSTERDAM_COPYRIGHT) views.AddGlobal("GlobalConfig", config.GlobalConfig) + views.AddGlobalFunc("GetCountryList", getCountryList) + views.AddGlobalFunc("GetMonthList", getMonthList) + views.AddGlobalFunc("MakeIntRange", makeIntRange) + views.AddGlobalFunc("MakeYearRange", makeYearRange) + + // preload the country list in the background + go internalGetCountryList() } // TemplateRenderer is the Renderer instance set into the Echo context at creation time, to render Jet templates. @@ -55,6 +161,7 @@ type TemplateRenderer struct{} */ func (r *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { view, err := views.GetTemplate(name) + if err != nil { return err } diff --git a/ui/views/agreement.jet b/ui/views/agreement.jet index 7ce5eee..0e27808 100644 --- a/ui/views/agreement.jet +++ b/ui/views/agreement.jet @@ -16,7 +16,7 @@

+ onclick="window.location.assign('/newacct2')">I Accept
diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet index a6f3490..ce099cd 100644 --- a/ui/views/dialog.jet +++ b/ui/views/dialog.jet @@ -21,13 +21,19 @@ {{ if amsterdam_dialog.Instructions != "" }}

{{ amsterdam_dialog.Instructions | raw }}

{{ end }} + {{ if amsterdam_required }} +

Required fields are marked with a *.

+ {{ end }}
{{ range amsterdam_dialog.Fields }} - {{ if .Type == "veniceid" }} + {{ if .Type == "text" || .Type == "ams_id" || .Type == "email" }}
- + 0 }}size="{{ .Size }}"{{ end }} {{ if .MaxLength > 0 }}maxlength="{{ .MaxLength }}"{{ end }} @@ -36,7 +42,10 @@
{{ else if .Type == "password" }}
- + 0 }}size="{{ .Size }}"{{ end }} {{ if .MaxLength > 0 }}maxlength="{{ .MaxLength }}"{{ end }} @@ -49,15 +58,66 @@
- +
+ {{ else if .Type == "countrylist" }} +
+ + {{ v := .Value }} + +
+ {{ else if .Type == "date" }} +
+ +
+ + + +
+
+ {{ else if .Type == "header" }} +

{{ .Caption }}

+ {{ else if .Type != "hidden" && .Type != "button" }} + BARF! I don't understand {{ .Type }} (field {{ .Name }}) {{ end }} {{ end }}
{{ range amsterdam_dialog.Fields }} {{ if .Type == "button" }} - {{ clstmp := "bg-" + .Tone + "-600 hover:bg-" + .Tone + "-700" }} + {{ clstmp := "bg-" + .Param + "-600 hover:bg-" + .Param + "-700" }} {{ end }}