├── .gitignore ├── LICENSE ├── README.md ├── aigen ├── image.go └── prompt.go ├── assets ├── css │ └── tail.components.css ├── images │ ├── grayarrow.gif │ ├── grayarrow2x.gif │ └── s.gif ├── javascript │ ├── append.js │ ├── main.js │ ├── models.js │ └── wasm_exec.js └── other │ ├── favicon.ico │ └── robots.txt ├── bin ├── homeducky │ ├── .gitignore │ ├── app │ │ ├── core.go │ │ ├── feedback.json │ │ └── welcome.go │ ├── assets │ │ ├── css │ │ │ └── tail.components.css │ │ └── javascript │ │ │ └── wasm_exec.js │ ├── browser │ │ └── register.go │ ├── go.mod │ ├── main.go │ ├── markup │ │ ├── 404.mu │ │ ├── application_layout.mu │ │ ├── login.mu │ │ ├── navbar.mu │ │ ├── register.mu │ │ ├── start.mu │ │ └── welcome.mu │ ├── run │ ├── tailwind.config.js │ └── wasm │ │ └── main.go └── main.py ├── buckets └── cloud.go ├── common └── templates.go ├── feedback.json ├── files ├── home.go ├── read.go └── write.go ├── filestorage ├── README.md └── bucket.go ├── glamor └── run.py ├── go.mod ├── gogen ├── files.go └── routes.go ├── htmlgen └── pretty_print.go ├── location └── zip.go ├── main.go ├── markup ├── 404.mu ├── application_layout.mu ├── color.go ├── html.go ├── html_test.go ├── navbar.mu ├── random.go ├── tag.go └── welcome.mu ├── models ├── base.go ├── curl.go ├── field.go ├── find.go ├── regex.go ├── routes.go └── structure.go ├── network ├── get_to.go ├── http.go └── limit.go ├── openapi ├── endpoint.go ├── params.go ├── path.go ├── scan.go └── yaml.go ├── persist ├── scan.go ├── schema.go └── sqlite.go ├── prefix └── table.go ├── router ├── about_controller.go ├── admin_controller.go ├── ajax.go ├── api_controller.go ├── app.go ├── assets.go ├── before_all.go ├── bucket.go ├── cells.go ├── context.go ├── context_copy.go ├── context_database.go ├── context_database_count.go ├── context_database_model.go ├── context_decorate.go ├── context_decorate_test.go ├── context_delete.go ├── context_free_form.go ├── context_free_form_index.go ├── context_json.go ├── context_model.go ├── context_one_with_index.go ├── context_params.go ├── context_template.go ├── context_timezone.go ├── context_where_in.go ├── context_with_index.go ├── cookies.go ├── cors.go ├── editable_cols.go ├── feedback_site.go ├── fields_controller.go ├── filelog.go ├── forms.go ├── functions_to_run.go ├── google_controller.go ├── json.go ├── markup.go ├── model_user.go ├── models_controller.go ├── new.go ├── parse.go ├── path.go ├── redirect.go ├── regex.go ├── reset.go ├── search.go ├── server.go ├── sesssions_controller.go ├── stats_controller.go ├── tables.go ├── templates.go ├── upsert.go ├── users_controller.go ├── validate.go ├── viewport.go ├── wasm.go ├── welcome_controller.go ├── wrangle.go └── zippy.go ├── run ├── sqlgen ├── geometry.go ├── mysql.go ├── pg.go ├── rows.go └── sqlite.go ├── stats └── memory.go ├── tailwind.config.js ├── tailwind ├── README.md ├── example.css ├── extra.html └── tailwind.config.js ├── tool ├── controller.go ├── editable.go ├── main.go ├── mod_template.go ├── table.go ├── template_cols.go ├── template_controller.go ├── template_create.go ├── template_ignore.go ├── template_list_top.go ├── template_main.go ├── template_run.go ├── template_show.go ├── template_show_cols.go └── template_top.go ├── util ├── args.go ├── array.go ├── domain.go ├── guid_filename.go ├── header.go ├── javascript.go ├── json.go ├── number.go ├── plural.go ├── snake.go ├── strip.go ├── template.go └── uuid.go ├── views ├── 404.html ├── _editable_fields.html ├── _models.html ├── _nav_user.html ├── _table_large.html ├── _table_large_append.html ├── _table_small.html ├── _table_small_append.html ├── _welcome_show_cols.html ├── about_index.html ├── admin_index.html ├── admin_layout.html ├── admin_users_index.html ├── application_layout.html ├── fields_show.html ├── generic_top_bottom.html ├── models_edit.html ├── models_index.html ├── models_layout.html ├── models_list.html ├── models_show.html ├── sessions_new.html ├── stats_index.html ├── table_show.html ├── tailwind_index.html ├── users_show.html └── welcome_index.html └── wasm ├── auto_click.go ├── auto_form.go ├── class.go ├── data.go ├── document.go ├── form.go ├── func.go ├── global.go ├── http.go ├── http_bearer.go ├── keys.go ├── location.go ├── logout.go ├── render.go ├── scroll.go ├── stack.go ├── toast.go └── wrapper.go /.gitignore: -------------------------------------------------------------------------------- 1 | go.sum 2 | __pycache__ 3 | node_modules 4 | package-lock.json 5 | package.json 6 | assets/css/tail.min.css 7 | .DS_Store 8 | feedback 9 | tool/tool 10 | glamor/glamor_layout.html 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Andrew Arrow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Feedback 2 | 3 | [![screenshot](https://i.imgur.com/8s6SKK9.png)](https://andrewarrow.dev/) 4 | 5 | [andrewarrow.dev](https://andrewarrow.dev/) 6 | 7 | 8 | ``` 9 | ALTER TABLE users ALTER COLUMN id SET DATA TYPE bigint; 10 | ALTER SEQUENCE users_id_seq AS bigint; 11 | ``` 12 | 13 | -------------------------------------------------------------------------------- /aigen/image.go: -------------------------------------------------------------------------------- 1 | package aigen 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/andrewarrow/feedback/network" 9 | ) 10 | 11 | func RunImage(prompt string) { 12 | 13 | m := map[string]any{"prompt": "work from home", 14 | "n": 1, 15 | "size": "256x256"} 16 | asBytes, _ := json.Marshal(m) 17 | 18 | key := os.Getenv("OPEN_AI") 19 | fmt.Println(key) 20 | 21 | jsonString, _ := network.DoPost(nil, os.Getenv("OPEN_AI"), "/v1/images/generations", asBytes) 22 | fmt.Println(jsonString) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /aigen/prompt.go: -------------------------------------------------------------------------------- 1 | package aigen 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/andrewarrow/feedback/network" 8 | ) 9 | 10 | func RunPrompt(prompt string) { 11 | 12 | //prompt := "User: Hi\nAI: Hello, how can I help you today?\nUser: Can you tell me more about your product?\nAI: Sure, our product is a software tool that helps businesses streamline their operations.\nUser: What are the features of your product?" 13 | 14 | messages := MakeMessages(`you are an issue tracking system that uses terms like Project, Epic, Story, Task, and Bug. When I ask you to do something to the system in english, respond with json like {"steps": ["insert_issue": ["title", "bug"], "assign_issue": "username", "update_issue": "title=x"]}. Be sure to include N number of steps. If the user is asking to do multiple things, don't assume they can be done all in one step. For example the user asks to assign an issue to a user, make a seperate step to create that user. Don't include any sql in your response, just nice json that explains what sql will need to be crafted. make a new issue with title bug with encoding video and assign it to mark then add it to the current epic.`) 15 | m := map[string]any{"model": "gpt-3.5-turbo", 16 | "messages": messages} 17 | asBytes, _ := json.Marshal(m) 18 | 19 | jsonString, _ := network.DoPost(nil, "", "/v1/chat/completions", asBytes) 20 | fmt.Println(jsonString) 21 | 22 | } 23 | 24 | func MakeMessages(content string) []map[string]string { 25 | messages := []map[string]string{} 26 | message := map[string]string{"role": "user", "content": content} 27 | messages = append(messages, message) 28 | return messages 29 | } 30 | -------------------------------------------------------------------------------- /assets/css/tail.components.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .td1 { 7 | @apply border-t-0 px-6 align-middle border-l-0 border-r-0 text-xs whitespace-nowrap p-4; 8 | } 9 | .btn-main { 10 | @apply bg-blue-600 hover:bg-blue-700 px-5 py-3 text-white rounded-lg; 11 | } 12 | .btn-red { 13 | @apply bg-red-600 hover:bg-red-700 px-5 py-3 text-white rounded-lg; 14 | } 15 | .btn-small { 16 | @apply bg-red-600 hover:bg-red-700 px-2 py-2 text-white text-xs rounded-lg; 17 | } 18 | .btn-sm2 { 19 | @apply bg-blue-600 hover:bg-blue-700 px-2 py-2 text-white text-xs rounded-lg; 20 | } 21 | .th1 { 22 | @apply px-6 align-middle border border-solid py-3 text-xs uppercase border-l-0 border-r-0 whitespace-nowrap font-semibold text-left; 23 | } 24 | .bli { 25 | @apply p-4 items-center mx-3 inline-block 26 | } 27 | .bhb { 28 | @apply text-xs mb-1 font-bold 29 | } 30 | .bh2 { 31 | @apply text-xs mb-1 break-all 32 | } 33 | .bh3 { 34 | @apply text-xs mb-1 35 | } 36 | 37 | .nice { 38 | @apply block text-sm text-gray-500 39 | } 40 | 41 | .nice-ta { 42 | @apply border border-gray-300 px-4 py-2 rounded-md 43 | } 44 | .nice-i { 45 | @apply border border-gray-300 px-4 py-2 rounded-md 46 | } 47 | .w-800px { 48 | width: 800px; 49 | } 50 | .allunder a { 51 | text-decoration: underline; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /assets/images/grayarrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewarrow/feedback/ce6b2e30c6e4479926ce909d7f8d25025ca73956/assets/images/grayarrow.gif -------------------------------------------------------------------------------- /assets/images/grayarrow2x.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewarrow/feedback/ce6b2e30c6e4479926ce909d7f8d25025ca73956/assets/images/grayarrow2x.gif -------------------------------------------------------------------------------- /assets/images/s.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewarrow/feedback/ce6b2e30c6e4479926ce909d7f8d25025ca73956/assets/images/s.gif -------------------------------------------------------------------------------- /assets/javascript/append.js: -------------------------------------------------------------------------------- 1 | 2 | function sendFormAsJsonAndAppend(e) { 3 | e.preventDefault(); 4 | const route = e.target.action; 5 | const form = e.target; 6 | const formData = new FormData(form); 7 | 8 | var queryString = "?"; 9 | formData.forEach(function(value, key) { 10 | queryString += key + "=" + value + "&"; 11 | }); 12 | 13 | queryString = queryString.slice(0, -1); 14 | 15 | const xhr = new XMLHttpRequest(); 16 | xhr.open('POST', route); 17 | xhr.responseType = 'json'; 18 | xhr.setRequestHeader('Content-Type', 'application/json'); 19 | xhr.setRequestHeader('Feedback-Ajax', 'true'); 20 | xhr.addEventListener('load', function(event) { 21 | if (xhr.status != 200) { 22 | document.getElementById('flash').innerHTML = event.target.response; 23 | } else { 24 | const jsonResponse = xhr.response; 25 | 26 | var newTrElement = document.createElement('tr'); 27 | var newTableElement = document.createElement('tbody'); 28 | newTrElement.innerHTML = jsonResponse.table_large; 29 | newTableElement.innerHTML = jsonResponse.table_small; 30 | 31 | document.getElementById(jsonResponse.table_large_div).prepend(newTrElement); 32 | document.getElementById(jsonResponse.table_small_div).prepend(newTableElement); 33 | 34 | document.getElementById('flash').innerHTML = ''; 35 | setAllLinks(); 36 | } 37 | }); 38 | 39 | const formDataJson = {}; 40 | formData.forEach(function(value, key) { 41 | formDataJson[key] = value; 42 | }); 43 | 44 | const jsonData = JSON.stringify(formDataJson); 45 | xhr.send(jsonData); 46 | 47 | } 48 | -------------------------------------------------------------------------------- /assets/other/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewarrow/feedback/ce6b2e30c6e4479926ce909d7f8d25025ca73956/assets/other/favicon.ico -------------------------------------------------------------------------------- /assets/other/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | -------------------------------------------------------------------------------- /bin/homeducky/.gitignore: -------------------------------------------------------------------------------- 1 | go.sum 2 | {{homeducky}} 3 | node_modules 4 | package*.json 5 | json.wasm.gz 6 | .DS_Store 7 | views 8 | tail.min.css 9 | 10 | -------------------------------------------------------------------------------- /bin/homeducky/app/core.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/andrewarrow/feedback/router" 5 | ) 6 | 7 | func Core(c *router.Context, second, third string) { 8 | if second == "start" && third == "" && c.Method == "GET" { 9 | handleStart(c) 10 | return 11 | } 12 | if second == "about-us" && third == "" && c.Method == "GET" { 13 | handleAboutUs(c) 14 | return 15 | } 16 | if second == "privacy" && third == "" && c.Method == "GET" { 17 | handlePrivacy(c) 18 | return 19 | } 20 | if second == "terms" && third == "" && c.Method == "GET" { 21 | handleTerms(c) 22 | return 23 | } 24 | if second == "register" && third == "" && c.Method == "GET" { 25 | handleRegister(c) 26 | return 27 | } 28 | if second == "login" && third == "" && c.Method == "GET" { 29 | handleLogin(c) 30 | return 31 | } 32 | if second == "register" && third == "" && c.Method == "POST" { 33 | router.HandleCreateUserAutoForm(c, "") 34 | return 35 | } 36 | if second == "login" && third == "" && c.Method == "POST" { 37 | router.HandleCreateSessionAutoForm(c) 38 | return 39 | } 40 | if second == "logout" && third == "" && c.Method == "DELETE" { 41 | router.DestroySession(c) 42 | return 43 | } 44 | c.NotFound = true 45 | } 46 | 47 | func handleIndex(c *router.Context) { 48 | send := map[string]any{} 49 | c.SendContentInLayout("welcome.html", send, 200) 50 | } 51 | 52 | func handleRegister(c *router.Context) { 53 | send := map[string]any{} 54 | c.SendContentInLayout("register.html", send, 200) 55 | } 56 | func handleLogin(c *router.Context) { 57 | send := map[string]any{} 58 | c.SendContentInLayout("login.html", send, 200) 59 | } 60 | 61 | func handlePrivacy(c *router.Context) { 62 | send := map[string]any{} 63 | c.SendContentInLayout("privacy.html", send, 200) 64 | } 65 | func handleTerms(c *router.Context) { 66 | send := map[string]any{} 67 | c.SendContentInLayout("terms.html", send, 200) 68 | } 69 | func handleAboutUs(c *router.Context) { 70 | send := map[string]any{} 71 | c.SendContentInLayout("about_us.html", send, 200) 72 | } 73 | func handleStart(c *router.Context) { 74 | send := map[string]any{} 75 | c.SendContentInLayout("start.html", send, 200) 76 | } 77 | -------------------------------------------------------------------------------- /bin/homeducky/app/feedback.json: -------------------------------------------------------------------------------- 1 | { 2 | "footer": "github.com/andrewarrow/feedback", 3 | "title": "{{homeducky}}", 4 | "routes": [{"root": "sessions", "paths": [ 5 | {"verb": "GET", "second": "", "third": ""}, 6 | {"verb": "GET", "second": "*", "third": ""}, 7 | {"verb": "POST", "second": "", "third": ""} 8 | ]}, 9 | {"root": "users", "paths": [ 10 | {"verb": "GET", "second": "", "third": ""}, 11 | {"verb": "GET", "second": "*", "third": ""}, 12 | {"verb": "GET", "second": "thing", "third": "*"}, 13 | {"verb": "POST", "second": "", "third": ""} 14 | ]} 15 | ], 16 | "models": [ 17 | { 18 | "name": "user", 19 | "fields": [ 20 | { 21 | "name": "username", 22 | "flavor": "username", 23 | "index": "unique", 24 | "regex": "^[\\+@\\.a-zA-Z0-9_]{2,50}$" 25 | }, 26 | { 27 | "name": "email", 28 | "flavor": "name", 29 | "index": "unique", 30 | "regex": "^[a-zA-Z0-9._%\\+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", 31 | "null": "yes" 32 | }, 33 | { 34 | "name": "slug", 35 | "flavor": "name", 36 | "index": "unique", 37 | "regex": "^[\\-a-zA-Z0-9]{2,20}$", 38 | "null": "yes" 39 | }, 40 | { 41 | "name": "password", 42 | "flavor": "fewWords", 43 | "index": "", 44 | "required": "", 45 | "regex": "^.{8,100}$", 46 | "null": "" 47 | } 48 | ] 49 | }, 50 | { 51 | "name": "ip_data", 52 | "fields": [ 53 | { 54 | "name": "ip", 55 | "flavor": "name", 56 | "index": "unique" 57 | }, 58 | { 59 | "name": "content", 60 | "flavor": "json" 61 | } 62 | ] 63 | }, 64 | { 65 | "name": "admin", 66 | "fields": [ 67 | { 68 | "name": "user_id", 69 | "flavor": "int", 70 | "index": "yes" 71 | } 72 | ] 73 | }, 74 | { 75 | "name": "cookie_token", 76 | "fields": [ 77 | { 78 | "name": "guid", 79 | "flavor": "uuid", 80 | "index": "yes", 81 | "required": "", 82 | "regex": "", 83 | "null": "" 84 | }, 85 | { 86 | "name": "user_id", 87 | "flavor": "int", 88 | "index": "yes", 89 | "required": "", 90 | "regex": "", 91 | "null": "" 92 | } 93 | ] 94 | } 95 | ] 96 | } 97 | 98 | -------------------------------------------------------------------------------- /bin/homeducky/app/welcome.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/andrewarrow/feedback/router" 5 | ) 6 | 7 | func Welcome(c *router.Context, second, third string) { 8 | if second == "" && third == "" && c.Method == "GET" { 9 | handleWelcomeIndex(c) 10 | return 11 | } 12 | c.NotFound = true 13 | } 14 | 15 | func handleWelcomeIndex(c *router.Context) { 16 | 17 | send := map[string]any{} 18 | c.SendContentInLayout("welcome.html", send, 200) 19 | } 20 | -------------------------------------------------------------------------------- /bin/homeducky/assets/css/tail.components.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /bin/homeducky/browser/register.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/andrewarrow/feedback/wasm" 5 | ) 6 | 7 | var Global *wasm.Global 8 | var Document *wasm.Document 9 | 10 | func RegisterEvents() { 11 | LogoutEvents() 12 | afterRegister := func(id int64) { 13 | Global.Location.Set("href", "/core/start") 14 | } 15 | afterLogin := func(id int64) { 16 | Global.Location.Set("href", "/core/start") 17 | } 18 | if Global.Start == "start.html" { 19 | } else if Global.Start == "login.html" { 20 | Global.AutoForm("login", "core", nil, afterLogin) 21 | } else if Global.Start == "register.html" { 22 | Global.AutoForm("register", "core", nil, afterRegister) 23 | } 24 | } 25 | 26 | func LogoutEvents() { 27 | if Document.Id("logout") == nil { 28 | return 29 | } 30 | Global.Event("logout", Global.Logout("/core")) 31 | } 32 | -------------------------------------------------------------------------------- /bin/homeducky/go.mod: -------------------------------------------------------------------------------- 1 | module {{homeducky}} 2 | 3 | //replace github.com/andrewarrow/feedback => /Users/aa/os/feedback 4 | 5 | go 1.21.0 6 | 7 | require github.com/andrewarrow/feedback v0.0.0-20240907021727-403548d46285 8 | -------------------------------------------------------------------------------- /bin/homeducky/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "math/rand" 6 | "os" 7 | "time" 8 | "{{homeducky}}/app" 9 | 10 | "github.com/andrewarrow/feedback/router" 11 | ) 12 | 13 | //go:embed app/feedback.json 14 | var embeddedFile []byte 15 | 16 | //go:embed views/*.html 17 | var embeddedTemplates embed.FS 18 | 19 | //go:embed assets/**/*.* 20 | var embeddedAssets embed.FS 21 | 22 | var buildTag string 23 | 24 | func main() { 25 | rand.Seed(time.Now().UnixNano()) 26 | if len(os.Args) == 1 { 27 | //PrintHelp() 28 | return 29 | } 30 | 31 | arg := os.Args[1] 32 | router.DB_FLAVOR = "sqlite" 33 | 34 | if arg == "import" { 35 | } else if arg == "render" { 36 | router.RenderMarkup() 37 | } else if arg == "run" { 38 | router.BuildTag = buildTag 39 | router.EmbeddedTemplates = embeddedTemplates 40 | router.EmbeddedAssets = embeddedAssets 41 | r := router.NewRouter("DATABASE_URL", embeddedFile) 42 | r.Paths["/"] = app.Welcome 43 | r.Paths["core"] = app.Core 44 | //r.Paths["api"] = app.HandleApi 45 | //r.Paths["login"] = app.Login 46 | //r.Paths["register"] = app.Register 47 | //r.Paths["admin"] = app.Admin 48 | r.Paths["markup"] = router.Markup 49 | r.BucketPath = "/Users/aa/bucket" 50 | r.ListenAndServe(":" + os.Args[2]) 51 | } else if arg == "help" { 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /bin/homeducky/markup/404.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex flex-col md:flex-row space-x-9 items-start justify-center 4 | h1 text-3xl mt-9 5 | This is your 404 page. 6 | -------------------------------------------------------------------------------- /bin/homeducky/markup/application_layout.mu: -------------------------------------------------------------------------------- 1 | html data-theme=sunset 2 | head 3 | {{ $build := index . "build" }} 4 | {{ $og := index . "og" }} 5 | meta property=og:image content={{$og}} 6 | link rel=apple-touch-icon href=/assets/images/logo.png 7 | link rel=apple-touch-startup-image href=/assets/images/logo.png 8 | link rel=icon href=/assets/images/logo.png 9 | link rel=stylesheet type=text/css href=/assets/css/tail.min.css?id!{{$build}} 10 | link rel=stylesheet type=text/css href=/assets/css/main.css?id!{{$build}} 11 | {{ if index . "USE_LIVE_TEMPLATES" }} 12 | script src=https://cdn.tailwindcss.com 13 | link href=https://cdn.jsdelivr.net/npm/daisyui@4.12.8/dist/full.min.css rel=stylesheet type=text/css 14 | {{ end }} 15 | script src=/assets/javascript/wasm_exec.js?id!{{$build}} 16 | script 17 | function $(id) { return document.getElementById(id); } 18 | title 19 | {{ index . "title" }} 20 | {{ index . "viewport" }} 21 | body 22 | div id=flash bg-red-600 text-white text-center fixed top-0 left-0 w-full 23 | {{ index . "flash" }} 24 | div overflow-x-auto pl-3 pr-3 min-h-screen font-montserrat text-base 25 | {{ index . "content" }} 26 | div 27 | div pb-32 footer items-center p-10 bg-base-200 text-base-content rounded 28 | div items-center grid-flow-col 29 | Copyright © 2024 - All right reserved by andrewarrow.dev 30 | div grid-flow-col gap-4 md:place-self-center md:justify-self-end 31 | a href=https://andrewarrow.dev/ link link-hover 32 | About Us 33 | a href=https://andrewarrow.dev/ link link-hover 34 | Pricing 35 | a href=https://andrewarrow.dev/ link link-hover 36 | Terms & Conditions 37 | a href=https://andrewarrow.dev/ link link-hover 38 | Privacy Policy 39 | {{ index . "wasm" }} 40 | -------------------------------------------------------------------------------- /bin/homeducky/markup/login.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex justify-center mt-32 4 | div 5 | div text-center 6 | h1 text-5xl font-bold 7 | Login 8 | form mt-6 space-y-3 id=login 9 | div 10 | input input input-primary id=email placeholder=email autofocus=true 11 | div 12 | input input input-primary id=password placeholder=password 13 | div flex space-x-6 justify-center 14 | div 15 | input type=submit btn btn-primary value=Go 16 | div mt-3 data-theme=light p-2 rounded-lg text-center 17 | span 18 | Need an account? 19 | a href=/core/register underline 20 | Register 21 | div hidden mt-6 text-sm flex justify-center 22 | a href=/core/forgot underline 23 | Password Help 24 | -------------------------------------------------------------------------------- /bin/homeducky/markup/navbar.mu: -------------------------------------------------------------------------------- 1 | {{ define "navbar" }} 2 | div navbar bg-base-200 font-familjen 3 | div navbar-start 4 | div btn btn-ghost text-4xl 5 | a href=/ 6 | img src=logo.png w-12 7 | div navbar-center flex hidden md:block 8 | div navbar-end 9 | div hidden md:block flex space-x-3 10 | {{ if .user }} 11 | a href=/ id=logout 12 | Logout 13 | {{ else }} 14 | a href=/core/login link link-hover 15 | Login 16 | {{ end }} 17 | {{ end }} 18 | -------------------------------------------------------------------------------- /bin/homeducky/markup/register.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex justify-center mt-32 4 | div 5 | div text-center 6 | h1 text-5xl font-bold 7 | Register 8 | form mt-6 space-y-3 id=register 9 | div 10 | input input input-primary id=email placeholder=email autofocus=true 11 | div 12 | input input input-primary id=password placeholder=password 13 | div flex justify-center 14 | input type=submit btn btn-primary value=Go 15 | div mt-3 text-center 16 | span 17 | Already have account? 18 | a href=/core/login underline 19 | Login 20 | -------------------------------------------------------------------------------- /bin/homeducky/markup/start.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex flex-col md:flex-row space-x-9 items-start justify-center 4 | div w-full md:w-1/2 5 | div text-center text-2xl 6 | Start 7 | -------------------------------------------------------------------------------- /bin/homeducky/markup/welcome.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex flex-col md:flex-row space-x-9 items-start justify-center 4 | div w-full md:w-1/2 5 | div text-center text-2xl 6 | Welcome 7 | -------------------------------------------------------------------------------- /bin/homeducky/run: -------------------------------------------------------------------------------- 1 | go mod tidy 2 | go build 3 | ./{{homeducky}} render 4 | cp main.go save_main 5 | cp wasm/main.go . 6 | GOOS=js GOARCH=wasm go build -ldflags="-s -w -X main.useLive=true" -o assets/other/json.wasm 7 | mv save_main main.go 8 | export DATABASE_URL={{homeducky}} 9 | if [ $? -eq 0 ]; then 10 | cd assets/other 11 | rm json.wasm.gz 12 | gzip -f json.wasm 13 | cd ../.. 14 | tailwindcss -i assets/css/tail.components.css -o assets/css/tail.min.css --minify 15 | uuid=$(uuidgen); go build -ldflags="-X main.buildTag=$uuid" 16 | echo 3 17 | ./{{homeducky}} run 3000 18 | fi 19 | 20 | -------------------------------------------------------------------------------- /bin/homeducky/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['views/*.html',], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'cream': '#EFDECD', 8 | 'lime': '#8FBC8F', 9 | 'a-blue': '#4A88EE', 10 | 'a-dark': '#00364d', 11 | 'a-good': '#00364d' 12 | }, 13 | fontFamily: { 14 | pragmatica: ['Pragmatica'], 15 | familjen: ['Familjen Grotesk'], 16 | }, 17 | }, 18 | }, 19 | plugins: [require("daisyui")], 20 | daisyui: { 21 | themes: ["light", "dark", "luxury", "sunset"], 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /bin/homeducky/wasm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | "{{homeducky}}/browser" 9 | 10 | "github.com/andrewarrow/feedback/wasm" 11 | ) 12 | 13 | //go:embed views/*.html 14 | var embeddedTemplates embed.FS 15 | 16 | var useLive string 17 | var viewList string 18 | 19 | func main() { 20 | fmt.Println(viewList) 21 | wasm.UseLive = useLive == "true" 22 | wasm.EmbeddedTemplates = embeddedTemplates 23 | rand.Seed(time.Now().UnixNano()) 24 | fmt.Println("Go Web Assembly") 25 | browser.Global, browser.Document = wasm.NewGlobal() 26 | 27 | <-browser.Global.Ready 28 | if wasm.UseLive { 29 | files, _ := embeddedTemplates.ReadDir("views") 30 | go func() { 31 | wasm.LoadAllTemplates(files) 32 | browser.RegisterEvents() 33 | }() 34 | } else { 35 | browser.RegisterEvents() 36 | } 37 | 38 | select {} 39 | } 40 | -------------------------------------------------------------------------------- /bin/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import fileinput 6 | import shutil 7 | 8 | path = sys.argv[1] 9 | name = sys.argv[2] 10 | 11 | def replace_placeholders(destination_directory): 12 | for dirpath, _, filenames in os.walk(destination_directory): 13 | for filename in filenames: 14 | file_path = os.path.join(dirpath, filename) 15 | if os.path.isfile(file_path): 16 | try: 17 | replace_in_file(file_path) 18 | except UnicodeDecodeError: 19 | pass 20 | 21 | def replace_in_file(file_path): 22 | with fileinput.FileInput(file_path, inplace=True) as f: 23 | for line in f: 24 | line = line.replace('{{homeducky}}', name) 25 | print(line, end='') 26 | 27 | def main(): 28 | source_directory = "homeducky" 29 | destination_directory = path + "/" + name 30 | shutil.copytree(source_directory, destination_directory) 31 | replace_placeholders(destination_directory) 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /buckets/cloud.go: -------------------------------------------------------------------------------- 1 | package buckets 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | 9 | "cloud.google.com/go/storage" 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/service/s3" 12 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 13 | "github.com/aws/aws-sdk-go/aws" 14 | "google.golang.org/api/option" 15 | ) 16 | 17 | func StoreInAws(data []byte, filename string) { 18 | StoreInAwsWithFlavor(data, filename, "application/octet-stream") 19 | } 20 | 21 | func StoreInAwsWithFlavor(data []byte, filename, flavor string) { 22 | 23 | bucketName := os.Getenv("PUBLIC_STORAGE_BUCKET") 24 | 25 | cfg, err := config.LoadDefaultConfig(context.Background(), 26 | config.WithRegion("us-west-2"), 27 | ) 28 | fmt.Println(err) 29 | 30 | client := s3.NewFromConfig(cfg) 31 | 32 | dataReader := bytes.NewReader(data) 33 | 34 | putObjectInput := &s3.PutObjectInput{ 35 | Bucket: aws.String(bucketName), 36 | Key: aws.String(filename), 37 | Body: dataReader, 38 | ContentType: aws.String(flavor), 39 | } 40 | putObjectInput.ACL = types.ObjectCannedACLPublicRead 41 | 42 | _, err = client.PutObject(context.Background(), putObjectInput) 43 | fmt.Println(err) 44 | } 45 | 46 | func StoreInGoogle(data []byte, filename string) { 47 | bucket := os.Getenv("PUBLIC_STORAGE_BUCKET") 48 | keyPath := os.Getenv("KEY_PATH") 49 | 50 | gcsClient, err := storage.NewClient(context.Background(), 51 | option.WithCredentialsFile(keyPath)) 52 | fmt.Println(err, bucket, keyPath, len(data), filename) 53 | 54 | w := gcsClient.Bucket(bucket).Object(filename).NewWriter(context.Background()) 55 | w.ContentType = "application/octet-stream" 56 | _, err = w.Write(data) 57 | fmt.Println("write", err) 58 | w.Close() 59 | } 60 | -------------------------------------------------------------------------------- /feedback.json: -------------------------------------------------------------------------------- 1 | { 2 | "footer": "github.com/andrewarrow/feedback", 3 | "title": "feedback", 4 | "routes": [{"root": "sessions", "paths": [ 5 | {"verb": "GET", "second": "", "third": ""}, 6 | {"verb": "GET", "second": "*", "third": ""}, 7 | {"verb": "POST", "second": "", "third": ""} 8 | ]}, 9 | {"root": "users", "paths": [ 10 | {"verb": "GET", "second": "", "third": ""}, 11 | {"verb": "GET", "second": "*", "third": ""}, 12 | {"verb": "GET", "second": "thing", "third": "*"}, 13 | {"verb": "POST", "second": "", "third": ""} 14 | ]} 15 | ], 16 | "models": [ 17 | { 18 | "name": "user", 19 | "fields": [ 20 | { 21 | "name": "username", 22 | "flavor": "username", 23 | "index": "unique", 24 | "required": "", 25 | "regex": "^[a-zA-Z0-9_]{2,20}$", 26 | "null": "" 27 | }, 28 | { 29 | "name": "password", 30 | "flavor": "fewWords", 31 | "index": "", 32 | "required": "", 33 | "regex": "^.{8,100}$", 34 | "null": "" 35 | }, 36 | { 37 | "name": "id", 38 | "flavor": "int", 39 | "index": "", 40 | "required": "", 41 | "regex": "", 42 | "null": "" 43 | }, 44 | { 45 | "name": "created_at", 46 | "flavor": "timestamp", 47 | "index": "yes", 48 | "required": "", 49 | "regex": "", 50 | "null": "" 51 | }, 52 | { 53 | "name": "guid", 54 | "flavor": "uuid", 55 | "index": "yes", 56 | "required": "", 57 | "regex": "", 58 | "null": "" 59 | }, 60 | { 61 | "name": "updated_at", 62 | "flavor": "timestamp", 63 | "index": "yes", 64 | "required": "", 65 | "regex": "", 66 | "null": "" 67 | }, 68 | { 69 | "name": "security_level", 70 | "flavor": "name", 71 | "index": "", 72 | "required": "", 73 | "regex": "", 74 | "null": "" 75 | } 76 | ] 77 | }, 78 | { 79 | "name": "zip_location", 80 | "fields": [ 81 | { 82 | "name": "zip", 83 | "flavor": "int", 84 | "index": "unique" 85 | }, 86 | { 87 | "name": "location", 88 | "flavor": "geometry", 89 | "null": "yes" 90 | } 91 | ] 92 | }, 93 | { 94 | "name": "cookie_token", 95 | "fields": [ 96 | { 97 | "name": "guid", 98 | "flavor": "uuid", 99 | "index": "yes", 100 | "required": "", 101 | "regex": "", 102 | "null": "" 103 | }, 104 | { 105 | "name": "user_id", 106 | "flavor": "int", 107 | "index": "yes", 108 | "required": "", 109 | "regex": "", 110 | "null": "" 111 | } 112 | ] 113 | } 114 | ] 115 | } 116 | -------------------------------------------------------------------------------- /files/home.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | ) 7 | 8 | func UserHomeDir() string { 9 | if runtime.GOOS == "windows" { 10 | home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 11 | if home == "" { 12 | home = os.Getenv("USERPROFILE") 13 | } 14 | return home 15 | } 16 | return os.Getenv("HOME") 17 | } 18 | -------------------------------------------------------------------------------- /files/read.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "io/ioutil" 4 | 5 | func ReadFile(name string) string { 6 | b, _ := ioutil.ReadFile(name) 7 | return string(b) 8 | } 9 | -------------------------------------------------------------------------------- /files/write.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | ) 7 | 8 | func SaveFile(name, data string) { 9 | os.Remove(name) 10 | ioutil.WriteFile(name, []byte(data), 0644) 11 | } 12 | -------------------------------------------------------------------------------- /filestorage/README.md: -------------------------------------------------------------------------------- 1 | 2 | The real 3 | 4 | `cloud.google.com/go/storage` and `google.golang.org/api/option` 5 | packages are what production code will eventually use. But, during dev time 6 | it's nice to just write files to the hard drive of your 7 | [one free tier google compute instance](https://many.pw/hosting). 8 | You get 30 GB for free! And storing in a real bucket will cost some money. 9 | 10 | ``` 11 | func getStorageClient() (*filestorage.Client, string) { 12 | keyPath := "" 13 | client, err := filestorage.NewClient(context.Background(), 14 | option.WithCredentialsFile(keyPath)) 15 | if err != nil { 16 | return nil, "" 17 | } 18 | client.BucketPath = "/bucket" 19 | bucket := "unique-name" 20 | return client, bucket 21 | } 22 | ``` 23 | 24 | This is the same make client code you'll need but it's using this fake `filestorage` 25 | package which does everything the real client does but uses your machine's hard drive. 26 | 27 | Write: 28 | 29 | ``` 30 | client, bucket := getStorageClient() 31 | w := client.Bucket(bucket).Object(filename).NewWriter(context.Background()) 32 | w.ContentType = "application/octet-stream" 33 | w.Write(data) 34 | w.Close() 35 | ``` 36 | 37 | Delete: 38 | 39 | ``` 40 | client, bucket := getStorageClient(c) 41 | ctx := context.Background() 42 | client.Bucket(bucket).Object(object).Delete(ctx) 43 | ``` 44 | 45 | How much space is left: 46 | 47 | ``` 48 | func getAvailableDiskSpace(path string) (uint64, error) { 49 | var stat syscall.Statfs_t 50 | 51 | err := syscall.Statfs(path, &stat) 52 | if err != nil { 53 | return 0, err 54 | } 55 | 56 | availableSpace := stat.Bavail * uint64(stat.Bsize) 57 | return availableSpace, nil 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /filestorage/bucket.go: -------------------------------------------------------------------------------- 1 | package filestorage 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | 9 | "google.golang.org/api/option" 10 | ) 11 | 12 | type Client struct { 13 | BucketPath string 14 | } 15 | type Bucket struct { 16 | BucketPath string 17 | } 18 | type Object struct { 19 | Filename string 20 | BucketPath string 21 | } 22 | type Writer struct { 23 | ContentType string 24 | Filename string 25 | BucketPath string 26 | } 27 | type Reader struct { 28 | Filename string 29 | BucketPath string 30 | offset int64 31 | } 32 | 33 | func NewClient(ctx context.Context, option option.ClientOption) (*Client, error) { 34 | c := Client{} 35 | return &c, nil 36 | } 37 | 38 | func (c *Client) Bucket(s string) *Bucket { 39 | b := Bucket{} 40 | b.BucketPath = c.BucketPath 41 | return &b 42 | } 43 | 44 | func (b *Bucket) Object(s string) *Object { 45 | o := Object{} 46 | o.Filename = s 47 | o.BucketPath = b.BucketPath 48 | return &o 49 | } 50 | 51 | func (o *Object) NewWriter(ctx context.Context) *Writer { 52 | w := Writer{} 53 | w.Filename = o.Filename 54 | w.BucketPath = o.BucketPath 55 | return &w 56 | } 57 | 58 | func (o *Object) NewReader(ctx context.Context) (*Reader, error) { 59 | r := Reader{} 60 | r.Filename = o.Filename 61 | r.BucketPath = o.BucketPath 62 | return &r, nil 63 | } 64 | 65 | func (o *Object) Delete(ctx context.Context) { 66 | os.Remove(o.BucketPath + "/" + o.Filename) 67 | } 68 | 69 | func (w *Writer) Close() { 70 | } 71 | 72 | func (w *Writer) Write(b []byte) (int, error) { 73 | ioutil.WriteFile(w.BucketPath+"/"+w.Filename, b, 0644) 74 | return len(b), nil 75 | } 76 | 77 | func (r *Reader) Read(p []byte) (n int, err error) { 78 | file, err := os.Open(r.BucketPath + "/" + r.Filename) 79 | if err != nil { 80 | return 0, err 81 | } 82 | defer file.Close() 83 | 84 | _, err = file.Seek(r.offset, io.SeekStart) 85 | if err != nil { 86 | return 0, err 87 | } 88 | 89 | n, err = file.Read(p) 90 | if err != nil { 91 | if err == io.EOF { 92 | r.offset += int64(n) 93 | return n, io.EOF 94 | } 95 | return n, err 96 | } 97 | 98 | r.offset += int64(n) 99 | 100 | return n, nil 101 | } 102 | -------------------------------------------------------------------------------- /glamor/run.py: -------------------------------------------------------------------------------- 1 | from selenium import webdriver 2 | from selenium.webdriver.firefox.options import Options 3 | from selenium import webdriver 4 | #from selenium.webdriver.common.by import By 5 | #from selenium.webdriver.common.keys import Keys 6 | #from selenium.webdriver.chrome.options import Options 7 | import sys 8 | import time 9 | import json 10 | from bs4 import BeautifulSoup 11 | 12 | route = sys.argv[1] 13 | 14 | options = Options() 15 | options.add_argument("-profile") 16 | options.add_argument("/Users/aa/os/synapse-system/python/fred") 17 | #options.add_argument('-headless') 18 | browser = webdriver.Firefox(options=options) 19 | browser.get(route) 20 | #browser = webdriver.Chrome(options=options) 21 | #browser.get(route) 22 | 23 | time.sleep(6) 24 | 25 | rendered_html = browser.page_source 26 | browser.quit() 27 | 28 | soup = BeautifulSoup(rendered_html, 'html.parser') 29 | 30 | for script in soup.find_all('script'): 31 | script.extract() 32 | 33 | soup = BeautifulSoup(str(soup), 'html.parser') 34 | formatted_html = soup.prettify() 35 | with open("glamor_layout.html", "w", encoding="utf-8") as f: 36 | f.write(formatted_html) 37 | 38 | -------------------------------------------------------------------------------- /gogen/files.go: -------------------------------------------------------------------------------- 1 | package gogen 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/andrewarrow/feedback/files" 9 | "github.com/andrewarrow/feedback/util" 10 | ) 11 | 12 | func MakeControllerAndView(name, dir string) { 13 | c := `package app 14 | 15 | import ( 16 | "github.com/andrewarrow/feedback/router" 17 | ) 18 | 19 | func Handle{{index . "camel" }}(c *router.Context, second, third string) { 20 | if c.User == nil { 21 | c.UserRequired = true 22 | return 23 | } 24 | if second == "" { 25 | handle{{index . "camel" }}Index(c) 26 | } else if second != "" && third == "" { 27 | handle{{index . "camel" }}Show(c, second) 28 | } else { 29 | c.NotFound = true 30 | } 31 | } 32 | 33 | func handle{{index . "camel" }}Index(c *router.Context) { 34 | if c.Method == "GET" { 35 | rows := c.SelectAllFrom("{{index . "name" }}", "", "") 36 | c.SendContentInLayout("{{index . "name" }}_index.html", rows, 200) 37 | return 38 | } 39 | handle{{index . "camel" }}Create(c) 40 | } 41 | 42 | func handle{{index . "camel" }}Create(c *router.Context) { 43 | c.NotFound = true 44 | } 45 | 46 | func handle{{index . "camel" }}Show(c *router.Context, id string) { 47 | if c.Method == "GET" { 48 | row := c.SelectOneFrom(id, "{{index . "name" }}") 49 | c.SendContentInLayout("{{index . "name" }}_show.html", row, 200) 50 | return 51 | } 52 | handle{{index . "camel" }}Updates(c, id) 53 | } 54 | 55 | func handle{{index . "camel" }}Updates(c *router.Context, id string) { 56 | if c.Method == "POST" { 57 | c.NotFound = true 58 | } else if c.Method == "DELETE" { 59 | c.NotFound = true 60 | } 61 | }` 62 | 63 | m := map[string]string{"name": name, "camel": util.ToCamelCase(name)} 64 | t, _ := template.New("c").Parse(c) 65 | content := new(bytes.Buffer) 66 | t.Execute(content, m) 67 | controller := content.String() 68 | files.SaveFile(dir+"/app/"+name+"_controller.go", controller) 69 | MakeIndexView(name, dir+"/views/"+name+"_index.html") 70 | MakeShowView(name, dir+"/views/"+name+"_show.html") 71 | 72 | fmt.Printf("\nr.Paths[\"%s\"] = app.Handle%s\n", name, m["camel"]) 73 | } 74 | 75 | func MakeIndexView(name, path string) { 76 | v := `
77 |
78 |
79 |

%s

80 |

hi

81 |
82 |
83 | 84 | {{range $i, $item := .}} 85 | 86 | 87 | 88 | 89 | {{end}} 90 |
{{add $i 1}}{{index $item "guid"}}
91 |
` 92 | view := fmt.Sprintf(v, name, name) 93 | files.SaveFile(path, view) 94 | } 95 | 96 | func MakeShowView(name, path string) { 97 | v := `
98 |
99 |
100 |

%s

101 |

{{ index . "guid"}}

102 |
103 |
104 |
105 |
` 106 | view := fmt.Sprintf(v, name) 107 | files.SaveFile(path, view) 108 | } 109 | -------------------------------------------------------------------------------- /htmlgen/pretty_print.go: -------------------------------------------------------------------------------- 1 | package htmlgen 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | 11 | "github.com/andrewarrow/feedback/files" 12 | "golang.org/x/net/html" 13 | ) 14 | 15 | func PrettyPrint(path string) { 16 | input := files.ReadFile(path) 17 | s := parseIt(input) 18 | fmt.Println(s) 19 | } 20 | 21 | func PrettyPrintAll() { 22 | list, _ := ioutil.ReadDir("views") 23 | for _, file := range list { 24 | input := files.ReadFile("views/" + file.Name()) 25 | s := parseIt(input) 26 | fmt.Println(s) 27 | } 28 | } 29 | 30 | func parseIt(input string) string { 31 | doc, err := html.ParseFragment(strings.NewReader(input), nil) 32 | if err != nil { 33 | fmt.Fprintf(os.Stderr, "Error parsing HTML: %v\n", err) 34 | os.Exit(1) 35 | } 36 | content := new(bytes.Buffer) 37 | for _, n := range doc { 38 | indent(content, n, 0) 39 | } 40 | 41 | return content.String() 42 | } 43 | 44 | func indent(w io.Writer, n *html.Node, depth int) { 45 | prefix := strings.Repeat(" ", depth*2) 46 | switch n.Type { 47 | case html.ElementNode: 48 | fmt.Fprintf(w, "%s<%s", prefix, n.Data) 49 | for _, a := range n.Attr { 50 | fmt.Fprintf(w, " %s=\"%s\"", a.Key, a.Val) 51 | } 52 | if n.FirstChild == nil { 53 | fmt.Fprint(w, "/>\n") 54 | } else { 55 | fmt.Fprint(w, ">\n") 56 | for c := n.FirstChild; c != nil; c = c.NextSibling { 57 | indent(w, c, depth+1) 58 | } 59 | fmt.Fprintf(w, "%s\n", prefix, n.Data) 60 | } 61 | case html.TextNode: 62 | text := strings.TrimSpace(n.Data) 63 | if text != "" { 64 | fmt.Fprintf(w, "%s%s\n", prefix, text) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /location/zip.go: -------------------------------------------------------------------------------- 1 | package location 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/andrewarrow/feedback/router" 13 | ) 14 | 15 | func ReadInZips(c *router.Context, dirPath string) { 16 | 17 | files, _ := ioutil.ReadDir(dirPath) 18 | for _, file := range files { 19 | filename := dirPath + "/" + file.Name() 20 | ReadInZipsState(c, filename) 21 | } 22 | } 23 | 24 | var gcount = 0 25 | 26 | func processLine(c *router.Context, line string) { 27 | gcount++ 28 | if gcount%1000 == 0 { 29 | fmt.Println(gcount, line) 30 | } 31 | 32 | line = strings.TrimSpace(line) 33 | if line == "" { 34 | //fmt.Println("no zip") 35 | return 36 | } 37 | var m map[string]any 38 | json.Unmarshal([]byte(line), &m) 39 | properties := m["properties"].(map[string]any) 40 | zip, ok := properties["postcode"].(string) 41 | if !ok { 42 | //fmt.Println("no zip", line) 43 | return 44 | } 45 | geo, ok := m["geometry"].(map[string]any) 46 | if !ok { 47 | //fmt.Println("no zip", line) 48 | return 49 | } 50 | latlong := geo["coordinates"].([]any) 51 | if len(latlong) == 2 && len(zip) == 5 { 52 | //fmt.Println(zip, latlong) 53 | s1 := `INSERT INTO zip_locations (zip, location) VALUES (%d, %s)` 54 | s2 := `ST_SetSRID(ST_MakePoint(%f, %f), 4326)` 55 | loc := fmt.Sprintf(s2, latlong[0], latlong[1]) 56 | zipInt, _ := strconv.Atoi(zip) 57 | sql := fmt.Sprintf(s1, zipInt, loc) 58 | //fmt.Println(sql) 59 | c.FreeFormUpdate(sql) 60 | } else { 61 | //fmt.Println("no zip", line) 62 | } 63 | } 64 | 65 | func handleFileInBatches(c *router.Context, filename string) { 66 | file, _ := os.Open(filename) 67 | buffer := make([]byte, 1) 68 | line := []string{} 69 | for { 70 | n, err := file.Read(buffer) 71 | 72 | if err == io.EOF || n == 0 { 73 | break 74 | } 75 | 76 | s := string(buffer) 77 | if s == "\n" { 78 | theLine := strings.Join(line, "") 79 | processLine(c, theLine) 80 | line = []string{} 81 | } 82 | line = append(line, s) 83 | } 84 | file.Close() 85 | } 86 | 87 | func ReadInZipsState(c *router.Context, dirPath string) { 88 | // from https://openaddresses.io/ 89 | // https://batch.openaddresses.io/data 90 | // alameda-addresses-county.geojson 91 | // {"type":"Feature","properties":{"hash":"9967fbc6d2dd1931","number":"41829","street":"OSGOOD RD","unit":"","city":"FREMONT","district":"","region":"","postcode":"94539","id":"525 034200500"},"geometry":{"type":"Point","coordinates":[-121.952505,37.5293622]}} 92 | files, _ := ioutil.ReadDir(dirPath) 93 | for _, file := range files { 94 | filename := dirPath + "/" + file.Name() 95 | if strings.HasSuffix(filename, ".meta") { 96 | continue 97 | } 98 | handleFileInBatches(c, filename) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /markup/404.mu: -------------------------------------------------------------------------------- 1 | div p-0 2 | {{ template "navbar" . }} 3 | div flex flex-col md:flex-row space-x-9 items-start justify-center 4 | h1 text-3xl mt-9 5 | This is your 404 page. 6 | -------------------------------------------------------------------------------- /markup/application_layout.mu: -------------------------------------------------------------------------------- 1 | html data-theme=sunset 2 | head 3 | {{ $build := index . "build" }} 4 | {{ $og := index . "og" }} 5 | meta property=og:image content={{$og}} 6 | link rel=apple-touch-icon href=/assets/images/logo.png 7 | link rel=apple-touch-startup-image href=/assets/images/logo.png 8 | link rel=icon href=/assets/images/logo.png 9 | link rel=stylesheet type=text/css href=/assets/css/tail.min.css?id!{{$build}} 10 | link rel=stylesheet type=text/css href=/assets/css/main.css?id!{{$build}} 11 | {{ if index . "USE_LIVE_TEMPLATES" }} 12 | script src=https://cdn.tailwindcss.com 13 | link href=https://cdn.jsdelivr.net/npm/daisyui@4.12.8/dist/full.min.css rel=stylesheet type=text/css 14 | {{ end }} 15 | script src=/assets/javascript/wasm_exec.js?id!{{$build}} 16 | script 17 | function $(id) { return document.getElementById(id); } 18 | title 19 | {{ index . "title" }} 20 | {{ index . "viewport" }} 21 | body 22 | div id=flash bg-red-600 text-white text-center fixed top-0 left-0 w-full 23 | {{ index . "flash" }} 24 | div overflow-x-auto pl-3 pr-3 min-h-screen font-montserrat text-base 25 | {{ index . "content" }} 26 | div 27 | div pb-32 footer items-center p-10 bg-base-200 text-base-content rounded 28 | div items-center grid-flow-col 29 | Copyright © 2024 - All right reserved by andrewarrow.dev 30 | div grid-flow-col gap-4 md:place-self-center md:justify-self-end 31 | a href=/space/about-us link link-hover 32 | About Us 33 | a href=/space/pricing link link-hover 34 | Pricing 35 | a href=/space/terms link link-hover 36 | Terms & Conditions 37 | a href=/space/privacy link link-hover 38 | Privacy Policy 39 | {{ index . "wasm" }} 40 | -------------------------------------------------------------------------------- /markup/html.go: -------------------------------------------------------------------------------- 1 | package markup 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strings" 7 | ) 8 | 9 | func ToHTML(m map[string]any, filename string) string { 10 | asBytes, _ := ioutil.ReadFile(filename) 11 | asString := string(asBytes) 12 | asLines := strings.Split(asString, "\n") 13 | return ToHTMLFromLines(m, asLines) 14 | } 15 | 16 | func ToHTMLFromLines(m map[string]any, asLines []string) string { 17 | root := NewTag(0, []string{"root"}) 18 | 19 | spaceMap := map[string]*Tag{} 20 | for i, line := range asLines { 21 | tokens := strings.Split(line, " ") 22 | if len(tokens) == 1 { 23 | continue 24 | } 25 | spaces := countSpaces(tokens) 26 | tag := NewTag(spaces, tokens) 27 | key := fmt.Sprintf("%d_%d", i, spaces) 28 | //fmt.Println(key) 29 | spaceMap[key] = tag 30 | } 31 | 32 | //fmt.Println("key") 33 | 34 | for i, line := range asLines { 35 | tokens := strings.Split(line, " ") 36 | if len(tokens) == 1 { 37 | continue 38 | } 39 | 40 | spaces := countSpaces(tokens) 41 | more := 0 42 | for { 43 | key := fmt.Sprintf("%d_%d", i-more, spaces-2) 44 | p := spaceMap[key] 45 | if p != nil { 46 | key = fmt.Sprintf("%d_%d", i, spaces) 47 | t := spaceMap[key] 48 | //fmt.Println(key, p.Name, t.Name) 49 | 50 | p.Children = append(p.Children, t) 51 | break 52 | } 53 | more++ 54 | if i-more < 0 { 55 | break 56 | } 57 | } 58 | 59 | } 60 | 61 | top := spaceMap["0_0"] 62 | root.Children = append(root.Children, top) 63 | final := renderHTML(m, root, "") 64 | //final := "" 65 | //fmt.Println(root) 66 | return final 67 | } 68 | 69 | func renderHTML2(m map[string]any, tag *Tag, tabs string) string { 70 | if tag.Name != "root" && tag.Name != "" { 71 | fmt.Println(tabs + tag.Name) 72 | } 73 | 74 | for _, child := range tag.Children { 75 | renderHTML(m, child, tabs+" ") 76 | } 77 | 78 | if tag.Name != "root" && tag.Name != "" && tag.Close { 79 | fmt.Println(tabs + "/" + tag.Name) 80 | } 81 | 82 | if tag.Text != "" { 83 | fmt.Println(tabs + "/" + tag.Text) 84 | } 85 | 86 | return "" 87 | } 88 | 89 | func renderHTML(m map[string]any, tag *Tag, tabs string) string { 90 | html := "" 91 | 92 | if tag.Name != "root" && tag.Name != "" { 93 | if tag.Name != "{{" { 94 | html += fmt.Sprintf("%s<%s", tabs, tag.Name) 95 | //html += tabs + "<" + tag.Name 96 | html += fmt.Sprintf(` %s `, tag.MakeAttr()) 97 | } 98 | if tag.Close == false { 99 | if tag.Name != "{{" { 100 | html += "/>" 101 | } else { 102 | html += fmt.Sprintf("%s%s", tabs, tag.Text) 103 | } 104 | } else { 105 | html = strings.TrimRight(html, " ") + ">" 106 | } 107 | html += "\n" 108 | } 109 | 110 | for _, child := range tag.Children { 111 | html += renderHTML(m, child, tabs+" ") 112 | } 113 | 114 | if tag.Name != "root" && tag.Name != "" && tag.Close { 115 | html += tabs + "" 116 | html += "\n" 117 | } 118 | 119 | if tag.Text != "" && tag.Name != "{{" { 120 | if strings.HasPrefix(tag.Text, "#") { 121 | key := tag.Text[1:len(tag.Text)] 122 | html += m[key].(string) 123 | } else { 124 | html += tabs + tag.Text 125 | html += "\n" 126 | } 127 | } 128 | 129 | return html 130 | } 131 | 132 | func countSpaces(tokens []string) int { 133 | count := 0 134 | for _, item := range tokens { 135 | if item == "" { 136 | count++ 137 | } else { 138 | break 139 | } 140 | } 141 | return count 142 | } 143 | -------------------------------------------------------------------------------- /markup/html_test.go: -------------------------------------------------------------------------------- 1 | package markup 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestToHTML(t *testing.T) { 10 | send := map[string]any{} 11 | 12 | q := ` 13 | div 14 | div 15 | img 16 | div bg-green-100 space-y-3 pt-3 pl-3 17 | {{ $list := index . "list" }} 18 | {{ range $i, $item := $list }} 19 | {{ $title := index $item "title" }} 20 | {{ $id := index $item "id_hacker" }} 21 | {{ $digitSum := index $item "digit_sum" }} 22 | {{ $sum := index $item "sum" }} 23 | div flex 24 | div mr-3 25 | {{ add $i 1 }}. 26 | div 27 | a href=https://news.ycombinator.com/item?id!{{$id}} 28 | {{ $title }} 29 | div text-gray-400 30 | {{ $digitSum }} 31 | from 32 | a text-gray-400 href=/landing-pages/news-sum/{{$sum}} 33 | {{ $sum }} 34 | {{ end }} 35 | ` 36 | lines := strings.Split(q, "\n") 37 | s := ToHTMLFromLines(send, lines) 38 | fmt.Println(s) 39 | } 40 | -------------------------------------------------------------------------------- /markup/navbar.mu: -------------------------------------------------------------------------------- 1 | {{ define "navbar" }} 2 | div navbar bg-base-200 font-familjen 3 | div navbar-start 4 | div btn btn-ghost text-4xl 5 | a href=/ 6 | img src=logo.png w-12 7 | div navbar-center flex hidden md:block 8 | div navbar-end 9 | div hidden md:block flex space-x-3 10 | a href=/frame/routing link link-hover 11 | Routing 12 | a href=/frame/sql link link-hover 13 | SQL 14 | a href=/frame/migrations link link-hover 15 | Migrations 16 | a href=/frame/wasm link link-hover 17 | WASM 18 | a href=/frame/faq link link-hover 19 | FAQ 20 | {{ end }} 21 | -------------------------------------------------------------------------------- /markup/random.go: -------------------------------------------------------------------------------- 1 | package markup 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | var attrs = []string{"flex", "w-%d", "h-%d", "items-center", "justify-center"} 11 | 12 | func makeAttrs() string { 13 | buff := []string{} 14 | r := rand.Intn(100) 15 | if r > 50 { 16 | buff = append(buff, "flex") 17 | } 18 | r = rand.Intn(100) 19 | if r > 50 { 20 | buff = append(buff, "bg-r") 21 | } 22 | r = rand.Intn(100) 23 | if r > 50 { 24 | buff = append(buff, "w-64") 25 | buff = append(buff, "h-64") 26 | } 27 | r = rand.Intn(100) 28 | if r > 50 { 29 | buff = append(buff, "w-96") 30 | buff = append(buff, "h-96") 31 | } 32 | r = rand.Intn(100) 33 | if r > 50 { 34 | buff = append(buff, "w-full") 35 | } 36 | r = rand.Intn(100) 37 | if r > 50 { 38 | buff = append(buff, "w-1/2") 39 | } 40 | r = rand.Intn(100) 41 | if r > 50 { 42 | buff = append(buff, "rounded") 43 | } 44 | r = rand.Intn(100) 45 | if r > 50 { 46 | buff = append(buff, "rounded-full") 47 | } 48 | r = rand.Intn(100) 49 | if r > 50 { 50 | buff = append(buff, "flex-grow") 51 | } 52 | 53 | return strings.Join(buff, " ") 54 | } 55 | 56 | func DivsAndDivs() { 57 | rand.Seed(time.Now().UnixNano()) 58 | 59 | count := 0 60 | fmt.Println("div") 61 | spaces := " " 62 | maxIndent := 4 63 | for { 64 | count++ 65 | childIndent := 2 66 | childSpaces := strings.Repeat(" ", childIndent) 67 | fmt.Printf("%sdiv %s\n", spaces+childSpaces, makeAttrs()) 68 | r := rand.Intn(100) 69 | if r > 80 { 70 | fmt.Printf("%s D%d\n", spaces+childSpaces, count) 71 | } 72 | 73 | r = rand.Intn(100) 74 | action := 1 75 | if r > 60 { 76 | action = -1 77 | } 78 | r = rand.Intn(100) 79 | if r > 60 { 80 | action = 0 81 | } 82 | if maxIndent > 20 { 83 | action = rand.Intn(3) - 1 84 | } 85 | maxIndent += 2 * action 86 | 87 | spaces = moreOrLess(len(spaces), maxIndent) 88 | if count > 200 { 89 | break 90 | } 91 | } 92 | } 93 | 94 | func moreOrLess(currLen, maxLen int) string { 95 | if currLen < maxLen { 96 | return strings.Repeat(" ", currLen+2) 97 | } else { 98 | if currLen-2 < 0 { 99 | return " " 100 | } 101 | return strings.Repeat(" ", currLen-2) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /markup/tag.go: -------------------------------------------------------------------------------- 1 | package markup 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Tag struct { 9 | Name string 10 | Text string 11 | Children []*Tag 12 | Close bool 13 | Attr map[string]string 14 | } 15 | 16 | var validTagMap = map[string]int{"div": 2, "img": 3, "root": 1, "a": 2, 17 | "h1": 2, 18 | "h2": 2, 19 | "h3": 2, 20 | "h4": 2, 21 | "h5": 2, 22 | "meta": 2, 23 | "html": 2, 24 | "head": 2, 25 | "script": 2, 26 | "pre": 2, 27 | "label": 2, 28 | "link": 3, 29 | "body": 2, 30 | "ul": 2, 31 | "li": 2, 32 | "title": 2, 33 | "tbody": 2, 34 | "details": 2, 35 | "summary": 2, 36 | "canvas": 2, 37 | "figure": 2, 38 | "select": 2, 39 | "option": 2, 40 | "table": 2, "th": 2, "tr": 2, "td": 2, "iframe": 2, "p": 2, "span": 2, "form": 2, "input": 3, "textarea": 2, "button": 2, "{{": 4} 41 | 42 | func NewTag(index int, tokens []string) *Tag { 43 | t := Tag{} 44 | name := "?" 45 | if index < len(tokens) { 46 | name = tokens[index] 47 | t.Attr = makeClassAndAttrMap(name, tokens[index+1:len(tokens)]) 48 | } 49 | if name == "form" && t.Attr["method"] == "" { 50 | t.Attr["method"] = "POST" 51 | } 52 | flavor := validTagMap[name] 53 | t.Name = name 54 | if flavor > 0 && flavor < 4 { 55 | t.Close = flavor == 2 56 | } else { 57 | t.Text = strings.Join(tokens[index:len(tokens)], " ") 58 | } 59 | if flavor == 0 { 60 | t.Name = "" 61 | } 62 | t.Children = []*Tag{} 63 | //t.Parent = parent 64 | return &t 65 | } 66 | 67 | func IsValidTag(s string) bool { 68 | return validTagMap[s] != 0 69 | } 70 | 71 | func (t *Tag) MakeAttr() string { 72 | buffer := "" 73 | 74 | for key, value := range t.Attr { 75 | buffer += fmt.Sprintf(`%s="%s" `, key, value) 76 | } 77 | 78 | return buffer 79 | } 80 | 81 | func fixValueForTag(name, key, value string) string { 82 | if (name == "a" || name == "link") && strings.Contains(value, "!") { 83 | return strings.ReplaceAll(value, "!", "=") 84 | } 85 | 86 | if strings.HasPrefix(value, "http") { 87 | return value 88 | } 89 | if strings.Contains(value, "full_url_photo") { 90 | return value 91 | } 92 | if strings.HasPrefix(value, "/bucket") { 93 | return value 94 | } 95 | if name == "img" && key == "src" { 96 | value = fmt.Sprintf("/assets/images/%s", value) 97 | } 98 | return value 99 | } 100 | 101 | func getKeyValue(s string) (string, string) { 102 | tokens := strings.Split(s, "=") 103 | if len(tokens) == 2 { 104 | return tokens[0], tokens[1] 105 | } 106 | return "", "" 107 | } 108 | 109 | func makeClassAndAttrMap(name string, tokens []string) map[string]string { 110 | m := map[string]string{} 111 | 112 | class := "" 113 | for _, item := range tokens { 114 | if strings.Contains(item, "=") { 115 | key, value := getKeyValue(item) 116 | value = fixValueForTag(name, key, value) 117 | m[key] = value 118 | } else { 119 | if item == "bg-r" { 120 | item = RandomColor() 121 | } 122 | class += item + " " 123 | } 124 | } 125 | m["class"] = class 126 | 127 | return m 128 | } 129 | -------------------------------------------------------------------------------- /models/base.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type BaseModel struct { 9 | Item map[string]any 10 | } 11 | 12 | func NewBase(item map[string]any) *BaseModel { 13 | b := BaseModel{} 14 | b.Item = item 15 | return &b 16 | } 17 | 18 | func (b *BaseModel) GetBytes(name string) []byte { 19 | v, _ := b.Item[name].(string) 20 | return []byte(v) 21 | } 22 | func (b *BaseModel) GetFloat(name string) float64 { 23 | v, _ := b.Item[name].(float64) 24 | return v 25 | } 26 | func (b *BaseModel) GetFloatAsInt(name string) int64 { 27 | v, _ := b.Item[name].(float64) 28 | return int64(v) 29 | } 30 | func (b *BaseModel) GetInt(name string) int64 { 31 | v, _ := b.Item[name].(int64) 32 | return v 33 | } 34 | func (b *BaseModel) GetString(name string) string { 35 | v, _ := b.Item[name].(string) 36 | return strings.TrimSpace(v) 37 | } 38 | func (b *BaseModel) GetStringOk(name string) (string, bool) { 39 | if b.Item[name] == nil { 40 | return "", false 41 | } 42 | v, _ := b.Item[name].(string) 43 | return v, true 44 | } 45 | func (b *BaseModel) GetMap(name string) map[string]any { 46 | v, _ := b.Item[name].(map[string]any) 47 | return v 48 | } 49 | func (b *BaseModel) GetBool(name string) (bool, bool) { 50 | if b.Item[name] == nil { 51 | return false, false 52 | } 53 | v := b.Item[name].(bool) 54 | if v == false { 55 | return false, true 56 | } 57 | return true, true 58 | } 59 | func (b *BaseModel) GetSimpleBool(name string) bool { 60 | v, _ := b.Item[name].(bool) 61 | return v 62 | } 63 | func (b *BaseModel) GetList(name string) []any { 64 | v, _ := b.Item[name].([]any) 65 | return v 66 | } 67 | func (b *BaseModel) GetTime(name string) time.Time { 68 | v, ok := b.Item[name].(time.Time) 69 | if ok { 70 | return v 71 | } 72 | v2, ok2 := b.Item[name].(int64) 73 | if ok2 { 74 | return time.Unix(v2, 0) 75 | } 76 | return time.Now() 77 | } 78 | -------------------------------------------------------------------------------- /models/find.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func FindField(model *Model, id string) *Field { 4 | for _, f := range model.Fields { 5 | if f.Name == id { 6 | return f 7 | } 8 | } 9 | 10 | return nil 11 | } 12 | -------------------------------------------------------------------------------- /models/regex.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "regexp" 4 | 5 | func RemoveNonAlphanumeric(s string) string { 6 | regex := regexp.MustCompile("[^a-z_A-Z0-9]+") 7 | return regex.ReplaceAllString(s, "_") 8 | } 9 | 10 | func RemoveMostNonAlphanumeric(s string) string { 11 | regex := regexp.MustCompile("[^a-zA-Z0-9\\[\\]()'\", .:\\/|]+") 12 | return regex.ReplaceAllString(s, "") 13 | } 14 | 15 | func MakeSlug(s string) string { 16 | regex := regexp.MustCompile("[^a-zA-Z0-9]+") 17 | return regex.ReplaceAllString(s, "-") 18 | } 19 | -------------------------------------------------------------------------------- /models/structure.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/andrewarrow/feedback/prefix" 7 | "github.com/andrewarrow/feedback/util" 8 | ) 9 | 10 | const ISO8601 = "2006-01-02T15:04:05-07:00" 11 | const HUMAN = "Monday, January 2, 2006 3:04 PM" 12 | const HUMAN_DATE = "2006-01-02" 13 | const FULL_MONTH_DATE = "January 02, 2006" 14 | 15 | type Model struct { 16 | Name string `json:"name"` 17 | Small bool `json:"small"` 18 | Fields []*Field `json:"fields"` 19 | } 20 | 21 | func (m *Model) EnsureIdAndCreatedAt() { 22 | if m.Small { 23 | return 24 | } 25 | id := FindField(m, "id") 26 | if id == nil { 27 | f := Field{} 28 | f.Name = "id" 29 | f.Flavor = "int" 30 | m.Fields = append(m.Fields, &f) 31 | } 32 | ca := FindField(m, "created_at") 33 | if ca == nil { 34 | f := Field{} 35 | f.Name = "created_at" 36 | f.Flavor = "timestamp" 37 | f.Index = "yes" 38 | m.Fields = append(m.Fields, &f) 39 | } 40 | ua := FindField(m, "updated_at") 41 | if ua == nil { 42 | f := Field{} 43 | f.Name = "updated_at" 44 | f.Flavor = "timestamp" 45 | f.Index = "yes" 46 | m.Fields = append(m.Fields, &f) 47 | } 48 | guid := FindField(m, "guid") 49 | if guid == nil { 50 | f := Field{} 51 | f.Name = "guid" 52 | f.Flavor = "uuid" 53 | f.Index = "yes" 54 | m.Fields = append(m.Fields, &f) 55 | } 56 | } 57 | 58 | func (m *Model) TableName() string { 59 | return prefix.Tablename(util.Plural(m.Name)) 60 | } 61 | 62 | func TypeToFlavor(dt, udt, cd string) string { 63 | if dt == "bigint" || dt == "integer" || dt == "smallint" { 64 | return "int" 65 | } else if dt == "boolean" { 66 | return "bool" 67 | } else if dt == "text" { 68 | return "text" 69 | } else if dt == "real" { 70 | return "double" 71 | } else if dt == "numeric" { 72 | return "numeric" 73 | } else if udt == "timestamptz" { 74 | return "timestamp" 75 | } else if udt == "geometry" { 76 | return "geometry" 77 | } else if dt == "jsonb" && cd == "" { 78 | return "json" 79 | } else if dt == "jsonb" && strings.Contains(cd, "[]") { 80 | return "json_list" 81 | } else if dt == "jsonb" && strings.Contains(cd, "{}") { 82 | return "json" 83 | } else if strings.HasPrefix(udt, "enum_") { 84 | return udt 85 | } 86 | return "name" 87 | } 88 | -------------------------------------------------------------------------------- /network/get_to.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func GetTo(full, bearer string) (string, int) { 11 | request, err := http.NewRequest("GET", full, nil) 12 | if err != nil { 13 | return "bad url", 500 14 | } 15 | SetHeaders(bearer, request) 16 | client := &http.Client{Timeout: time.Second * 150} 17 | 18 | return DoHttpRead(client, request) 19 | } 20 | 21 | func Get200Image(full, bearer string) bool { 22 | request, err := http.NewRequest("GET", full, nil) 23 | if err != nil { 24 | fmt.Println(err) 25 | return false 26 | } 27 | request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") 28 | request.Header.Set("Accept-Encoding", "gzip, deflate, br") 29 | request.Header.Set("Accept-Language", "en-US,en;q=0.5") 30 | request.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0") 31 | client := &http.Client{Timeout: time.Second * 3} 32 | 33 | resp, err := client.Do(request) 34 | if err != nil { 35 | fmt.Println(err) 36 | return false 37 | } 38 | resp.Body.Close() 39 | contentType := strings.ToLower(resp.Header.Get("Content-Type")) 40 | //fmt.Println("contentType", contentType) 41 | imageName := strings.ToLower(full) 42 | imageInName := strings.Contains(imageName, ".jpg") || strings.Contains(imageName, ".jpeg") || strings.Contains(imageName, ".gif") || strings.Contains(imageName, ".png") || strings.Contains(imageName, ".svg") || strings.Contains(imageName, ".webp") 43 | if strings.Contains(contentType, "image") || imageInName { 44 | return resp.StatusCode == 200 45 | } 46 | return false 47 | } 48 | -------------------------------------------------------------------------------- /network/limit.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | ) 11 | 12 | func DoHttpLimitRead(client *http.Client, request *http.Request) (string, int, string) { 13 | const maxBodySize = 10 * 1024 * 3 14 | 15 | resp, err := client.Do(request) 16 | if err == nil { 17 | defer resp.Body.Close() 18 | 19 | limitReader := io.LimitReader(resp.Body, maxBodySize) 20 | body, err := io.ReadAll(limitReader) 21 | if err != nil { 22 | fmt.Printf("\n\nERROR: %d %s\n\n", resp.StatusCode, err.Error()) 23 | return err.Error(), 500, "" 24 | } 25 | 26 | contentLength := resp.Header.Get("Content-Length") 27 | return DoReadZipped(body), resp.StatusCode, contentLength 28 | } 29 | 30 | fmt.Printf("\n\nERROR: %s\n\n", err.Error()) 31 | return err.Error(), 500, "" 32 | } 33 | 34 | func DoReadZipped(asBytes []byte) string { 35 | buf := bytes.NewBuffer(asBytes) 36 | gr, err := gzip.NewReader(buf) 37 | if err != nil { 38 | //fmt.Println(err) 39 | return "" 40 | } 41 | defer gr.Close() 42 | body, err := ioutil.ReadAll(gr) 43 | if err != nil { 44 | //fmt.Println(err) 45 | return "" 46 | } 47 | return string(body) 48 | } 49 | 50 | func DoHttpZRead(client *http.Client, request *http.Request, cb func(b []byte), 51 | max int64) { 52 | resp, err := client.Do(request) 53 | if err != nil { 54 | fmt.Println(err) 55 | return 56 | } 57 | //contentLength := resp.Header.Get("Content-Length") 58 | //fmt.Println(resp.StatusCode, contentLength) 59 | defer resp.Body.Close() 60 | reader, err := gzip.NewReader(resp.Body) 61 | if err != nil { 62 | fmt.Println("Error creating Gzip reader:", err) 63 | return 64 | } 65 | defer reader.Close() 66 | 67 | chunkSize := 10 * 1024 * 3 68 | buffer := make([]byte, chunkSize) 69 | count := 0 70 | for { 71 | n, err := reader.Read(buffer) 72 | if err == io.EOF { 73 | fmt.Println("EOF", count, n) 74 | //fmt.Println(string(buffer[:n])) 75 | break 76 | } 77 | if err != nil { 78 | fmt.Println("Error reading from Gzip reader:", err) 79 | return 80 | } 81 | cb(buffer[:n]) 82 | count++ 83 | if count > 9 && max == 0 { 84 | break 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /openapi/endpoint.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Endpoint struct { 8 | LowerVerb string 9 | Method string 10 | Returns string 11 | Path string 12 | HasId bool 13 | Params []Param 14 | LastFunc string 15 | CallingFunc string 16 | } 17 | 18 | var verbs = []string{"GET", "POST", "DELETE", "PATCH", "PUT"} 19 | 20 | func NewEndpoint(comment, line, lastFunc, callingFunc string) Endpoint { 21 | ep := Endpoint{} 22 | ep.LastFunc = lastFunc 23 | ep.CallingFunc = callingFunc 24 | 25 | for _, verb := range verbs { 26 | if strings.Contains(line, verb) { 27 | ep.Method = verb 28 | ep.LowerVerb = strings.ToLower(verb) 29 | break 30 | } 31 | } 32 | 33 | tokens := strings.Split(comment, " ") 34 | ep.Returns = tokens[len(tokens)-1] 35 | 36 | tokens = strings.Split(line, "&&") 37 | tokens = tokens[0 : len(tokens)-1] 38 | 39 | buffer := []string{} 40 | for _, item := range tokens { 41 | if strings.Contains(item, "==") { 42 | tokens := strings.Split(item, "==") 43 | thing := strings.TrimSpace(tokens[1]) 44 | thing = thing[1 : len(thing)-1] 45 | if thing != "" { 46 | buffer = append(buffer, thing) 47 | } 48 | } else if strings.Contains(item, "!=") { 49 | buffer = append(buffer, "{id}") 50 | ep.HasId = true 51 | } 52 | } 53 | ep.Path = "/" + strings.Join(buffer, "/") 54 | if ep.Path == "/" { 55 | ep.Path = "" 56 | } 57 | 58 | if ep.Method == "POST" { 59 | p1 := Param{"email", "string"} 60 | p2 := Param{"latitude", "number"} 61 | ep.Params = []Param{p1, p2} 62 | } 63 | 64 | return ep 65 | } 66 | -------------------------------------------------------------------------------- /openapi/params.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Param struct { 8 | Name string 9 | Flavor string 10 | } 11 | 12 | func (oa *OpenAPI) lookForParams(name string, lines []string) { 13 | start := false 14 | lastFunc := "" 15 | for _, line := range lines { 16 | trimmed := strings.TrimSpace(line) 17 | if strings.HasPrefix(trimmed, "func ") { 18 | tokens := strings.Split(trimmed, " ") 19 | tokens = strings.Split(tokens[1], "(") 20 | lastFunc = tokens[0] 21 | } 22 | if strings.HasPrefix(trimmed, "// oa end") { 23 | start = false 24 | } 25 | if start && strings.Contains(trimmed, "c.Params") { 26 | tokens := strings.Split(trimmed, ":=") 27 | item := tokens[1][11:] 28 | tokens = strings.Split(item, `"`) 29 | p := Param{} 30 | p.Name = tokens[0] 31 | flavor := tokens[1] 32 | p.Flavor = "number" 33 | if strings.Contains(flavor, "string") { 34 | p.Flavor = "string" 35 | } else if strings.Contains(flavor, "int") { 36 | p.Flavor = "integer" 37 | } else if strings.Contains(flavor, "bool") { 38 | p.Flavor = "boolean" 39 | } else if strings.Contains(flavor, "[]any") { 40 | p.Flavor = "array" 41 | } 42 | oa.ParamsByFunc[lastFunc] = append(oa.ParamsByFunc[lastFunc], p) 43 | } 44 | if strings.HasPrefix(trimmed, "// oa start") { 45 | start = true 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /openapi/path.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/andrewarrow/feedback/router" 10 | ) 11 | 12 | func (oa *OpenAPI) AddPath(path string, fn func(*router.Context, string, string)) { 13 | v := reflect.ValueOf(fn) 14 | name := runtime.FuncForPC(v.Pointer()).Name() 15 | tokens := strings.Split(name, ".") 16 | name = tokens[len(tokens)-1] 17 | 18 | oa.FuncToPath[name] = path 19 | fmt.Println(name, path) 20 | } 21 | -------------------------------------------------------------------------------- /openapi/scan.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | type OpenAPI struct { 9 | Endpoints map[string][]Endpoint 10 | FuncToPath map[string]string 11 | ParamsByFunc map[string][]Param 12 | } 13 | 14 | func NewOpenAPI() *OpenAPI { 15 | oa := OpenAPI{} 16 | oa.Endpoints = map[string][]Endpoint{} 17 | oa.FuncToPath = map[string]string{} 18 | oa.ParamsByFunc = map[string][]Param{} 19 | return &oa 20 | } 21 | 22 | func (oa *OpenAPI) ScanDir(dir string) { 23 | entries, _ := os.ReadDir(dir) 24 | for _, entry := range entries { 25 | name := entry.Name() 26 | if strings.HasSuffix(name, ".go") == false { 27 | continue 28 | } 29 | b, _ := os.ReadFile(dir + "/" + name) 30 | s := string(b) 31 | lines := strings.Split(s, "\n") 32 | lastFunc := "" 33 | for i, line := range lines { 34 | trimmed := strings.TrimSpace(line) 35 | if strings.HasPrefix(trimmed, "func ") { 36 | tokens := strings.Split(trimmed, " ") 37 | tokens = strings.Split(tokens[1], "(") 38 | lastFunc = tokens[0] 39 | } 40 | if strings.HasPrefix(trimmed, "// oa ") == false { 41 | continue 42 | } 43 | if strings.HasPrefix(trimmed, "// oa start") == true { 44 | continue 45 | } 46 | if strings.HasPrefix(trimmed, "// oa end") == true { 47 | continue 48 | } 49 | target := lines[i+1] 50 | nextLine := lines[i+2] 51 | nextLine = strings.TrimSpace(nextLine) 52 | tokens := strings.Split(nextLine, "(") 53 | callingFunc := tokens[0] 54 | ep := NewEndpoint(trimmed, target, lastFunc, callingFunc) 55 | prefix := oa.FuncToPath[lastFunc] 56 | oa.Endpoints[prefix+ep.Path] = append(oa.Endpoints[ep.Path], ep) 57 | } 58 | oa.lookForParams(name, lines) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /openapi/yaml.go: -------------------------------------------------------------------------------- 1 | package openapi 2 | 3 | import ( 4 | "io/ioutil" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | func comparePaths(path1, path2 string) bool { 10 | tokens1 := strings.Split(path1, "/") 11 | tokens2 := strings.Split(path2, "/") 12 | 13 | for i := 0; i < len(tokens1) && i < len(tokens2); i++ { 14 | if tokens1[i] != tokens2[i] { 15 | return tokens1[i] > tokens2[i] 16 | } 17 | } 18 | 19 | return len(tokens1) < len(tokens2) 20 | } 21 | 22 | func (oa *OpenAPI) WriteYaml() { 23 | buffer := []string{} 24 | 25 | items := []string{} 26 | for k, _ := range oa.Endpoints { 27 | items = append(items, k) 28 | } 29 | sort.Slice(items, func(i, j int) bool { 30 | return comparePaths(items[i], items[j]) 31 | }) 32 | for i := len(items) - 1; i >= 0; i-- { 33 | k := items[i] 34 | v := oa.Endpoints[k] 35 | buffer = append(buffer, " /"+k+":") 36 | for _, item := range v { 37 | buffer = append(buffer, " "+item.LowerVerb+":") 38 | buffer = append(buffer, " summary: ...") 39 | if item.Method == "POST" { 40 | buffer = append(buffer, " "+post) 41 | for _, param := range oa.ParamsByFunc[item.CallingFunc] { 42 | buffer = append(buffer, " "+param.Name+":") 43 | buffer = append(buffer, " type: "+param.Flavor) 44 | } 45 | } 46 | if item.HasId { 47 | buffer = append(buffer, " parameters:") 48 | buffer = append(buffer, " - name: id") 49 | buffer = append(buffer, " in: path") 50 | buffer = append(buffer, " required: true") 51 | buffer = append(buffer, " schema:") 52 | buffer = append(buffer, " type: string") 53 | } 54 | buffer = append(buffer, " responses:") 55 | buffer = append(buffer, " '200':") 56 | buffer = append(buffer, " description: ok") 57 | buffer = append(buffer, " content:") 58 | buffer = append(buffer, " application/json:") 59 | buffer = append(buffer, " schema:") 60 | buffer = append(buffer, " type: object") 61 | buffer = append(buffer, " properties:") 62 | buffer = append(buffer, " msg:") 63 | buffer = append(buffer, " type: string") 64 | } 65 | } 66 | final := yaml + "\n" + strings.Join(buffer, "\n") 67 | 68 | ioutil.WriteFile("openapi/openapi.yaml", []byte(final), 0644) 69 | } 70 | 71 | var yaml = `openapi: 3.0.3 72 | info: 73 | title: Feedback API 74 | description: Feedback API 75 | version: 1.0.0 76 | paths:` 77 | 78 | var post = `requestBody: 79 | required: true 80 | content: 81 | application/json: 82 | schema: 83 | type: object 84 | properties:` 85 | -------------------------------------------------------------------------------- /persist/scan.go: -------------------------------------------------------------------------------- 1 | package persist 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/andrewarrow/feedback/files" 8 | "github.com/andrewarrow/feedback/models" 9 | "github.com/andrewarrow/feedback/util" 10 | "github.com/jmoiron/sqlx" 11 | ) 12 | 13 | func ScanSchema(dbString string) []*models.Model { 14 | db := PostgresConnectionByUrl(dbString) 15 | sql := `SELECT t.typname AS enum_type, e.enumlabel AS enum_value 16 | FROM pg_enum e 17 | JOIN pg_type t ON e.enumtypid = t.oid` 18 | rows := SelectAll(db, sql) 19 | for _, row := range rows { 20 | t := fmt.Sprintf("%s", row["enum_type"]) 21 | v := fmt.Sprintf("%s", row["enum_value"]) 22 | //fmt.Println(t, v) 23 | _ = t 24 | _ = v 25 | } 26 | 27 | sql = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public';" 28 | 29 | list := []*models.Model{} 30 | rows = SelectAll(db, sql) 31 | for _, row := range rows { 32 | table := fmt.Sprintf("%s", row["tablename"]) 33 | single := util.Unplural(table) 34 | m := models.Model{} 35 | m.Name = single 36 | m.Fields = ScanTable(db, table) 37 | list = append(list, &m) 38 | } 39 | 40 | return list 41 | } 42 | 43 | func ModelsForTables(db *sqlx.DB, tablesString string) []*models.Model { 44 | tokens := strings.Split(tablesString, ",") 45 | mlist := []*models.Model{} 46 | for _, table := range tokens { 47 | single := util.Unplural(table) 48 | m := models.Model{} 49 | m.Name = single 50 | m.Fields = ScanTable(db, table) 51 | mlist = append(mlist, &m) 52 | } 53 | 54 | return mlist 55 | } 56 | 57 | func ScanTable(db *sqlx.DB, table string) []*models.Field { 58 | list := []*models.Field{} 59 | sql := fmt.Sprintf("SELECT * FROM information_schema.columns WHERE table_name = '%s'", table) 60 | rows := SelectAll(db, sql) 61 | for _, row := range rows { 62 | col := fmt.Sprintf("%s", row["column_name"]) 63 | dt := fmt.Sprintf("%s", row["data_type"]) 64 | udt := fmt.Sprintf("%s", row["udt_name"]) 65 | cd := fmt.Sprintf("%s", row["column_default"]) 66 | if cd == "%!s()" { 67 | cd = "" 68 | } 69 | field := models.Field{} 70 | field.Name = col 71 | field.Flavor = models.TypeToFlavor(strings.ToLower(dt), 72 | strings.ToLower(udt), 73 | strings.ToLower(cd)) 74 | //fmt.Println(col, "|", dt, "|", udt, "|", cd) 75 | //fmt.Println(field) 76 | list = append(list, &field) 77 | } 78 | return list 79 | } 80 | 81 | func SelectAll(db *sqlx.DB, sql string) []map[string]any { 82 | ms := []map[string]any{} 83 | rows, err := db.Queryx(sql) 84 | if err != nil { 85 | return ms 86 | } 87 | defer rows.Close() 88 | for rows.Next() { 89 | m := make(map[string]any) 90 | rows.MapScan(m) 91 | ms = append(ms, m) 92 | } 93 | return ms 94 | } 95 | 96 | func SaveSchema(asBytes []byte) { 97 | fmt.Println(string(asBytes)) 98 | files.SaveFile("feedback.json", string(asBytes)) 99 | } 100 | -------------------------------------------------------------------------------- /persist/sqlite.go: -------------------------------------------------------------------------------- 1 | package persist 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | "strings" 7 | 8 | "github.com/jmoiron/sqlx" 9 | _ "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | func SqliteConnection(name string) *sqlx.DB { 13 | currentUser, _ := user.Current() 14 | prefix := currentUser.HomeDir + "/" + name 15 | if strings.HasPrefix(name, "/") { 16 | prefix = name 17 | } 18 | db, err := sqlx.Connect("sqlite3", prefix+"_sqlite_560dc8c4-b18a-4517-a90c-b0f92d2ba5a5.db") 19 | if err != nil { 20 | fmt.Println(err) 21 | return nil 22 | } 23 | return db 24 | } 25 | -------------------------------------------------------------------------------- /prefix/table.go: -------------------------------------------------------------------------------- 1 | package prefix 2 | 3 | var FeedbackName string 4 | 5 | func Tablename(table string) string { 6 | prefix := FeedbackName 7 | if prefix != "" { 8 | return prefix + "_" + table 9 | } 10 | return table 11 | } 12 | -------------------------------------------------------------------------------- /router/about_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func handleAbout(c *Context, second, third string) { 4 | if second == "" { 5 | handleAboutIndex(c) 6 | } else if third != "" { 7 | c.NotFound = true 8 | } else { 9 | c.NotFound = true 10 | } 11 | } 12 | func handleAboutIndex(c *Context) { 13 | c.SendContentInLayout("about_index.html", nil, 200) 14 | } 15 | -------------------------------------------------------------------------------- /router/admin_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func handleAdmin(c *Context, second, third string) { 4 | c.Layout = "admin_layout.html" 5 | if second == "" && third == "" && c.Method == "GET" { 6 | handleAdminIndex(c) 7 | return 8 | } 9 | if second == "users" && third == "" && c.Method == "GET" { 10 | handleAdminUsersIndex(c) 11 | return 12 | } 13 | c.NotFound = true 14 | } 15 | 16 | func handleAdminIndex(c *Context) { 17 | c.SendContentInLayout("admin_index.html", nil, 200) 18 | } 19 | 20 | func handleAdminUsersIndex(c *Context) { 21 | c.SendContentInLayout("admin_users_index.html", nil, 200) 22 | } 23 | -------------------------------------------------------------------------------- /router/ajax.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | ) 8 | 9 | func (c *Context) SendContentForAjax(doZip bool, user map[string]any, writer http.ResponseWriter, 10 | filename string, contentVars any, status int) { 11 | 12 | t := c.Router.Template.Lookup(filename) 13 | content := new(bytes.Buffer) 14 | t.Execute(content, contentVars) 15 | cb := content.Bytes() 16 | m := map[string]any{} 17 | m["html"] = string(cb) 18 | m["next"] = c.LayoutMap["ajax_next"] 19 | m["div"] = c.LayoutMap["ajax_div"] 20 | if m["div"] == nil { 21 | m["div"] = "feedback-ajax" 22 | } 23 | //fmt.Println(m["div"], m["next"]) 24 | asBytes, _ := json.Marshal(m) 25 | doZippyJson(doZip, asBytes, status, writer) 26 | } 27 | -------------------------------------------------------------------------------- /router/api_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func handleApi(c *Context, second, third string) { 4 | if second != "" && third == "" { 5 | handleApiCall(c) 6 | return 7 | } 8 | c.NotFound = true 9 | } 10 | 11 | func handleApiCall(c *Context) { 12 | m := map[string]any{} 13 | m["test"] = []string{"hi", "there"} 14 | c.SendContentAsJson(m, 200) 15 | } 16 | -------------------------------------------------------------------------------- /router/app.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "github.com/andrewarrow/feedback/files" 8 | ) 9 | 10 | func InitNewApp(path string) { 11 | 12 | place := path + "/tailwind" 13 | os.MkdirAll(place, 0755) 14 | place = path + "/app" 15 | os.MkdirAll(place, 0755) 16 | name := "feedback.json" 17 | asString := files.ReadFile(name) 18 | files.SaveFile(place+"/"+name, asString) 19 | place = path 20 | name = "tailwind.config.js" 21 | asString = files.ReadFile(name) 22 | files.SaveFile(place+"/"+name, asString) 23 | name = "extra.html" 24 | place = path + "/tailwind" 25 | asString = files.ReadFile("tailwind/" + name) 26 | files.SaveFile(place+"/"+name, asString) 27 | 28 | dirs := []string{"views", "assets/css", "assets/images", "assets/javascript"} 29 | for _, dir := range dirs { 30 | place = path + "/" + dir 31 | os.MkdirAll(place, 0755) 32 | list, _ := ioutil.ReadDir(dir) 33 | for _, file := range list { 34 | name := file.Name() 35 | if dir == "views" { 36 | if name != "_table_large.html" && name != "_table_small.html" && 37 | name != "_welcome_show_cols.html" && 38 | name != "_nav_user.html" && 39 | name != "application_layout.html" && 40 | name != "sessions_new.html" && 41 | name != "table_show.html" && 42 | name != "404.html" && 43 | name != "_editable_fields.html" && 44 | name != "generic_top_bottom.html" { 45 | continue 46 | } 47 | } 48 | path := dir + "/" + name 49 | asString := files.ReadFile(path) 50 | files.SaveFile(place+"/"+file.Name(), asString) 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /router/assets.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | var EmbeddedAssets embed.FS 11 | 12 | func (r *Router) HandleAsset(path string, writer http.ResponseWriter, request *http.Request) { 13 | contentType := "text/css" 14 | contentEncoding := "identity" 15 | if strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".json") { 16 | contentType = "application/javascript" 17 | } else if strings.HasSuffix(path, ".ico") { 18 | contentType = "image/x-icon" 19 | } else if strings.HasSuffix(path, ".gif") { 20 | contentType = "image/gif" 21 | } else if strings.HasSuffix(path, ".png") { 22 | contentType = "image/png" 23 | } else if strings.HasSuffix(path, ".jpg") { 24 | contentType = "image/jpg" 25 | } else if strings.HasSuffix(path, ".svg") { 26 | contentType = "image/svg+xml" 27 | } else if strings.HasSuffix(path, ".ttf") { 28 | contentType = "font/ttf" 29 | } else if strings.HasSuffix(path, ".woff") { 30 | contentType = "font/woff" 31 | } else if strings.HasSuffix(path, ".woff2") { 32 | contentType = "font/woff2" 33 | } else if strings.HasSuffix(path, ".m3u8") { 34 | contentType = "application/vnd.apple.mpegurl" 35 | } else if strings.HasSuffix(path, ".xml") { 36 | contentType = "text/xml" 37 | } else if strings.HasSuffix(path, ".wasm.gz") { 38 | contentEncoding = "gzip" 39 | contentType = "application/wasm" 40 | } else if strings.HasSuffix(path, ".wasm") { 41 | contentType = "application/wasm" 42 | } 43 | writer.Header().Set("Content-Type", contentType) 44 | writer.Header().Set("Connection", "keep-alive") 45 | writer.Header().Set("Content-Encoding", contentEncoding) 46 | writer.Header().Set("Cache-Control", "max-age=3600, public, must-revalidate, proxy-revalidate") 47 | // matchFile := files.ReadFile(fmt.Sprintf("%s", path[1:])) 48 | 49 | matchFile, _ := EmbeddedAssets.ReadFile(fmt.Sprintf("%s", path[1:])) 50 | 51 | writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(matchFile))) 52 | writer.Write([]byte(matchFile)) 53 | } 54 | -------------------------------------------------------------------------------- /router/before_all.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func (r *Router) beforeAllFunc(c *Context) { 4 | // do nothing, apps can replace this if they need to 5 | } 6 | -------------------------------------------------------------------------------- /router/bucket.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func (r *Router) HandleBucketAsset(path string, writer http.ResponseWriter, request *http.Request) { 11 | contentType := "application/octet-stream" 12 | if strings.HasSuffix(path, ".js") { 13 | contentType = "application/javascript" 14 | } else if strings.HasSuffix(path, ".ico") { 15 | contentType = "image/x-icon" 16 | } else if strings.HasSuffix(path, ".gif") { 17 | contentType = "image/gif" 18 | } else if strings.HasSuffix(path, ".svg") { 19 | contentType = "image/svg+xml" 20 | } else if strings.HasSuffix(path, ".pdf") { 21 | contentType = "application/pdf" 22 | } 23 | writer.Header().Set("Content-Type", contentType) 24 | writer.Header().Set("Connection", "keep-alive") 25 | writer.Header().Set("Cache-Control", "max-age=3600, public, must-revalidate, proxy-revalidate") 26 | path = path[7:] 27 | asBytes, _ := ioutil.ReadFile(r.BucketPath + path) 28 | writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(asBytes))) 29 | writer.Write(asBytes) 30 | } 31 | -------------------------------------------------------------------------------- /router/cells.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (c *Context) MakeCells(list []any, headers []string, thing any, prefix string) [][]any { 8 | cells := [][]any{} 9 | for _, row := range list { 10 | thisRow := []any{} 11 | for j, _ := range headers { 12 | name := fmt.Sprintf("%s_col%d", prefix, j+1) 13 | templateVars := map[string]any{} 14 | templateVars["row"] = row 15 | if thing != nil { 16 | templateVars["params"] = thing 17 | } 18 | cell := c.Template(name, templateVars) 19 | thisRow = append(thisRow, cell) 20 | } 21 | cells = append(cells, thisRow) 22 | } 23 | return cells 24 | } 25 | -------------------------------------------------------------------------------- /router/context.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/andrewarrow/feedback/files" 11 | "github.com/andrewarrow/feedback/models" 12 | "github.com/andrewarrow/feedback/util" 13 | "github.com/jmoiron/sqlx" 14 | ) 15 | 16 | var BuildTag string 17 | var WasmTag string 18 | 19 | type Context struct { 20 | Writer http.ResponseWriter 21 | Request *http.Request 22 | tokens []string 23 | Router *Router 24 | User map[string]any 25 | UserRequired bool 26 | path string 27 | Db *sqlx.DB 28 | Dbs []*sqlx.DB 29 | NotFound bool 30 | Method string 31 | flash string 32 | Layout string 33 | Params map[string]any 34 | Title string 35 | LayoutMap map[string]any 36 | ParamMutex sync.Mutex 37 | Client *http.Client 38 | Batch bool 39 | BatchThing any 40 | } 41 | 42 | func (c *Context) SendContentInLayout(filename string, vars any, status int) { 43 | if c.Title == "" { 44 | c.LayoutMap["title"] = c.Router.Site.Title 45 | } else { 46 | c.LayoutMap["title"] = models.RemoveMostNonAlphanumeric(c.Title) 47 | } 48 | c.LayoutMap["build"] = BuildTag 49 | //c.LayoutMap["wasm"] = WasmTag 50 | ae := c.Request.Header.Get("Accept-Encoding") 51 | gzip := false 52 | if strings.Contains(ae, "gzip") { 53 | gzip = true 54 | } 55 | if c.Request.Header.Get("Feedback-Ajax") == "true" { 56 | c.SendContentForAjax(gzip, c.User, c.Writer, filename, vars, status) 57 | return 58 | } 59 | c.Router.SendContentInLayout(gzip, c.Layout, c.LayoutMap, c.flash, c.User, c.Writer, filename, vars, status) 60 | } 61 | 62 | func (c *Context) saveSchema() { 63 | asBytes, _ := json.Marshal(c.Router.Site) 64 | jqed := util.PipeToJq(string(asBytes)) 65 | files.SaveFile("feedback.json", jqed) 66 | } 67 | 68 | func (c *Context) BodyAsString() string { 69 | buffer := new(bytes.Buffer) 70 | buffer.ReadFrom(c.Request.Body) 71 | c.Request.Body.Close() 72 | return buffer.String() 73 | } 74 | 75 | func (c *Context) ReadFormPost() { 76 | hiddenMethod := c.Request.FormValue("_method") 77 | if hiddenMethod != "" { 78 | c.Method = hiddenMethod 79 | } 80 | } 81 | 82 | func handleContext(c *Context) { 83 | tokens := c.tokens 84 | first := tokens[1] 85 | 86 | funcToRun := c.Router.pathFuncToRun(first) 87 | 88 | if funcToRun == nil { 89 | c.NotFound = true 90 | return 91 | } 92 | 93 | if len(tokens) == 3 { // /foo/ 94 | funcToRun(c, "", "") 95 | } else if len(tokens) == 4 { // /foo/bar/ 96 | funcToRun(c, tokens[2], "") 97 | } else if len(tokens) >= 5 { // /foo/bar/more/ 98 | funcToRun(c, tokens[2], tokens[3]) 99 | } else { 100 | c.NotFound = true 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /router/context_copy.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func (c *Context) CopyContext() *Context { 4 | cc := Context{} 5 | cc.Params = map[string]any{} 6 | cc.Router = c.Router 7 | cc.Db = c.Router.Db 8 | return &cc 9 | } 10 | -------------------------------------------------------------------------------- /router/context_database_count.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (c *Context) Count(modelName string, whereString string, params []any) int64 { 8 | model := c.FindModel(modelName) 9 | sql := fmt.Sprintf("SELECT count(1) as c FROM %s %s", model.TableName(), whereString) 10 | m := map[string]any{} 11 | rows, err := c.Db.Queryx(sql, params...) 12 | if err != nil { 13 | return 0 14 | } 15 | defer rows.Close() 16 | rows.Next() 17 | rows.MapScan(m) 18 | return m["c"].(int64) 19 | } 20 | 21 | func (c *Context) SendIntAsJson(wrapper string, val int64) { 22 | m := map[string]any{wrapper: val} 23 | c.SendContentAsJson(m, 200) 24 | } 25 | -------------------------------------------------------------------------------- /router/context_database_model.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func (r *Router) All(modelName string, where, offset string, params ...any) []map[string]any { 9 | return r.SelectAll(modelName, where, params, offset) 10 | } 11 | 12 | func (c *Context) All(modelName string, where, offset string, params ...any) []map[string]any { 13 | return c.SelectAll(modelName, where, params, offset) 14 | } 15 | 16 | func (c *Context) SelectAll(modelName string, where string, params []any, offset string) []map[string]any { 17 | return c.Router.SelectAll(modelName, where, params, offset) 18 | } 19 | 20 | func (r *Router) SelectAll(modelName string, where string, params []any, offset string) []map[string]any { 21 | model := r.FindModel(modelName) 22 | offsetString := "" 23 | if offset != "" { 24 | offsetInt, _ := strconv.Atoi(offset) 25 | offsetString = fmt.Sprintf("OFFSET %d", offsetInt) 26 | } 27 | sql := fmt.Sprintf("SELECT * FROM %s %s limit 30 %s", model.TableName(), where, offsetString) 28 | ms := []map[string]any{} 29 | rows, err := r.Db.Queryx(sql, params...) 30 | if err != nil { 31 | return ms 32 | } 33 | defer rows.Close() 34 | for rows.Next() { 35 | m := make(map[string]any) 36 | rows.MapScan(m) 37 | CastFields(model, m) 38 | ms = append(ms, m) 39 | } 40 | return ms 41 | } 42 | 43 | func (r *Router) One(modelName string, where string, params ...any) map[string]any { 44 | return r.SelectOne(modelName, where, params) 45 | } 46 | 47 | func (c *Context) One(modelName string, where string, params ...any) map[string]any { 48 | return c.Router.SelectOne(modelName, where, params) 49 | } 50 | 51 | func (c *Context) SelectOne(modelName string, where string, params []any) map[string]any { 52 | return c.Router.SelectOne(modelName, where, params) 53 | } 54 | 55 | func (r *Router) SelectOne(modelName string, where string, params []any) map[string]any { 56 | model := r.FindModel(modelName) 57 | sql := fmt.Sprintf("SELECT * FROM %s %s", model.TableName(), where) 58 | m := map[string]any{} 59 | rows, err := r.Db.Queryx(sql, params...) 60 | if err != nil { 61 | return m 62 | } 63 | defer rows.Close() 64 | rows.Next() 65 | rows.MapScan(m) 66 | CastFields(model, m) 67 | return m 68 | } 69 | 70 | func (c *Context) UpdateOne(modelName, setString, whereString string, params []any) error { 71 | model := c.FindModel(modelName) 72 | sql := fmt.Sprintf("update %s set %s where %s", model.TableName(), setString, whereString) 73 | 74 | _, err := c.Db.Exec(sql, params...) 75 | return err 76 | } 77 | -------------------------------------------------------------------------------- /router/context_decorate.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type MSA map[string]any 8 | type MSAS map[string][]string 9 | type MSMAB map[string]map[any]bool 10 | type MI64MSA map[int64]map[string]any 11 | 12 | func (c *Context) DecorateListWithFields(list []map[string]any, 13 | fields map[string]bool) { 14 | c.DecorateList(list) 15 | for _, item := range list { 16 | for k, v := range item { 17 | m, isMap := v.(map[string]any) 18 | if isMap == false { 19 | continue 20 | } 21 | handleMap(k, m, fields, 0) 22 | } 23 | } 24 | } 25 | 26 | func handleMap(k string, m map[string]any, fields map[string]bool, level int) { 27 | if level > 10 { 28 | return 29 | } 30 | for k, v := range m { 31 | if fields[k] == false { 32 | delete(m, k) 33 | } 34 | otherMap, isMap := v.(map[string]any) 35 | if isMap { 36 | handleMap(k, otherMap, fields, level+1) 37 | } 38 | } 39 | 40 | } 41 | 42 | func (c *Context) DecorateSingle(item map[string]any) { 43 | list := []map[string]any{item} 44 | c.DecorateList(list) 45 | } 46 | 47 | func (c *Context) DecorateList(list []map[string]any) { 48 | topLevel := c.Decorate(list) 49 | for _, modelString := range topLevel { 50 | thingList := []map[string]any{} 51 | for _, item := range list { 52 | thing := item[modelString] 53 | if thing != nil { 54 | thingList = append(thingList, thing.(map[string]any)) 55 | } 56 | } 57 | c.Decorate(thingList) 58 | } 59 | } 60 | 61 | func (c *Context) Decorate(list []map[string]any) []string { 62 | topLevel := []string{} 63 | ids := map[string]map[any]bool{} 64 | for _, item := range list { 65 | for k, v := range item { 66 | if strings.HasSuffix(k, "_id") { 67 | tokens := strings.Split(k, "_") 68 | modelString := strings.Join(tokens[0:len(tokens)-1], "_") 69 | if c.FindModel(modelString) == nil { 70 | continue 71 | } 72 | if ids[modelString] == nil { 73 | topLevel = append(topLevel, modelString) 74 | ids[modelString] = map[any]bool{} 75 | } 76 | ids[modelString][v] = true 77 | } 78 | } 79 | } 80 | itemMaps := map[string]any{} 81 | for k, v := range ids { 82 | whereInList := []any{} 83 | for kk, _ := range v { 84 | whereInList = append(whereInList, kk) 85 | } 86 | itemMaps[k] = c.WhereIn(k, whereInList) 87 | } 88 | for _, item := range list { 89 | for k, v := range item { 90 | if strings.HasSuffix(k, "_id") { 91 | tokens := strings.Split(k, "_") 92 | modelString := strings.Join(tokens[0:len(tokens)-1], "_") 93 | if itemMaps[modelString] == nil { 94 | continue 95 | } 96 | intId, _ := v.(int64) 97 | if intId == 0 { 98 | continue 99 | } 100 | lookup := itemMaps[modelString].(MI64MSA) 101 | item[modelString] = lookup[intId] 102 | } 103 | } 104 | } 105 | return topLevel 106 | } 107 | -------------------------------------------------------------------------------- /router/context_decorate_test.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDecorcate(t *testing.T) { 8 | //TODO 9 | } 10 | -------------------------------------------------------------------------------- /router/context_delete.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "fmt" 4 | 5 | func (c *Context) Delete(modelName, fieldName string, id any) { 6 | model := c.FindModel(modelName) 7 | sql := fmt.Sprintf("delete from %s where %s=$1", model.TableName(), fieldName) 8 | c.Db.Exec(sql, id) 9 | } 10 | -------------------------------------------------------------------------------- /router/context_free_form.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func (c *Context) FreeFormSelect(sql string, params ...any) []map[string]any { 9 | return c.Router.FreeFormSelect(sql, params...) 10 | } 11 | 12 | func (r *Router) FreeFormSelect(sql string, params ...any) []map[string]any { 13 | ms := []map[string]any{} 14 | rows, err := r.Db.Queryx(sql, params...) 15 | if err != nil { 16 | fmt.Println(sql, err) 17 | return ms 18 | } 19 | defer rows.Close() 20 | for rows.Next() { 21 | m := make(map[string]any) 22 | rows.MapScan(m) 23 | ms = append(ms, m) 24 | } 25 | return ms 26 | } 27 | 28 | func (c *Context) FreeFormUpdate(sql string, params ...any) error { 29 | if os.Getenv("DEBUG") == "1" { 30 | fmt.Println("sqlgen.FreeFormUpdate", sql, params) 31 | } 32 | _, err := c.Db.Exec(sql, params...) 33 | if err != nil { 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | func (r *Router) FreeFormUpdate(sql string, params ...any) error { 40 | _, err := r.Db.Exec(sql, params...) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /router/context_free_form_index.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func (c *Context) UpdateWithIndex(index int64, sql string, params ...any) error { 9 | if os.Getenv("DEBUG") == "1" { 10 | fmt.Println("UpdateWithIndex", sql, params) 11 | } 12 | _, err := c.Dbs[index].Exec(sql, params...) 13 | if err != nil { 14 | return err 15 | } 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /router/context_model.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/andrewarrow/feedback/models" 4 | 5 | func (c *Context) Model(modelName string) *models.Model { 6 | return c.Router.Site.FindModel(modelName) 7 | } 8 | 9 | func (c *Context) Models() []*models.Model { 10 | return c.Router.Site.Models 11 | } 12 | -------------------------------------------------------------------------------- /router/context_one_with_index.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "fmt" 4 | 5 | func (c *Context) OneWithIndex(index int64, modelName string, where string, params ...any) map[string]any { 6 | model := c.FindModel(modelName) 7 | sql := fmt.Sprintf("SELECT * FROM %s %s", model.TableName(), where) 8 | m := map[string]any{} 9 | rows, err := c.Dbs[index].Queryx(sql, params...) 10 | if err != nil { 11 | return m 12 | } 13 | defer rows.Close() 14 | rows.Next() 15 | rows.MapScan(m) 16 | CastFields(model, m) 17 | return m 18 | } 19 | -------------------------------------------------------------------------------- /router/context_params.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/andrewarrow/feedback/files" 10 | ) 11 | 12 | func (c *Context) ReadJsonBodyIntoParams() { 13 | c.Params = map[string]any{} 14 | body := c.BodyAsString() 15 | //fmt.Println(body) 16 | json.Unmarshal([]byte(body), &c.Params) 17 | } 18 | 19 | func (c *Context) ReadJsonBodyAsArray() []any { 20 | var list []any 21 | body := c.BodyAsString() 22 | json.Unmarshal([]byte(body), &list) 23 | return list 24 | } 25 | 26 | func (c *Context) ExecuteTemplate(filename string, vars any) { 27 | c.Router.Template.ExecuteTemplate(c.Writer, filename, vars) 28 | } 29 | 30 | func (c *Context) ReadJsonBodyIntoParamsWithLog(file string) { 31 | home := files.UserHomeDir() 32 | filename := home + "/" + file 33 | f, _ := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) 34 | 35 | c.Params = map[string]any{} 36 | body := c.BodyAsString() 37 | 38 | defer f.Close() 39 | f.WriteString(fmt.Sprintf("%d\n\n%s\n\n", time.Now().Unix(), body)) 40 | json.Unmarshal([]byte(body), &c.Params) 41 | } 42 | -------------------------------------------------------------------------------- /router/context_template.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | ) 7 | 8 | func (c *Context) Template(name string, vars any) template.HTML { 9 | t := c.Router.GetLiveOrCachedTemplate(name) 10 | if t == nil { 11 | return template.HTML("") 12 | } 13 | content := new(bytes.Buffer) 14 | t.Execute(content, vars) 15 | cs := content.String() 16 | return template.HTML(cs) 17 | } 18 | -------------------------------------------------------------------------------- /router/context_timezone.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/xeonx/timeago" 7 | ) 8 | 9 | func (c *Context) TimezoneList(list []map[string]any, 10 | field1, field2 string, tz *time.Location) { 11 | cfg := timeago.English 12 | cfg.Max = 9223372036854775807 13 | 14 | for _, thing := range list { 15 | t1 := thing[field1].(int64) 16 | t2 := thing[field2].(int64) 17 | 18 | newT1 := time.Unix(t1, 0).In(tz) 19 | newT2 := time.Unix(t2, 0).In(tz) 20 | 21 | thing[field1] = newT1.Unix() 22 | thing[field2] = newT2.Unix() 23 | 24 | thing[field1+"_human"] = newT1.Format(HUMAN) 25 | thing[field2+"_human"] = newT2.Format(HUMAN) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /router/context_with_index.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "fmt" 4 | 5 | func (c *Context) WithIndex(index int64, sql string, params ...any) []map[string]any { 6 | ms := []map[string]any{} 7 | rows, err := c.Dbs[index].Queryx(sql, params...) 8 | if err != nil { 9 | fmt.Println(sql, err) 10 | return ms 11 | } 12 | defer rows.Close() 13 | for rows.Next() { 14 | m := make(map[string]any) 15 | rows.MapScan(m) 16 | ms = append(ms, m) 17 | } 18 | return ms 19 | } 20 | -------------------------------------------------------------------------------- /router/cookies.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func SetFlash(c *Context, flash string) { 8 | cookie := http.Cookie{} 9 | cookie.Path = "/" 10 | cookie.MaxAge = 86400 * 30 11 | cookie.Name = "flash" 12 | cookie.Value = flash 13 | http.SetCookie(c.Writer, &cookie) 14 | } 15 | 16 | func SetCookie(c *Context, name, value string) { 17 | cookie := http.Cookie{} 18 | cookie.Path = "/" 19 | cookie.MaxAge = 86400 * 30 20 | cookie.Name = name 21 | cookie.Value = value 22 | http.SetCookie(c.Writer, &cookie) 23 | } 24 | 25 | func GetCookie(c *Context, name string) string { 26 | cookie, err := c.Request.Cookie(name) 27 | if err != nil { 28 | return "" 29 | } 30 | return cookie.Value 31 | } 32 | 33 | func SetUser(c *Context, guid, domain string) { 34 | cookie := http.Cookie{} 35 | cookie.Path = "/" 36 | cookie.MaxAge = 86400 * 30 37 | cookie.Name = "user_v2" 38 | cookie.Value = guid 39 | if domain != "" && domain != "localhost" { 40 | cookie.Domain = domain 41 | cookie.SameSite = http.SameSiteNoneMode 42 | cookie.Secure = true 43 | cookie.HttpOnly = true 44 | } 45 | http.SetCookie(c.Writer, &cookie) 46 | } 47 | 48 | func removeFlash(writer http.ResponseWriter) { 49 | cookie := http.Cookie{} 50 | cookie.MaxAge = 0 51 | cookie.Name = "flash" 52 | cookie.Value = "" 53 | cookie.Path = "/" 54 | http.SetCookie(writer, &cookie) 55 | } 56 | -------------------------------------------------------------------------------- /router/cors.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func SetCorsOptions(c *Context) { 4 | c.Writer.Header().Set("Allow", "GET,POST,PUT,PATCH,DELETE") 5 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 6 | c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE") 7 | c.Writer.Header().Set("Access-Control-Allow-Headers", "*") 8 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 9 | c.Writer.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' http://localhost") 10 | c.Writer.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") 11 | c.Writer.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubdomains") 12 | 13 | } 14 | 15 | func SetCors(c *Context) { 16 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 17 | c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE") 18 | c.Writer.Header().Set("Access-Control-Allow-Headers", "*") 19 | } 20 | -------------------------------------------------------------------------------- /router/editable_cols.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func GetEditableCols(c *Context, modelString string) ([]string, map[string]string) { 4 | model := c.FindModel(modelString) 5 | cols := []string{} 6 | editable := map[string]string{} 7 | for _, f := range model.Fields { 8 | if f.Flavor == "editable" { 9 | editable[f.Name] = "string" 10 | } else if f.Flavor == "select" { 11 | editable[f.Name] = "select" 12 | } else if f.Flavor == "select-multi" { 13 | editable[f.Name] = "select-multi" 14 | } 15 | cols = append(cols, f.Name) 16 | } 17 | return cols, editable 18 | } 19 | -------------------------------------------------------------------------------- /router/feedback_site.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/andrewarrow/feedback/models" 4 | 5 | type FeedbackSite struct { 6 | Footer string `json:"footer"` 7 | Title string `json:"title"` 8 | Models []*models.Model `json:"models"` 9 | Routes []*models.Route `json:"routes"` 10 | Dynamic []*models.Model `json:"dynamic"` 11 | } 12 | 13 | func (s *FeedbackSite) FindModel(id string) *models.Model { 14 | for _, m := range s.Models { 15 | if m.Name == id { 16 | return m 17 | } 18 | } 19 | 20 | return nil 21 | } 22 | 23 | func (s *FeedbackSite) FindModelOrDynamic(id string) *models.Model { 24 | m := s.FindModel(id) 25 | if m == nil { 26 | m = s.FindDynamic(id) 27 | } 28 | return m 29 | } 30 | 31 | func (s *FeedbackSite) FindDynamic(id string) *models.Model { 32 | for _, m := range s.Dynamic { 33 | if m.Name == id { 34 | return m 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func (s *FeedbackSite) FindField(model *models.Model, id string) *models.Field { 42 | for _, f := range model.Fields { 43 | if f.Name == id { 44 | return f 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /router/fields_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/andrewarrow/feedback/models" 7 | ) 8 | 9 | func handleFields(c *Context, second, third string) { 10 | c.Layout = "models_layout.html" 11 | if c.User == nil { 12 | c.UserRequired = true 13 | return 14 | } 15 | if IsAdmin(c.User) == false { 16 | c.NotFound = true 17 | return 18 | } 19 | if second != "" && third != "" && c.Method == "GET" { 20 | handleFieldsShow(c, second, third) 21 | return 22 | } 23 | if second != "" && third != "" && c.Method == "PATCH" { 24 | handleFieldsPatch(c, second, third) 25 | return 26 | } 27 | c.NotFound = true 28 | } 29 | 30 | func handleFieldsShow(c *Context, modelName, fieldName string) { 31 | model := c.FindModel(modelName) 32 | field := models.FindField(model, fieldName) 33 | m := map[string]any{"model": model, "field": field} 34 | c.SendContentInLayout("fields_show.html", m, 200) 35 | } 36 | 37 | func handleFieldsPatch(c *Context, modelName, fieldName string) { 38 | model := c.FindModel(modelName) 39 | field := models.FindField(model, fieldName) 40 | field.Flavor = c.Request.FormValue("flavor") 41 | field.Required = c.Request.FormValue("required") 42 | field.Name = c.Request.FormValue("name") 43 | field.Index = c.Request.FormValue("index") 44 | field.Regex = c.Request.FormValue("regex") 45 | field.Null = c.Request.FormValue("null") 46 | c.saveSchema() 47 | http.Redirect(c.Writer, c.Request, "/models/"+modelName, 302) 48 | } 49 | -------------------------------------------------------------------------------- /router/filelog.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/andrewarrow/feedback/files" 10 | ) 11 | 12 | var openFile *os.File 13 | 14 | func Filelog(fields ...any) { 15 | home := files.UserHomeDir() 16 | filename := home + "/feedback.log" 17 | if openFile == nil { 18 | openFile, _ = os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) 19 | } 20 | t := fmt.Sprintf("%d", time.Now().Unix()) 21 | buffer := []string{t} 22 | for _, thing := range fields { 23 | buffer = append(buffer, fmt.Sprintf("%v", thing)) 24 | } 25 | 26 | openFile.WriteString(strings.Join(buffer, " ") + "\n") 27 | } 28 | -------------------------------------------------------------------------------- /router/forms.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "strings" 7 | 8 | "github.com/andrewarrow/feedback/buckets" 9 | "github.com/andrewarrow/feedback/util" 10 | ) 11 | 12 | func (c *Context) ReadFormValuesIntoParams(list ...string) { 13 | c.Params = map[string]any{} 14 | for _, name := range list { 15 | val := strings.TrimSpace(c.Request.FormValue(name)) 16 | c.Params[name] = val 17 | } 18 | } 19 | 20 | func (c *Context) ReadMultipleFormValues(list ...string) { 21 | c.Params = map[string]any{} 22 | for _, name := range list { 23 | selectedValues := c.Request.PostForm[name] 24 | buffer := []string{} 25 | for _, item := range selectedValues { 26 | buffer = append(buffer, strings.TrimSpace(item)) 27 | } 28 | 29 | val := strings.Join(buffer, ",") 30 | c.Params[name] = val 31 | } 32 | } 33 | 34 | type UploadedFile struct { 35 | OrigName string 36 | GuidFilename string 37 | Size int64 38 | } 39 | 40 | func SaveMultiFiles(c *Context, path, newName string) []UploadedFile { 41 | list := []UploadedFile{} 42 | files := c.Request.MultipartForm.File["file"] 43 | 44 | for _, fileHeader := range files { 45 | name := fileHeader.Filename 46 | file, _ := fileHeader.Open() 47 | asBytes, _ := io.ReadAll(file) 48 | file.Close() 49 | filename := util.GuidFilename(name, newName) 50 | ioutil.WriteFile(path+"/"+filename, asBytes, 0644) 51 | c.Params["photo"] = filename 52 | up := UploadedFile{} 53 | up.OrigName = name 54 | up.GuidFilename = filename 55 | up.Size = int64(len(asBytes)) 56 | list = append(list, up) 57 | } 58 | return list 59 | } 60 | 61 | func SaveMultiFilesAws(c *Context, guid string) { 62 | list := []string{"", "_2", "_3", "_4", "_5"} 63 | for _, item := range list { 64 | files := c.Request.MultipartForm.File["file"+item] 65 | 66 | for _, fileHeader := range files { 67 | name := fileHeader.Filename 68 | file, _ := fileHeader.Open() 69 | asBytes, _ := io.ReadAll(file) 70 | file.Close() 71 | filename := util.GuidFilename(name, guid) 72 | buckets.StoreInAws(asBytes, filename) 73 | c.Params["photo"+item] = filename 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /router/functions_to_run.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | type Batch struct { 9 | TheFunc func(*Context, string, string) 10 | Context *Context 11 | Second string 12 | Third string 13 | Params string 14 | } 15 | 16 | func (c *Context) FunctionToRun(route string, user map[string]any) *Batch { 17 | b := Batch{} 18 | b.Context = &Context{} 19 | b.Context.Batch = true 20 | b.Context.Db = c.Db 21 | b.Context.Writer = NewBatchWriter(route) 22 | 23 | tokens := strings.Split(route, "?") 24 | noParams := tokens[0] 25 | if len(tokens) == 2 { 26 | b.Params = "?" + tokens[1] 27 | } 28 | request, _ := http.NewRequest("GET", "/"+b.Params, nil) 29 | b.Context.Request = request 30 | b.Context.User = user 31 | b.Context.Method = "GET" 32 | b.Context.Router = c.Router 33 | 34 | b.Context.tokens = strings.Split(noParams+"/", "/") 35 | first := b.Context.tokens[1] 36 | 37 | second := "" 38 | third := "" 39 | if len(b.Context.tokens) == 4 { 40 | second = b.Context.tokens[2] 41 | } else if len(b.Context.tokens) == 5 { 42 | second = b.Context.tokens[2] 43 | third = b.Context.tokens[3] 44 | } 45 | b.Second = second 46 | b.Third = third 47 | b.TheFunc = c.Router.pathFuncToRun(first) 48 | return &b 49 | } 50 | 51 | type BatchWriter struct { 52 | http.ResponseWriter 53 | TheHeader http.Header 54 | Route string 55 | Code int 56 | } 57 | 58 | func NewBatchWriter(route string) *BatchWriter { 59 | b := BatchWriter{} 60 | b.TheHeader = http.Header{} 61 | b.Route = route 62 | return &b 63 | } 64 | 65 | func (w *BatchWriter) WriteHeader(statusCode int) { 66 | w.Code = statusCode 67 | } 68 | 69 | func (w *BatchWriter) Header() http.Header { 70 | return w.TheHeader 71 | } 72 | 73 | func (w *BatchWriter) Write(data []byte) (int, error) { 74 | return 200, nil 75 | } 76 | -------------------------------------------------------------------------------- /router/google_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/golang-jwt/jwt/v5" 11 | 12 | "github.com/MicahParks/keyfunc/v2" 13 | "github.com/andrewarrow/feedback/network" 14 | "github.com/andrewarrow/feedback/util" 15 | ) 16 | 17 | func handleGoogle(c *Context, second, third string) { 18 | if second == "" && third == "" && c.Method == "POST" { 19 | handleGoogleRedirect(c) 20 | return 21 | } 22 | c.NotFound = true 23 | } 24 | 25 | func writeGoogleFile(file string) string { 26 | // https://developers.google.com/identity/gsi/web/guides/verify-google-id-token 27 | // cache-control: public, max-age=18702, must-revalidate, no-transform 28 | jsonString, code := network.GetTo("https://www.googleapis.com/oauth2/v3/certs", "") 29 | if code == 200 { 30 | ioutil.WriteFile(file, []byte(jsonString), 0644) 31 | return jsonString 32 | } 33 | return "" 34 | } 35 | func getGoogleCerts() string { 36 | file := "/certs/google_jwt_oauth.json" 37 | fileInfo, err := os.Stat(file) 38 | if err != nil { 39 | return writeGoogleFile(file) 40 | } 41 | lastModified := fileInfo.ModTime().Unix() 42 | if time.Now().Unix()-lastModified > 86400 { 43 | return writeGoogleFile(file) 44 | } 45 | b, _ := ioutil.ReadFile(file) 46 | return string(b) 47 | } 48 | 49 | func handleGoogleRedirect(c *Context) { 50 | googleCerts := getGoogleCerts() 51 | c.ReadFormValuesIntoParams("credential") 52 | credential := c.Params["credential"].(string) 53 | jwksJSON := json.RawMessage(googleCerts) 54 | returnPath := "/sessions/new" 55 | 56 | jwks, err := keyfunc.NewJSON(jwksJSON) 57 | if err != nil { 58 | SetFlash(c, err.Error()) 59 | http.Redirect(c.Writer, c.Request, returnPath, 302) 60 | return 61 | } 62 | token, err := jwt.Parse(credential, jwks.Keyfunc) 63 | if err != nil { 64 | SetFlash(c, err.Error()) 65 | http.Redirect(c.Writer, c.Request, returnPath, 302) 66 | return 67 | } 68 | 69 | if !token.Valid { 70 | SetFlash(c, "token not valid") 71 | http.Redirect(c.Writer, c.Request, returnPath, 302) 72 | return 73 | } 74 | 75 | claims := token.Claims.(jwt.MapClaims) 76 | c.Params["email"] = claims["email"] 77 | c.Params["username"] = claims["email"] 78 | c.Params["first_name"] = claims["given_name"] 79 | c.Params["last_name"] = claims["family_name"] 80 | c.Params["password"] = "google" 81 | c.ValidateCreate("user") 82 | m := c.Insert("user") 83 | if m != "" { 84 | delete(c.Params, "password") 85 | c.Update("user", "where email=", claims["email"]) 86 | } 87 | row := c.One("user", "where email=$1", claims["email"]) 88 | 89 | guid := util.PseudoUuid() 90 | c.Params = map[string]any{"guid": guid, "user_id": row["id"].(int64)} 91 | c.Insert("cookie_token") 92 | SetUser(c, guid, os.Getenv("COOKIE_DOMAIN")) 93 | 94 | returnPath = "/" 95 | http.Redirect(c.Writer, c.Request, returnPath, 302) 96 | } 97 | -------------------------------------------------------------------------------- /router/json.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func (c *Context) TableJson(tableName string) { 4 | send := map[string]any{} 5 | items := c.All(tableName, "order by created_at desc", "") 6 | send["items"] = items 7 | c.SendContentAsJson(send, 200) 8 | } 9 | func (c *Context) TableJsonParams(tableName, where string, params ...any) { 10 | send := map[string]any{} 11 | items := c.All(tableName, where+" order by created_at desc", "", params...) 12 | send["items"] = items 13 | c.SendContentAsJson(send, 200) 14 | } 15 | -------------------------------------------------------------------------------- /router/markup.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "io/ioutil" 5 | ) 6 | 7 | func Markup(c *Context, second, third string) { 8 | if second != "" && third == "" && c.Method == "GET" { 9 | handleMarkupShow(c, second) 10 | return 11 | } 12 | c.NotFound = true 13 | } 14 | 15 | func handleMarkupShow(c *Context, name string) { 16 | c.Router.GetLiveOrCachedTemplate("form") 17 | asBytes, _ := ioutil.ReadFile("views/" + name) 18 | contentType := "text/plain" 19 | c.Writer.Header().Set("Content-Type", contentType) 20 | c.Writer.Write(asBytes) 21 | } 22 | -------------------------------------------------------------------------------- /router/model_user.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func (c *Context) LookupUser(guid string) map[string]any { 8 | return c.Router.LookupUser(guid) 9 | } 10 | 11 | func (c *Context) LookupUserByToken(token string) map[string]any { 12 | return c.Router.LookupUserByToken(token) 13 | } 14 | 15 | func (r *Router) LookupUserByToken(token string) map[string]any { 16 | var user map[string]any 17 | ct := r.SelectOne("cookie_token", "where guid=$1", []any{token}) 18 | if len(ct) == 0 { 19 | return user 20 | } 21 | return r.SelectOne("user", "where id=$1", []any{ct["user_id"]}) 22 | } 23 | 24 | func (r *Router) LookupUser(guid string) map[string]any { 25 | if guid == "" { 26 | return nil 27 | } 28 | params := []any{guid} 29 | m := r.SelectOne("user", "where guid=$1", params) 30 | if len(m) == 0 { 31 | return nil 32 | } 33 | return m 34 | } 35 | 36 | func (c *Context) LookupUsername(username string) map[string]any { 37 | return c.Router.LookupUsername(username) 38 | } 39 | 40 | func (r *Router) LookupUsername(username string) map[string]any { 41 | if username == "" { 42 | return map[string]any{} 43 | } 44 | params := []any{username} 45 | m := r.SelectOne("user", "where username=$1", params) 46 | if len(m) == 0 { 47 | return nil 48 | } 49 | return m 50 | } 51 | 52 | func IsAdmin(user map[string]any) bool { 53 | adminUser := os.Getenv("ADMIN_USER") 54 | if adminUser == "*" { 55 | return true 56 | } 57 | return user["guid"] == adminUser 58 | } 59 | 60 | func afterCreateUser(c *Context, guid string) { 61 | } 62 | -------------------------------------------------------------------------------- /router/parse.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | func ParseNumbers(c *Context, cols []string, editable map[string]string) { 9 | for _, item := range cols { 10 | if editable[item] == "int" { 11 | c.Params[item], _ = strconv.Atoi(c.Params[item].(string)) 12 | } else if editable[item] == "float" { 13 | c.Params[item], _ = strconv.ParseFloat(c.Params[item].(string), 64) 14 | } else if editable[item] == "edit_json" { 15 | var m map[string]any 16 | err := json.Unmarshal([]byte(c.Params[item].(string)), &m) 17 | c.Params[item] = nil 18 | if err == nil { 19 | c.Params[item] = m 20 | } 21 | } 22 | } 23 | } 24 | 25 | func IsEditable(item string, editable map[string]string) bool { 26 | if editable[item] != "string" && 27 | editable[item] != "text" && 28 | editable[item] != "int" && 29 | editable[item] != "float" && 30 | editable[item] != "json" && 31 | editable[item] != "edit_json" && 32 | editable[item] != "select" && 33 | editable[item] != "select-multi" && 34 | editable[item] != "timestamp" && 35 | editable[item] != "bool" { 36 | return false 37 | } 38 | return true 39 | } 40 | -------------------------------------------------------------------------------- /router/redirect.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "net/http" 4 | 5 | func Redirect(c *Context, path string) { 6 | http.Redirect(c.Writer, c.Request, path, 302) 7 | } 8 | -------------------------------------------------------------------------------- /router/regex.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func (c *Context) RegexMap(s string) map[string]string { 4 | model := c.FindModel(s) 5 | regexMap := map[string]string{} 6 | for _, f := range model.Fields { 7 | regexMap[f.Name] = f.Regex 8 | } 9 | return regexMap 10 | } 11 | -------------------------------------------------------------------------------- /router/reset.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/andrewarrow/feedback/sqlgen" 7 | ) 8 | 9 | func (r *Router) ResetDatabase() { 10 | for _, model := range r.Site.Models { 11 | r.Db.Exec("drop table " + model.TableName()) 12 | } 13 | r.Db.Exec(fmt.Sprintf("drop table %s", sqlgen.FeedbackSchemaTable())) 14 | fmt.Println("done.") 15 | } 16 | -------------------------------------------------------------------------------- /router/search.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func AddSearchResults(c *Context, field, token string, allRows map[int64]any) { 9 | rows := c.SelectAll("user", fmt.Sprintf("where LOWER(%s) like $1", field), 10 | []any{"%" + strings.ToLower(token) + "%"}, "") 11 | for _, row := range rows { 12 | id := row["id"].(int64) 13 | allRows[id] = row 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /router/server.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | func (r *Router) ListenAndServe(port string) { 11 | http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { 12 | r.RouteFromRequest(writer, request) 13 | }) 14 | 15 | log.Fatal(http.ListenAndServe(port, nil)) 16 | } 17 | 18 | func (r *Router) ListenAndServeTLS() { 19 | http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { 20 | r.RouteFromRequest(writer, request) 21 | }) 22 | 23 | server := &http.Server{ 24 | Addr: ":443", 25 | TLSConfig: &tls.Config{ 26 | InsecureSkipVerify: true, 27 | }, 28 | } 29 | 30 | fmt.Println("/Users/aa/cert.pem", "/Users/aa/key.pem") 31 | err := server.ListenAndServeTLS("/Users/aa/cert.pem", "/Users/aa/key.pem") 32 | fmt.Println(err) 33 | select {} 34 | 35 | } 36 | -------------------------------------------------------------------------------- /router/sesssions_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/andrewarrow/feedback/util" 8 | "golang.org/x/crypto/bcrypt" 9 | ) 10 | 11 | func NotLoggedIn(c *Context) bool { 12 | if len(c.User) == 0 { 13 | path := c.Request.URL.Path 14 | SetCookie(c, "desired_path", path) 15 | http.Redirect(c.Writer, c.Request, "/"+c.Router.NotLoggedInPath, 302) 16 | return true 17 | } 18 | return false 19 | } 20 | 21 | func HandleSessions(c *Context, second, third string) { 22 | if second == "" { 23 | handleSessionsIndex(c) 24 | } else if third != "" { 25 | c.NotFound = true 26 | } else { 27 | if second == "new" && c.Method == "GET" { 28 | m := map[string]any{} 29 | m["client_id"] = os.Getenv("GOOGLE_ID") 30 | c.SendContentInLayout("sessions_new.html", m, 200) 31 | return 32 | } 33 | c.NotFound = true 34 | } 35 | } 36 | 37 | func handleSessionsIndex(c *Context) { 38 | if c.Method == "DELETE" { 39 | DestroySession(c) 40 | } else if c.Method == "POST" { 41 | CreateSession(c) 42 | } else { 43 | c.NotFound = true 44 | } 45 | } 46 | 47 | func checkPasswordHash(password, hash string) bool { 48 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 49 | return err == nil 50 | } 51 | 52 | func CreateSession(c *Context) { 53 | username := c.Request.FormValue("username") 54 | password := c.Request.FormValue("password") 55 | row := c.SelectOne("user", "where username=$1", []any{username}) 56 | 57 | returnPath := "/" 58 | cookie := http.Cookie{} 59 | cookie.Path = "/" 60 | if len(row) > 0 && checkPasswordHash(password, row["password"].(string)) { 61 | 62 | guid := util.PseudoUuid() 63 | c.Params = map[string]any{"guid": guid, "user_id": row["id"].(int64)} 64 | c.Insert("cookie_token") 65 | SetUser(c, guid, os.Getenv("COOKIE_DOMAIN")) 66 | } else { 67 | cookie.MaxAge = 86400 * 30 68 | cookie.Name = "flash" 69 | cookie.Value = "username not found." 70 | returnPath = "/sessions/new" 71 | } 72 | http.SetCookie(c.Writer, &cookie) 73 | http.Redirect(c.Writer, c.Request, returnPath, 302) 74 | } 75 | func HandleCreateSessionAutoForm(c *Context) { 76 | c.ReadJsonBodyIntoParams() 77 | email, _ := c.Params["email"].(string) 78 | password, _ := c.Params["password"].(string) 79 | row := c.One("user", "where email=$1", email) 80 | if len(row) > 0 && checkPasswordHash(password, row["password"].(string)) { 81 | 82 | guid := util.PseudoUuid() 83 | c.Params = map[string]any{"guid": guid, "user_id": row["id"]} 84 | c.Insert("cookie_token") 85 | SetUser(c, guid, os.Getenv("COOKIE_DOMAIN")) 86 | c.SendContentAsJson("ok", 200) 87 | return 88 | } 89 | send := map[string]any{"error": "invalid login"} 90 | c.SendContentAsJson(send, 422) 91 | } 92 | 93 | func DestroySession(c *Context) { 94 | id := c.User["id"].(int64) 95 | c.Delete("cookie_token", "user_id", id) 96 | 97 | cookie := http.Cookie{} 98 | cookie.MaxAge = 0 99 | cookie.Name = "user_v2" 100 | cookie.Value = "" 101 | cookie.Path = "/" 102 | http.SetCookie(c.Writer, &cookie) 103 | http.Redirect(c.Writer, c.Request, "/", 302) 104 | } 105 | -------------------------------------------------------------------------------- /router/stats_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/andrewarrow/feedback/stats" 4 | 5 | func handleStats(c *Context, second, third string) { 6 | c.Layout = "models_layout.html" 7 | if c.User == nil { 8 | c.UserRequired = true 9 | return 10 | } 11 | if IsAdmin(c.User) == false { 12 | c.NotFound = true 13 | return 14 | } 15 | if second == "" { 16 | handleStatsIndex(c) 17 | return 18 | } 19 | c.NotFound = true 20 | } 21 | 22 | func handleStatsIndex(c *Context) { 23 | c.SendContentInLayout("stats_index.html", stats.Hits, 200) 24 | } 25 | -------------------------------------------------------------------------------- /router/tables.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/andrewarrow/feedback/models" 10 | "github.com/andrewarrow/feedback/sqlgen" 11 | "github.com/andrewarrow/feedback/util" 12 | "github.com/jmoiron/sqlx" 13 | ) 14 | 15 | func MakeTables(db *sqlx.DB, models []*models.Model) { 16 | for _, model := range models { 17 | MakeTable(db, model) 18 | } 19 | } 20 | 21 | func MakeTable(db *sqlx.DB, model *models.Model) { 22 | tableName := model.TableName() 23 | //c.Db.Exec(sqlgen.MysqlCreateTable(tableName)) 24 | //_, msg := db.Exec(sqlgen.PgCreateTable(tableName)) 25 | if DB_FLAVOR == "pg" { 26 | db.Exec(sqlgen.PgCreateTable(tableName, model.Small)) 27 | sql := `ALTER TABLE %s ADD COLUMN %s %s default %s;` 28 | for _, field := range model.Fields { 29 | flavor, defaultString := field.SqlTypeAndDefault() 30 | if os.Getenv("DEBUG") == "1" { 31 | s := fmt.Sprintf(sql, tableName, field.Name, flavor, defaultString) 32 | _, msg := db.Exec(s) 33 | fmt.Println(s, msg, flavor) 34 | } else { 35 | db.Exec(fmt.Sprintf(sql, tableName, field.Name, flavor, defaultString)) 36 | } 37 | } 38 | for _, field := range model.Fields { 39 | if field.Index == "yes" { 40 | sql := `create index CONCURRENTLY %s_%s_index on %s(%s);` 41 | db.Exec(fmt.Sprintf(sql, tableName, field.Name, tableName, field.Name)) 42 | } else if field.Index == "unique" { 43 | sql := `create unique index %s_%s_index on %s(%s);` 44 | db.Exec(fmt.Sprintf(sql, tableName, field.Name, tableName, field.Name)) 45 | } else if strings.HasPrefix(field.Index, "unique_two") { 46 | tokens := strings.Split(field.Index, ":") 47 | fields := strings.Split(tokens[1], ",") 48 | field1 := fields[0] 49 | field2 := fields[1] 50 | sql := `create unique index %s_%s_%s_index on %s(%s,%s);` 51 | s := fmt.Sprintf(sql, tableName, field1, field2, tableName, field1, field2) 52 | db.Exec(s) 53 | } 54 | } 55 | } else { 56 | db.Exec(sqlgen.SqliteCreateTable(tableName, model.Small)) 57 | items := sqlgen.SqliteAlterTable(tableName, model) 58 | for _, item := range items { 59 | db.Exec(item) 60 | } 61 | for _, field := range model.Fields { 62 | if field.Index == "yes" { 63 | sql := `CREATE INDEX IF NOT EXISTS %s_%s_index ON %s (%s);` 64 | db.Exec(fmt.Sprintf(sql, tableName, field.Name, tableName, field.Name)) 65 | } else if field.Index == "unique" { 66 | sql := `CREATE UNIQUE INDEX IF NOT EXISTS %s_%s_index ON %s (%s);` 67 | db.Exec(fmt.Sprintf(sql, tableName, field.Name, tableName, field.Name)) 68 | } 69 | } 70 | } 71 | 72 | } 73 | 74 | func ModelsToBytes(list []*models.Model) []byte { 75 | site := FeedbackSite{} 76 | site.Models = list 77 | asBytes, _ := json.Marshal(site) 78 | return asBytes 79 | } 80 | 81 | func MakeGuidsInTables(db *sqlx.DB, models []*models.Model) { 82 | for _, model := range models { 83 | MakeGuidsInTable(db, model) 84 | } 85 | } 86 | 87 | func MakeGuidsInTable(db *sqlx.DB, model *models.Model) { 88 | tableName := model.TableName() 89 | sql := `update %s set guid=$1 where id=$2;` 90 | for i := 1; i < 1000; i++ { 91 | guid := util.PseudoUuid() 92 | s := fmt.Sprintf(sql, tableName) 93 | fmt.Println(s, guid, i) 94 | db.Exec(s, guid, i) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /router/upsert.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/andrewarrow/feedback/sqlgen" 4 | 5 | func (c *Context) Upsert(modelString, where string, lastParam any) string { 6 | model := c.FindModel(modelString) 7 | tableName := model.TableName() 8 | sql, params := sqlgen.InsertRowNoRandomDefaults(DB_FLAVOR, tableName, model.Fields, c.Params) 9 | _, err := c.Db.Exec(sql, params...) 10 | if err != nil { 11 | return c.Update(modelString, where, lastParam) 12 | } 13 | return "" 14 | } 15 | -------------------------------------------------------------------------------- /router/validate.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/andrewarrow/feedback/models" 10 | "github.com/andrewarrow/feedback/util" 11 | ) 12 | 13 | func (c *Context) ValidateOneField(modelString, fieldString, value string) bool { 14 | model := c.FindModel(modelString) 15 | field := models.FindField(model, fieldString) 16 | if field.Regex == "" { 17 | return true 18 | } 19 | re := regexp.MustCompile(field.Regex) 20 | if !re.MatchString(value) { 21 | return false 22 | } 23 | return true 24 | } 25 | 26 | func (c *Context) ValidateCreate(modelString string) string { 27 | model := c.FindModel(modelString) 28 | return c.Validate(true, model.Fields) 29 | } 30 | 31 | func (c *Context) ValidateUpdate(modelString string) string { 32 | model := c.FindModel(modelString) 33 | list := []*models.Field{} 34 | for _, field := range model.Fields { 35 | if c.Params[field.Name] == nil { 36 | continue 37 | } 38 | list = append(list, field) 39 | } 40 | return c.Validate(false, list) 41 | } 42 | 43 | func (c *Context) Validate(create bool, fields []*models.Field) string { 44 | 45 | for _, field := range fields { 46 | if field.Flavor != "timestamp" { 47 | continue 48 | } 49 | if c.Params[field.Name] != nil { 50 | var t time.Time 51 | stringTime, ok := c.Params[field.Name].(string) 52 | if ok { 53 | intTime, _ := strconv.ParseInt(stringTime, 10, 64) 54 | t = time.Unix(intTime, 0) 55 | } else { 56 | floatVal, ok := c.Params[field.Name].(float64) 57 | if ok { 58 | t = time.Unix(int64(floatVal), 0) 59 | } else { 60 | intVal, ok := c.Params[field.Name].(int64) 61 | if ok { 62 | t = time.Unix(intVal, 0) 63 | } else { 64 | t = c.Params[field.Name].(time.Time) 65 | } 66 | } 67 | } 68 | c.Params[field.Name] = t 69 | } 70 | } 71 | 72 | for _, field := range fields { 73 | if field.Required == "yes" { 74 | if c.Params[field.Name] == nil { 75 | return "missing " + field.Name 76 | } 77 | } else if strings.HasPrefix(field.Required, "if") { 78 | tokens := strings.Split(field.Required, " ") 79 | value := tokens[1] 80 | if strings.HasPrefix(value, "!") { 81 | value = value[1:] 82 | if c.Params[value] == nil && c.Params[field.Name] == nil { 83 | return "missing " + value + " or " + field.Name 84 | } 85 | } 86 | } 87 | } 88 | 89 | for _, field := range fields { 90 | if field.Regex == "" { 91 | continue 92 | } 93 | if field.Null == "yes" && c.Params[field.Name] == nil { 94 | continue 95 | } 96 | 97 | if c.Params[field.Name] == nil { 98 | continue 99 | } 100 | 101 | val := c.Params[field.Name].(string) 102 | 103 | re := regexp.MustCompile(field.Regex) 104 | if !re.MatchString(val) { 105 | return "wrong format " + field.Name 106 | } 107 | } 108 | 109 | if create { 110 | guid := util.PseudoUuid() 111 | c.Params["guid"] = guid 112 | } 113 | 114 | return "" 115 | } 116 | -------------------------------------------------------------------------------- /router/viewport.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "html/template" 4 | 5 | // viewport := r.Header.Get("Viewport") 6 | 7 | var viewport = template.HTML( 8 | ` 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | `) 17 | -------------------------------------------------------------------------------- /router/wasm.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | ) 7 | 8 | func makeScript(s string) template.HTML { 9 | script := `` 10 | return template.HTML(fmt.Sprintf(script, s)) 11 | } 12 | 13 | func MakeWasmScript(tag, s string) template.HTML { 14 | t := fmt.Sprintf(wasmScript, tag, s) 15 | return makeScript(t) 16 | } 17 | 18 | var wasmScript = `document.addEventListener("DOMContentLoaded", function() { 19 | const go = new Go(); 20 | WebAssembly.instantiateStreaming(fetch("/assets/other/json.wasm.gz?id=%s"), go.importObject).then((result) => { 21 | go.run(result.instance); 22 | WasmReady('%s'); 23 | }); 24 | });` 25 | 26 | func MakeWasmScript2(tag, s string) template.HTML { 27 | t := fmt.Sprintf(wasmScript2, tag, s) 28 | return makeScript(t) 29 | } 30 | 31 | var wasmScript2 = `document.addEventListener("DOMContentLoaded", function() { 32 | const go = new Go(); 33 | WebAssembly.instantiateStreaming(fetch("/real-estate-agents/json.wasm.gz?id=%s"), go.importObject).then((result) => { 34 | go.run(result.instance); 35 | WasmReady('%s'); 36 | }); 37 | });` 38 | -------------------------------------------------------------------------------- /router/welcome_controller.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func handleWelcome(c *Context, second, third string) { 9 | if second == "" && third == "" && c.Method == "GET" { 10 | handleWelcomeIndex(c) 11 | return 12 | } 13 | c.NotFound = true 14 | } 15 | 16 | func handleWelcomeIndex(c *Context) { 17 | list := getData() 18 | 19 | colAttributes := map[int]string{} 20 | colAttributes[0] = "w-1/2" 21 | 22 | m := map[string]any{} 23 | headers := []string{"name", "age", "species", "home_planet", "language", "occupation"} 24 | 25 | params := map[string]any{} 26 | m["headers"] = headers 27 | m["cells"] = c.MakeCells(list, headers, params, "_welcome") 28 | m["col_attributes"] = colAttributes 29 | 30 | send := map[string]any{} 31 | send["bottom"] = c.Template("table_show.html", m) 32 | c.SendContentInLayout("generic_top_bottom.html", send, 200) 33 | } 34 | 35 | func getData() []any { 36 | data := ` 37 | { 38 | "aliens": [ 39 | { 40 | "name": "Zog", 41 | "age": 150, 42 | "species": "Xenon", 43 | "home_planet": "Zeta-7", 44 | "language": "Zorgon", 45 | "occupation": "Astrobiologist" 46 | }, 47 | { 48 | "name": "Luna", 49 | "age": 200, 50 | "species": "Lunarian", 51 | "home_planet": "Moon", 52 | "language": "Lunar", 53 | "occupation": "Quantum Physicist" 54 | }, 55 | { 56 | "name": "Glimmer", 57 | "age": 75, 58 | "species": "Nebulite", 59 | "home_planet": "Nebula-9", 60 | "language": "Stellar", 61 | "occupation": "Astroengineer" 62 | }, 63 | { 64 | "name": "Xylon", 65 | "age": 300, 66 | "species": "Celestial", 67 | "home_planet": "Alpha Centauri", 68 | "language": "Cosmic", 69 | "occupation": "Interstellar Diplomat" 70 | }, 71 | { 72 | "name": "Astra", 73 | "age": 120, 74 | "species": "Stardust", 75 | "home_planet": "Polaris", 76 | "language": "Celestial", 77 | "occupation": "Astroarchaeologist" 78 | } 79 | ] 80 | } 81 | ` 82 | 83 | var result map[string]any 84 | err := json.Unmarshal([]byte(data), &result) 85 | if err != nil { 86 | fmt.Println("Error:", err) 87 | return nil 88 | } 89 | 90 | return result["aliens"].([]any) 91 | 92 | } 93 | -------------------------------------------------------------------------------- /router/wrangle.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | func (c *Context) Wrangle(s *FeedbackSite) *Context { 4 | r := Router{} 5 | r.Site = s 6 | r.Db = c.Router.WrangleDb 7 | newContext := r.ToContext() 8 | newContext.Db = c.Router.WrangleDb 9 | return newContext 10 | } 11 | -------------------------------------------------------------------------------- /router/zippy.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "fmt" 7 | "html/template" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/andrewarrow/feedback/markup" 14 | ) 15 | 16 | var UseLiveTemplates = os.Getenv("USE_LIVE_TEMPLATES") == "true" 17 | 18 | func RenderMarkup() { 19 | list, _ := ioutil.ReadDir("markup") 20 | for _, file := range list { 21 | name := file.Name() 22 | //fmt.Println("*", name) 23 | tokens := strings.Split(name, ".") 24 | if tokens[0] == "" { 25 | continue 26 | } 27 | send := map[string]any{} 28 | rendered := markup.ToHTML(send, "markup/"+name) 29 | //fmt.Println(rendered) 30 | ioutil.WriteFile("views/"+tokens[0]+".html", []byte(rendered), 0644) 31 | } 32 | } 33 | 34 | func (r *Router) GetLiveOrCachedTemplate(name string) *template.Template { 35 | var t *template.Template 36 | if UseLiveTemplates { 37 | RenderMarkup() 38 | live := LoadLiveTemplates(*CustomFuncMap) 39 | t = live.Lookup(name) 40 | } else { 41 | t = r.Template.Lookup(name) 42 | } 43 | return t 44 | } 45 | 46 | func (r *Router) sendZippy(doZip bool, name string, vars any, writer http.ResponseWriter, status int) { 47 | t := r.GetLiveOrCachedTemplate(name) 48 | content := new(bytes.Buffer) 49 | err := t.Execute(content, vars) 50 | if err != nil { 51 | fmt.Println(err) 52 | } 53 | cb := content.Bytes() 54 | 55 | if doZip { 56 | writer.Header().Set("Content-Encoding", "gzip") 57 | 58 | var compressedData bytes.Buffer 59 | gzipWriter := gzip.NewWriter(&compressedData) 60 | gzipWriter.Write(cb) 61 | gzipWriter.Close() 62 | 63 | cb = compressedData.Bytes() 64 | } 65 | 66 | if name == "sitemap_layout.html" { 67 | writer.Header().Set("Content-Type", "application/xml") 68 | } else { 69 | writer.Header().Set("Content-Type", "text/html") 70 | } 71 | writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(cb))) 72 | writer.WriteHeader(status) 73 | writer.Write(cb) 74 | } 75 | 76 | func doZippyJson(doZip bool, asBytes []byte, status int, writer http.ResponseWriter) { 77 | if doZip { 78 | writer.Header().Set("Content-Encoding", "gzip") 79 | 80 | var compressedData bytes.Buffer 81 | gzipWriter := gzip.NewWriter(&compressedData) 82 | gzipWriter.Write(asBytes) 83 | gzipWriter.Close() 84 | 85 | asBytes = compressedData.Bytes() 86 | } 87 | 88 | writer.Header().Set("Content-Type", "application/json") 89 | writer.Header().Set("Cache-Control", "none") 90 | writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(asBytes))) 91 | writer.WriteHeader(status) 92 | writer.Write(asBytes) 93 | } 94 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | # https://tailwindcss.com/blog/standalone-cli 2 | tailwindcss -i assets/css/tail.components.css -o assets/css/tail.min.css --minify 3 | uuid=$(uuidgen); go build -ldflags="-X main.buildTag=$uuid" 4 | ./feedback run feedback.json 5 | -------------------------------------------------------------------------------- /sqlgen/geometry.go: -------------------------------------------------------------------------------- 1 | package sqlgen 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | //"github.com/twpayne/cockroach/geo" 9 | 10 | "github.com/twpayne/go-geom" 11 | "github.com/twpayne/go-geom/encoding/ewkbhex" 12 | ) 13 | 14 | func CreatePointHexRepresentation(longitude, latitude float64) string { 15 | 16 | point := geom.NewPoint(geom.XY).MustSetCoords([]float64{longitude, latitude}).SetSRID(4326) 17 | 18 | hex, _ := ewkbhex.Encode(point, ewkbhex.NDR) 19 | return hex 20 | } 21 | 22 | func HexToPoint(s string) (float64, float64) { 23 | decoded, _ := ewkbhex.Decode(s) 24 | txt := fmt.Sprintf("%v", decoded) 25 | first := strings.Index(txt, "[") 26 | prefix := txt[first+1:] 27 | last := strings.Index(prefix, "]") 28 | if last == -1 { 29 | return 0, 0 30 | } 31 | tokens := strings.Split(prefix[0:last], " ") 32 | lonFloat, _ := strconv.ParseFloat(tokens[0], 64) 33 | latFloat, _ := strconv.ParseFloat(tokens[1], 64) 34 | return latFloat, lonFloat 35 | } 36 | -------------------------------------------------------------------------------- /sqlgen/mysql.go: -------------------------------------------------------------------------------- 1 | package sqlgen 2 | 3 | import "fmt" 4 | 5 | func MysqlCreateTable(tableName string) string { 6 | sql := `CREATE TABLE %s ( 7 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 8 | username varchar(255), 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 10 | UNIQUE KEY unique_username (username) 11 | ) ENGINE InnoDB;` 12 | return fmt.Sprintf(sql, tableName) 13 | } 14 | -------------------------------------------------------------------------------- /sqlgen/pg.go: -------------------------------------------------------------------------------- 1 | package sqlgen 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/andrewarrow/feedback/prefix" 7 | ) 8 | 9 | func PgCreateTable(tableName string, small bool) string { 10 | sql := `CREATE TABLE %s ( 11 | id SERIAL PRIMARY KEY, 12 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 13 | updated_at TIMESTAMP NOT NULL DEFAULT NOW() 14 | );` 15 | if small { 16 | sql = `CREATE TABLE %s ();` 17 | } 18 | return fmt.Sprintf(sql, tableName) 19 | } 20 | 21 | func FeedbackSchemaTable() string { 22 | return prefix.Tablename("feedback_schema") 23 | } 24 | 25 | func PgCreateSchemaTable() string { 26 | sql := `CREATE TABLE %s ( 27 | id SERIAL PRIMARY KEY, 28 | json_string text 29 | );` 30 | return fmt.Sprintf(sql, FeedbackSchemaTable()) 31 | } 32 | -------------------------------------------------------------------------------- /sqlgen/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlgen 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/andrewarrow/feedback/models" 7 | ) 8 | 9 | func SqliteCreateTable(tableName string, small bool) string { 10 | sql := `CREATE TABLE %s ( 11 | id INTEGER PRIMARY KEY, 12 | guid TEXT NOT NULL, 13 | created_at datetime CURRENT_TIMESTAMP, 14 | updated_at datetime CURRENT_TIMESTAMP 15 | );` 16 | if small { 17 | sql = `CREATE TABLE %s (id int);` 18 | } 19 | return fmt.Sprintf(sql, tableName) 20 | } 21 | 22 | func SqliteAlterTable(tableName string, model *models.Model) []string { 23 | sql := `ALTER TABLE %s ADD COLUMN %s %s default %s;` 24 | items := []string{} 25 | for _, field := range model.Fields { 26 | flavor, defaultString := SqlTypeAndDefault(field) 27 | a := fmt.Sprintf(sql, tableName, field.Name, flavor, defaultString) 28 | items = append(items, a) 29 | } 30 | return items 31 | } 32 | 33 | func SqlTypeAndDefault(f *models.Field) (string, string) { 34 | flavor := "TEXT" 35 | defaultString := "''" 36 | if f.Flavor == "int" { 37 | flavor = "INTEGER" 38 | defaultString = "0" 39 | } else if f.Flavor == "text" { 40 | flavor = "TEXT" 41 | } else if f.Flavor == "timestamp" { 42 | flavor = "timestamp" 43 | defaultString = "CURRENT_TIMESTAMP" 44 | } 45 | if f.Null == "yes" { 46 | defaultString = "NULL" 47 | } 48 | return flavor, defaultString 49 | } 50 | -------------------------------------------------------------------------------- /stats/memory.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "time" 7 | 8 | "github.com/andrewarrow/feedback/util" 9 | ) 10 | 11 | type Hit struct { 12 | Remote string 13 | Agent string 14 | Path string 15 | Referer string // keep the mis-spelling going Referrer 16 | Timestamp time.Time 17 | } 18 | 19 | var hitMutex sync.Mutex 20 | var Hits = []*Hit{} 21 | 22 | func AddHit(path string, request *http.Request) { 23 | h := Hit{} 24 | h.Agent = util.GetHeader("User-Agent", request) 25 | h.Path = path 26 | h.Remote = getRealIp(request) 27 | h.Timestamp = time.Now() 28 | h.Referer = util.GetHeader("Referer", request) 29 | if h.Referer == "" { 30 | h.Referer = util.GetHeader("HTTP_REFERER", request) 31 | } 32 | hitMutex.Lock() 33 | Hits = append([]*Hit{&h}, Hits...) 34 | if len(Hits) > 100 { 35 | Hits = Hits[0:99] 36 | } 37 | hitMutex.Unlock() 38 | } 39 | 40 | func getRealIp(request *http.Request) string { 41 | ip := util.GetHeader("X-Forwarded-For", request) 42 | if ip != "" { 43 | return ip 44 | } 45 | ip = util.GetHeader("X-Real-IP", request) 46 | if ip != "" { 47 | return ip 48 | } 49 | ip = util.GetHeader("Forwarded", request) 50 | if ip != "" { 51 | return ip 52 | } 53 | ip = util.GetHeader("Via", request) 54 | if ip != "" { 55 | return ip 56 | } 57 | return request.RemoteAddr 58 | } 59 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['views/*.html','tailwind/*.html',], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'cream': '#EFDECD', 8 | 'lime': '#8FBC8F' 9 | }, 10 | }, 11 | }, 12 | plugins: [], 13 | } 14 | -------------------------------------------------------------------------------- /tailwind/README.md: -------------------------------------------------------------------------------- 1 | # How to use 2 | 3 | ``` 4 | brew install npm 5 | npm install -D tailwindcss 6 | npx tailwindcss -i example.css -o ../assets/css/tail.min.css --minify 7 | ``` 8 | 9 | From [https://www.codeinwp.com/blog/tailwind-css-tutorial/](https://www.codeinwp.com/blog/tailwind-css-tutorial/) 10 | 11 | 12 | https://tailwindcss.com/blog/standalone-cli 13 | 14 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64 15 | curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 16 | 17 | chmod +x tailwindcss-macos-arm64 18 | mv tailwindcss-macos-arm64 tailwindcss 19 | -------------------------------------------------------------------------------- /tailwind/example.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .btn-main { 7 | @apply bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tailwind/extra.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /tailwind/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['../views/*.html',], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /tool/controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "strings" 8 | "text/template" 9 | 10 | "github.com/andrewarrow/feedback/util" 11 | ) 12 | 13 | func controller(path, name string) { 14 | fmt.Println(path) 15 | 16 | lower := strings.ToLower(name) 17 | withS := util.Plural(lower) 18 | 19 | m := map[string]string{"name": name, 20 | "lower": lower, 21 | "with_s": withS, 22 | } 23 | tmpl, _ := template.New("").Parse(controllerTemplate()) 24 | result := bytes.NewBuffer([]byte{}) 25 | tmpl.Execute(result, m) 26 | filename := lower + "_controller.go" 27 | ioutil.WriteFile(path+"/app/"+filename, result.Bytes(), 0644) 28 | 29 | tmpl, _ = template.New("").Parse(createTemplate()) 30 | result = bytes.NewBuffer([]byte{}) 31 | tmpl.Execute(result, m) 32 | filename = lower + "_create.go" 33 | ioutil.WriteFile(path+"/app/"+filename, result.Bytes(), 0644) 34 | 35 | tmpl, _ = template.New("").Parse(showTemplate()) 36 | result = bytes.NewBuffer([]byte{}) 37 | tmpl.Execute(result, m) 38 | filename = lower + "_show.go" 39 | ioutil.WriteFile(path+"/app/"+filename, result.Bytes(), 0644) 40 | 41 | /* 42 | tmpl, _ = template.New("").Parse(topTemplate()) 43 | result = bytes.NewBuffer([]byte{}) 44 | tmpl.Execute(result, m) 45 | filename = withS + "_top.html" 46 | ioutil.WriteFile(path+"/views/"+filename, result.Bytes(), 0644) 47 | 48 | tmpl, _ = template.New("").Parse(listTopTemplate()) 49 | result = bytes.NewBuffer([]byte{}) 50 | tmpl.Execute(result, m) 51 | filename = withS + "_list_top.html" 52 | ioutil.WriteFile(path+"/views/"+filename, result.Bytes(), 0644) 53 | 54 | tmpl, err := template.New("").Parse(colsTemplate()) 55 | fmt.Println(err) 56 | result = bytes.NewBuffer([]byte{}) 57 | tmpl.Execute(result, m) 58 | filename = "_" + lower + "_cols.html" 59 | ioutil.WriteFile(path+"/views/"+filename, result.Bytes(), 0644) 60 | 61 | tmpl, _ = template.New("").Parse(showColsTemplate()) 62 | result = bytes.NewBuffer([]byte{}) 63 | tmpl.Execute(result, m) 64 | filename = "_" + lower + "_show_cols.html" 65 | ioutil.WriteFile(path+"/views/"+filename, result.Bytes(), 0644) 66 | */ 67 | } 68 | -------------------------------------------------------------------------------- /tool/editable.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | 8 | "github.com/andrewarrow/feedback/router" 9 | ) 10 | 11 | func editable(path, name string) { 12 | asBytes, _ := ioutil.ReadFile(path + "/app/feedback.json") 13 | var site router.FeedbackSite 14 | json.Unmarshal(asBytes, &site) 15 | m := site.FindModel(name) 16 | 17 | fmt.Println(``) 18 | fmt.Println(`{{$row := index . "item"}}`) 19 | for _, field := range m.Fields { 20 | fmt.Println("") 21 | fmt.Println(`{{$value := index $row "` + field.Name + `"}}`) 22 | if field.Flavor == "photo" { 23 | t := ` 24 | 34 | ` 35 | fmt.Printf(t, field.Name) 36 | } else { 37 | t := ` 38 | 42 | ` 43 | fmt.Printf(t, field.Name, field.Name) 44 | } 45 | fmt.Println("") 46 | } 47 | fmt.Println("
25 | %s
26 | {{ if eq $value ""}} 27 | 28 | {{ else }} 29 | 30 | {{ end }} 31 |
32 | 33 |
39 | %s
40 | {{ textfield "%s" $value}} 41 |
") 48 | } 49 | -------------------------------------------------------------------------------- /tool/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "os" 9 | "strings" 10 | "text/template" 11 | "time" 12 | 13 | "github.com/andrewarrow/feedback/util" 14 | ) 15 | 16 | func main() { 17 | rand.Seed(time.Now().UnixNano()) 18 | if len(os.Args) == 1 { 19 | return 20 | } 21 | arg := os.Args[1] 22 | 23 | if arg == "init" { 24 | initApp(os.Args[2], os.Args[3]) 25 | } else if arg == "controller" { 26 | controller(os.Args[2], os.Args[3]) 27 | } else if arg == "table" { 28 | table(os.Args[2], os.Args[3]) 29 | } else if arg == "editable" { 30 | editable(os.Args[2], os.Args[3]) 31 | } else if arg == "list" { 32 | list(os.Args[2], os.Args[3]) 33 | } else if arg == "" { 34 | } 35 | } 36 | 37 | func initApp(path, name string) { 38 | fmt.Println(path) 39 | os.Mkdir(path+"/app", 0775) 40 | os.Mkdir(path+"/views", 0775) 41 | os.Mkdir(path+"/assets", 0775) 42 | 43 | lower := strings.ToLower(name) 44 | withS := util.Plural(lower) 45 | 46 | m := map[string]string{"name": name, 47 | "lower": lower, 48 | "with_s": withS, 49 | } 50 | 51 | tmpl, _ := template.New("").Parse(mainTemplate()) 52 | m["package"] = lower 53 | result := bytes.NewBuffer([]byte{}) 54 | tmpl.Execute(result, m) 55 | filename := "main.go" 56 | ioutil.WriteFile(path+"/"+filename, result.Bytes(), 0644) 57 | 58 | tmpl, _ = template.New("").Parse(modTemplate()) 59 | result = bytes.NewBuffer([]byte{}) 60 | tmpl.Execute(result, m) 61 | filename = "go.mod" 62 | ioutil.WriteFile(path+"/"+filename, result.Bytes(), 0644) 63 | 64 | tmpl, _ = template.New("").Parse(runTemplate()) 65 | result = bytes.NewBuffer([]byte{}) 66 | tmpl.Execute(result, m) 67 | filename = "run" 68 | ioutil.WriteFile(path+"/"+filename, result.Bytes(), 0644) 69 | 70 | tmpl, _ = template.New("").Parse(ignoreTemplate()) 71 | result = bytes.NewBuffer([]byte{}) 72 | tmpl.Execute(result, m) 73 | filename = ".gitignore" 74 | ioutil.WriteFile(path+"/"+filename, result.Bytes(), 0644) 75 | } 76 | -------------------------------------------------------------------------------- /tool/mod_template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func modTemplate() string { 4 | t := `{{$package := index . "package"}} module {{$package}} 5 | 6 | replace github.com/andrewarrow/feedback => /Users/aa/os/feedback 7 | 8 | go 1.19 9 | 10 | require ( 11 | github.com/andrewarrow/feedback v0.0.0-20230629214121-08868362ccbe 12 | golang.org/x/crypto v0.8.0 13 | )` 14 | 15 | return t 16 | } 17 | -------------------------------------------------------------------------------- /tool/table.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "strings" 8 | 9 | "github.com/andrewarrow/feedback/router" 10 | ) 11 | 12 | func list(path, name string) { 13 | asBytes, _ := ioutil.ReadFile(path + "/app/feedback.json") 14 | var site router.FeedbackSite 15 | json.Unmarshal(asBytes, &site) 16 | m := site.FindModel(name) 17 | m.EnsureIdAndCreatedAt() 18 | 19 | top := `package app 20 | 21 | import ( 22 | "github.com/andrewarrow/feedback/router" 23 | )` 24 | 25 | fmt.Println(top) 26 | fmt.Println("func handleFoo(c *router.Context) {") 27 | fmt.Printf(`list := c.All("%s", "order by created_at desc", "")`+"\n", name) 28 | send := `send := map[string]any{} 29 | send["list"] = list 30 | c.SendContentInLayout("foo.html", send, 200)` 31 | fmt.Println(send) 32 | fmt.Println("}") 33 | } 34 | 35 | func table(path, name string) { 36 | asBytes, _ := ioutil.ReadFile(path + "/app/feedback.json") 37 | var site router.FeedbackSite 38 | json.Unmarshal(asBytes, &site) 39 | m := site.FindModel(name) 40 | m.EnsureIdAndCreatedAt() 41 | 42 | buff := []string{} 43 | buff = append(buff, fmt.Sprintf(`{{ define "%ss" }}`, name)) 44 | goal := ` {{ $items := index . "items" }} 45 | table id=thing 46 | tr font-bold` 47 | buff = append(buff, goal) 48 | for _, field := range m.Fields { 49 | buff = append(buff, " td pr-3\n "+field.Name) 50 | } 51 | header := strings.Join(buff, "\n") 52 | fmt.Println(header) 53 | 54 | fmt.Println(` {{ range $i, $item := $items }}`) 55 | fmt.Println(` tr`) 56 | fmt.Printf(` {{ template "%s" $item }}`+"\n", name) 57 | fmt.Println(` {{end}}`) 58 | fmt.Println(` {{end}}`) 59 | 60 | fmt.Printf(` {{ define "%s" }}`+"\n", name) 61 | for _, field := range m.Fields { 62 | fmt.Println(` {{ $` + field.Name + ` := index . "` + field.Name + `" }}`) 63 | fmt.Printf(` td pr-3 whitespace-nowrap` + "\n {{ $" + field.Name + " }}\n") 64 | } 65 | fmt.Println(` {{end}}`) 66 | 67 | } 68 | -------------------------------------------------------------------------------- /tool/template_cols.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func colsTemplate() string { 4 | t := ` 5 | {{$name := index . "name"}} 6 | {{$lower := index . "lower"}} 7 | {{$withS := index . "with_s"}} 8 | {{ "{{" }} define "_{{$lower}}_col1" {{ "}}" }} 9 | {{ "{{" }} $row := index . "row" {{ "}}" }} 10 | {{ "{{" }} $guid := index $row "guid" {{ "}}" }} 11 | 12 | {{ "{{" }} index $row "name" {{ "}}" }} 13 | 14 | {{ "{{" }} end {{ "}}" }} 15 | 16 | {{ "{{" }} define "_{{$lower}}_col2" {{ "}}" }} 17 | {{ "{{" }} $row := index . "row" {{ "}}" }} 18 | {{ "{{" }} index $row "street1" {{ "}}" }} 19 |
20 | {{ "{{" }} index $row "street2" {{ "}}" }} 21 | {{ "{{" }} end {{ "}}" }} 22 | 23 | {{ "{{" }} define "_{{$lower}}_col3" {{ "}}" }} 24 | {{ "{{" }} $row := index . "row" {{ "}}" }} 25 | {{ "{{" }} index $row "city" {{ "}}" }}, 26 | {{ "{{" }} index $row "state" {{ "}}" }} 27 | {{ "{{" }} index $row "zip" {{ "}}" }} 28 | {{ "{{" }} index $row "country" {{ "}}" }} 29 | {{ "{{" }} end {{ "}}" }} 30 | 31 | {{ "{{" }} define "_{{$lower}}_col4" {{ "}}" }} 32 | {{ "{{" }} $row := index . "row" {{ "}}" }} 33 | {{ "{{" }} index $row "created_at_human" {{ "}}" }} 34 |
35 | {{ "{{" }} index $row "created_at_ago" {{ "}}" }} 36 | {{ "{{" }} end {{ "}}" }} 37 | ` 38 | return t 39 | } 40 | -------------------------------------------------------------------------------- /tool/template_controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func controllerTemplate() string { 4 | t := `package app 5 | 6 | import ( 7 | "github.com/andrewarrow/feedback/router" 8 | ) 9 | 10 | {{$name := index . "name"}} 11 | {{$lower := index . "lower"}} 12 | {{$withS := index . "with_s"}} 13 | func Handle{{$name}}(c *router.Context, second, third string) { 14 | if router.NotLoggedIn(c) { 15 | return 16 | } 17 | if second == "" && third == "" && c.Method == "GET" { 18 | handle{{$name}}Index(c) 19 | return 20 | } 21 | if second == "" && third == "" && c.Method == "POST" { 22 | handle{{$name}}Create(c) 23 | return 24 | } 25 | if second != "" && third == "" && c.Method == "GET" { 26 | handle{{$name}}Show(c, second) 27 | return 28 | } 29 | if second != "" && third == "" && c.Method == "POST" { 30 | handle{{$name}}ShowPost(c, second) 31 | return 32 | } 33 | c.NotFound = true 34 | } 35 | 36 | func handle{{$name}}Index(c *router.Context) { 37 | //list := c.All("{{$lower}}", "where user_id=$1 order by created_at desc", "", c.User["id"]) 38 | 39 | send := map[string]any{} 40 | c.SendContentInLayout(".html", send, 200) 41 | }` 42 | 43 | return t 44 | } 45 | -------------------------------------------------------------------------------- /tool/template_create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func createTemplate() string { 4 | 5 | t := `package app 6 | 7 | {{$name := index . "name"}} 8 | {{$lower := index . "lower"}} 9 | {{$withS := index . "with_s"}} 10 | 11 | import ( 12 | "fmt" 13 | "net/http" 14 | "time" 15 | 16 | "github.com/andrewarrow/feedback/router" 17 | ) 18 | 19 | func handle{{$name}}Create(c *router.Context) { 20 | //c.ReadFormValuesIntoParams("") 21 | 22 | returnPath := "/{{$withS}}" 23 | 24 | now := time.Now().Unix() 25 | c.Params = map[string]any{} 26 | c.Params["user_id"] = c.User["id"] 27 | c.Params["name"] = fmt.Sprintf("Untitled %d", now) 28 | c.Params["street1"] = "123 Main St." 29 | c.Params["city"] = "Los Angeles" 30 | c.Params["state"] = "CA" 31 | c.Params["zip"] = "90066" 32 | c.Params["country"] = "USA" 33 | message := c.ValidateCreate("{{$lower}}") 34 | if message != "" { 35 | router.SetFlash(c, message) 36 | http.Redirect(c.Writer, c.Request, returnPath, 302) 37 | return 38 | } 39 | message = c.Insert("{{$lower}}") 40 | if message != "" { 41 | router.SetFlash(c, message) 42 | http.Redirect(c.Writer, c.Request, returnPath, 302) 43 | return 44 | } 45 | http.Redirect(c.Writer, c.Request, returnPath, 302) 46 | }` 47 | 48 | return t 49 | } 50 | -------------------------------------------------------------------------------- /tool/template_ignore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func ignoreTemplate() string { 4 | t := ` 5 | {{$name := index . "name"}} 6 | {{$lower := index . "lower"}} 7 | go.sum 8 | node_modules 9 | package-lock.json 10 | package.json 11 | assets/css/tail.min.css 12 | .DS_Store 13 | {{$lower}} 14 | ` 15 | return t 16 | } 17 | -------------------------------------------------------------------------------- /tool/template_list_top.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func listTopTemplate() string { 4 | t := ` 5 |
6 | 7 |
` 8 | return t 9 | } 10 | -------------------------------------------------------------------------------- /tool/template_main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func mainTemplate() string { 4 | t := `package main 5 | {{$package := index . "package"}} 6 | import ( 7 | "embed" 8 | "math/rand" 9 | "os" 10 | "{{$package}}/app" 11 | "time" 12 | 13 | "github.com/andrewarrow/feedback/router" 14 | ) 15 | 16 | //go:embed app/feedback.json 17 | var embeddedFile []byte 18 | 19 | //go:embed views/*.html 20 | var embeddedTemplates embed.FS 21 | 22 | //go:embed assets/**/*.* 23 | var embeddedAssets embed.FS 24 | 25 | var buildTag string 26 | 27 | func main() { 28 | rand.Seed(time.Now().UnixNano()) 29 | if len(os.Args) == 1 { 30 | //PrintHelp() 31 | return 32 | } 33 | arg := os.Args[1] 34 | 35 | if arg == "reset" { 36 | //r := router.NewRouter("DATABASE_URL") 37 | //r.ResetDatabase() 38 | } else if arg == "run" { 39 | router.BuildTag = buildTag 40 | router.EmbeddedTemplates = embeddedTemplates 41 | router.EmbeddedAssets = embeddedAssets 42 | r := router.NewRouter("DATABASE_URL", embeddedFile) 43 | r.Paths["/"] = app.HandleWelcome 44 | //r.Paths["sessions"] = app.HandleSessions 45 | //r.Paths["users"] = app.HandleUsers 46 | r.Prefix = "" 47 | r.ListenAndServe(":" + os.Args[2]) 48 | } else if arg == "help" { 49 | } 50 | 51 | }` 52 | 53 | return t 54 | } 55 | -------------------------------------------------------------------------------- /tool/template_run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func runTemplate() string { 4 | 5 | t := `{{$package := index . "package"}}# https://tailwindcss.com/blog/standalone-cli 6 | tailwindcss -i assets/css/tail.components.css -o assets/css/tail.min.css --minify 7 | uuid=$(uuidgen); go build -ldflags="-X main.buildTag=$uuid" 8 | ./{{$package}} run 3000` 9 | return t 10 | } 11 | -------------------------------------------------------------------------------- /tool/template_show.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func showTemplate() string { 4 | 5 | t := `package app 6 | 7 | {{$name := index . "name"}} 8 | {{$lower := index . "lower"}} 9 | {{$withS := index . "with_s"}} 10 | 11 | import ( 12 | "net/http" 13 | 14 | "github.com/andrewarrow/feedback/router" 15 | ) 16 | 17 | func handle{{$name}}ShowPost(c *router.Context, guid string) { 18 | c.ReadFormValuesIntoParams("file") 19 | returnPath := "/" 20 | 21 | //c.ValidateCreate("{{$lower}}") 22 | message := c.Update("{{$lower}}", "where guid=", guid) 23 | if message != "" { 24 | router.SetFlash(c, message) 25 | http.Redirect(c.Writer, c.Request, returnPath+"/"+guid, 302) 26 | return 27 | } 28 | http.Redirect(c.Writer, c.Request, returnPath, 302) 29 | } 30 | 31 | func handle{{$name}}Show(c *router.Context, guid string) { 32 | item := c.One("{{$lower}}", "where guid=$1", guid) 33 | send := map[string]any{} 34 | send["item"] = item 35 | c.SendContentInLayout(".html", send, 200) 36 | }` 37 | 38 | return t 39 | } 40 | -------------------------------------------------------------------------------- /tool/template_show_cols.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func showColsTemplate() string { 4 | t := ` 5 | {{$name := index . "name"}} 6 | {{$lower := index . "lower"}} 7 | {{ "{{" }} define "_{{$lower}}_show_col1" {{ "}}" }} 8 | {{ "{{" }} $row := index . "row" {{ "}}" }} 9 | {{ "{{" }} $row {{ "}}" }} 10 | {{ "{{" }} end {{ "}}" }} 11 | 12 | {{ "{{" }} define "_{{$lower}}_show_col2" {{ "}}" }} 13 | 14 | {{ "{{" }} template "_editable_fields" . {{ "}}" }} 15 | 16 | {{ "{{" }} end {{ "}}" }} 17 | ` 18 | return t 19 | } 20 | -------------------------------------------------------------------------------- /tool/template_top.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func topTemplate() string { 4 | t := ` 5 |
6 | 7 |
` 8 | return t 9 | } 10 | -------------------------------------------------------------------------------- /util/args.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func GetArg(index int) string { 10 | if len(os.Args) > index { 11 | return os.Args[index] 12 | } 13 | return "" 14 | } 15 | 16 | func Atoi(num string, ifzero int) int { 17 | thing, _ := strconv.Atoi(num) 18 | if thing == 0 { 19 | return ifzero 20 | } 21 | return thing 22 | } 23 | 24 | func ArgsToMap() map[string]string { 25 | m := map[string]string{} 26 | if len(os.Args) == 1 { 27 | return m 28 | } 29 | 30 | for _, a := range os.Args[1:] { 31 | if strings.HasPrefix(a, "--") { 32 | tokens := strings.Split(a, "=") 33 | key := strings.Split(tokens[0], "--") 34 | if len(tokens) == 2 { 35 | m[key[1]] = tokens[1] 36 | } else { 37 | m[key[1]] = "true" 38 | } 39 | } else if strings.Contains(a, "=") { 40 | tokens := strings.Split(a, "=") 41 | if len(tokens) == 2 { 42 | m[tokens[0]] = tokens[1] 43 | } 44 | } 45 | } 46 | return m 47 | } 48 | -------------------------------------------------------------------------------- /util/array.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func ToAnyArray(rows []map[string]any) []any { 4 | list := []any{} 5 | for _, row := range rows { 6 | list = append(list, row) 7 | } 8 | return list 9 | } 10 | 11 | func ToAny(rows []string) []any { 12 | list := []any{} 13 | for _, row := range rows { 14 | list = append(list, row) 15 | } 16 | return list 17 | } 18 | 19 | func ToMSA(rows []any) []map[string]any { 20 | sizes := []map[string]any{} 21 | for _, item := range rows { 22 | sizes = append(sizes, item.(map[string]any)) 23 | } 24 | return sizes 25 | } 26 | -------------------------------------------------------------------------------- /util/domain.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | func ExtractDomain(url string) string { 6 | tokens := strings.Split(url, "/") 7 | if len(tokens) > 2 { 8 | tokens = strings.Split(tokens[2], ".") 9 | if len(tokens) == 3 { 10 | tokens = tokens[1:] 11 | } 12 | return strings.Join(tokens, ".") 13 | } 14 | return "" 15 | } 16 | -------------------------------------------------------------------------------- /util/guid_filename.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | func GuidFilename(name, guid string) string { 6 | if !strings.Contains(name, ".") { 7 | name = name + ".bin" 8 | } 9 | tokens := strings.Split(name, ".") 10 | ext := tokens[len(tokens)-1] 11 | filename := guid + "." + ext 12 | return filename 13 | } 14 | -------------------------------------------------------------------------------- /util/header.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | func GetHeader(field string, request *http.Request) string { 9 | //fmt.Printf("%+v", request.Header) 10 | val := request.Header[field] 11 | if len(val) == 0 { 12 | return "" 13 | } 14 | return strings.Join(val, ",") 15 | } 16 | -------------------------------------------------------------------------------- /util/javascript.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | 8 | "github.com/tdewolff/minify/v2" 9 | "github.com/tdewolff/minify/v2/js" 10 | ) 11 | 12 | func Minify() { 13 | dir := "assets/javascript" 14 | outputFile := "assets/javascript/main.js" 15 | inputFiles := listOfJavascriptFiles(dir) 16 | 17 | var combinedJS strings.Builder 18 | for _, file := range inputFiles { 19 | content, _ := ioutil.ReadFile(file) 20 | combinedJS.Write(content) 21 | combinedJS.WriteString("\n") 22 | } 23 | 24 | m := minify.New() 25 | m.AddFunc("text/javascript", js.Minify) 26 | 27 | minified, _ := m.String("text/javascript", combinedJS.String()) 28 | ioutil.WriteFile(outputFile, []byte(minified), 0644) 29 | } 30 | 31 | func listOfJavascriptFiles(dir string) []string { 32 | files, _ := os.ReadDir(dir) 33 | paths := []string{} 34 | for _, file := range files { 35 | name := file.Name() 36 | if name == "main.js" { 37 | continue 38 | } 39 | if strings.HasSuffix(name, ".js") == false { 40 | continue 41 | } 42 | paths = append(paths, dir+"/"+name) 43 | } 44 | return paths 45 | } 46 | -------------------------------------------------------------------------------- /util/json.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | ) 7 | 8 | func PipeToJq(inputString string) string { 9 | 10 | jq := exec.Command("jq", ".") 11 | jq.Stdin = strings.NewReader(inputString) 12 | 13 | b, _ := jq.CombinedOutput() 14 | return strings.TrimSpace(string(b)) 15 | 16 | } 17 | 18 | func GetJsonString(m map[string]any, key string) string { 19 | s := m[key] 20 | if s == nil { 21 | return "" 22 | } 23 | return s.(string) 24 | } 25 | 26 | func GetJsonMap(m map[string]any, key string) map[string]any { 27 | item := m[key] 28 | if item == nil { 29 | return map[string]any{} 30 | } 31 | return item.(map[string]any) 32 | } 33 | 34 | func GetJsonInt64(m map[string]any, key string) int64 { 35 | val := m[key] 36 | if val == nil { 37 | return 0 38 | } 39 | return int64(m[key].(float64)) 40 | } 41 | -------------------------------------------------------------------------------- /util/number.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "fmt" 4 | 5 | func IntComma(i int64) string { 6 | if i < 0 { 7 | return "-" + IntComma(-i) 8 | } 9 | if i < 1000 { 10 | return fmt.Sprintf("%d", i) 11 | } 12 | return IntComma(i/1000) + "," + fmt.Sprintf("%03d", i%1000) 13 | } 14 | -------------------------------------------------------------------------------- /util/plural.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func SpecialToSingle() map[string]string { 8 | return map[string]string{"series": "series", 9 | "properties": "property", 10 | "energies": "energy", 11 | "brightnesses": "brightness", 12 | "bases": "base", 13 | "coaches": "coach", 14 | "matches": "match", 15 | "batches": "batch", 16 | "dies": "die", 17 | "children": "child", 18 | "searches": "search", 19 | "species": "species"} 20 | } 21 | func SpecialToPlural() map[string]string { 22 | return map[string]string{"series": "series", 23 | "child": "children", 24 | "energy": "energies", 25 | "brightness": "brightnesses", 26 | "batch": "batches", 27 | "coach": "coaches", 28 | "match": "matches", 29 | "search": "searches", 30 | "species": "species"} 31 | } 32 | 33 | func Plural(s string) string { 34 | tokens := strings.Split(s, "_") 35 | if len(tokens) == 1 { 36 | return PluralLogic(s) 37 | } else { 38 | front := strings.Join(tokens[0:len(tokens)-1], "_") 39 | return front + "_" + PluralLogic(tokens[len(tokens)-1]) 40 | } 41 | } 42 | 43 | func PluralLogic(s string) string { 44 | m := SpecialToPlural() 45 | if m[s] != "" { 46 | return m[s] 47 | } 48 | if strings.HasSuffix(s, "y") { 49 | return strings.TrimSuffix(s, "y") + "ies" 50 | } 51 | return s + "s" 52 | } 53 | 54 | func Unplural(s string) string { 55 | tokens := strings.Split(s, "_") 56 | if len(tokens) == 1 { 57 | return UnpluralLogic(s) 58 | } else { 59 | front := strings.Join(tokens[0:len(tokens)-1], "_") 60 | return front + "_" + UnpluralLogic(tokens[len(tokens)-1]) 61 | } 62 | } 63 | 64 | func UnpluralLogic(s string) string { 65 | m := SpecialToSingle() 66 | if m[s] != "" { 67 | return m[s] 68 | } 69 | return strings.TrimSuffix(s, "s") 70 | } 71 | -------------------------------------------------------------------------------- /util/snake.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/iancoleman/strcase" 8 | ) 9 | 10 | var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") 11 | var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") 12 | 13 | func ToSnakeCase(str string) string { 14 | snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") 15 | snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") 16 | s := strings.ToLower(snake) 17 | return s 18 | } 19 | func ToCamelCase(str string) string { 20 | return strcase.ToCamel(str) 21 | } 22 | 23 | func FixForDash(s string) string { 24 | return strings.Replace(s, "-", "_", -1) 25 | } 26 | -------------------------------------------------------------------------------- /util/strip.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func StripFields(row map[string]any) map[string]any { 8 | 9 | for k, _ := range row { 10 | if k == "password" || k == "id" { 11 | delete(row, k) 12 | } else if strings.HasSuffix(k, "_id") { 13 | delete(row, k) 14 | } 15 | } 16 | 17 | return row 18 | 19 | } 20 | 21 | func RemoveSensitiveKeys(m map[string]any) { 22 | for k, v := range m { 23 | switch vv := v.(type) { 24 | case map[string]any: 25 | RemoveSensitiveKeys(vv) 26 | case []map[string]any: 27 | for _, item := range vv { 28 | RemoveSensitiveKeys(item) 29 | } 30 | default: 31 | if k == "password" || strings.HasSuffix(k, "_id") || k == "id" { 32 | delete(m, k) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /util/template.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | func MakeTemplate(name string) { 10 | asBytes, _ := ioutil.ReadFile("t") 11 | top := `{{ define "_%s" }}` 12 | end := `{{ end }}` 13 | s := fmt.Sprintf(top, name) + "\n" + string(asBytes) + "\n" + end 14 | ioutil.WriteFile("views/_"+name+".html", []byte(s), 0644) 15 | result := fmt.Sprintf(`{{ template "_%s" . }}`, name) 16 | fmt.Printf("\n\n%s\n\n", result) 17 | os.Remove("t") 18 | } 19 | -------------------------------------------------------------------------------- /util/uuid.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | func PseudoUuid() string { 9 | 10 | b := make([]byte, 16) 11 | rand.Read(b) 12 | 13 | return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) 14 | } 15 | -------------------------------------------------------------------------------- /views/404.html: -------------------------------------------------------------------------------- 1 |
2 | {{ template "navbar" . }} 3 |
4 |

5 | This is your 404 page. 6 |

7 |
8 |
9 | -------------------------------------------------------------------------------- /views/_editable_fields.html: -------------------------------------------------------------------------------- 1 | {{ define "_editable_fields" }} 2 | 3 | {{ $p := index . "params" }} 4 | {{ $item := index $p "item" }} 5 | {{ $row := index . "row" }} 6 | {{ $editable := index $p "editable" }} 7 | {{ $regexMap := index $p "regex_map" }} 8 | {{ $flavor := index $editable $row }} 9 | {{ $regex := index $regexMap $row }} 10 | 11 | {{ if eq $flavor "string" }} 12 | 13 | {{ else if eq $flavor "int" }} 14 | 15 | {{ else if eq $flavor "float" }} 16 | 17 | {{ else if eq $flavor "json" }} 18 | {{ $json := index $item $row }} 19 |
{{ indent $json }}
20 | {{ else if eq $flavor "text" }} 21 | 22 | {{ else if eq $flavor "bool" }} 23 | {{ $boolVal := index $item $row }} 24 | 31 | {{ else if eq $flavor "save" }} 32 | 33 | {{ else }} 34 | {{ index $item $row }} 35 | {{ end }} 36 |
37 | {{ $regex }} 38 | 39 | {{ end }} 40 | -------------------------------------------------------------------------------- /views/_models.html: -------------------------------------------------------------------------------- 1 | {{define "_models"}} 2 | {{range $i, $item := .Models}} 3 | 4 | 5 | {{add $i 1}}. 6 | 7 | 8 | {{$item.Name}} 9 | 10 | 11 | {{end}} 12 | {{end}} 13 | -------------------------------------------------------------------------------- /views/_nav_user.html: -------------------------------------------------------------------------------- 1 | {{ define "_nav_user" }} 2 | {{$user := index . "user"}} 3 | {{ if index $user "id" }} 4 | 7 | 8 | logout 9 | {{ index $user "first_name"}} 10 | 11 | 12 | {{ index $user "timezone"}} 13 | 14 | {{ else }} 15 | 16 | Login 17 | 18 | {{ end }} 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /views/_table_large.html: -------------------------------------------------------------------------------- 1 | {{ define "_table_large" }} 2 |
3 | {{ $colAttributes := index . "col_attributes" }} 4 | {{ $colAttributesPresent := false }} 5 | {{ if $colAttributes }} 6 | {{ $colAttributesPresent = true }} 7 | {{ end }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{ $headers := index . "headers" }} 15 | {{ range $i, $h := $headers }} 16 | {{ $wrap := "whitespace-nowrap" }} 17 | {{ if $colAttributesPresent }} 18 | {{ $attr := index $colAttributes $i }} 19 | {{ if $attr }} 20 | {{ $wrap = $attr }} 21 | {{ end }} 22 | {{ end }} 23 | 30 | {{ end }} 31 | 32 | 33 | 34 | 35 | {{ $cells := index . "cells" }} 36 | {{ range $i, $c := $cells }} 37 | 38 | {{ range $j, $cell := $c }} 39 | {{ $wrap := "whitespace-nowrap" }} 40 | {{ if $colAttributesPresent }} 41 | {{ $attr := index $colAttributes $j }} 42 | {{ if $attr }} 43 | {{ $wrap = $attr }} 44 | {{ end }} 45 | {{ end }} 46 | 51 | {{ end }} 52 | 53 | {{ end }} 54 | 55 | 56 |
24 |
25 | 26 | {{ $h }} 27 | 28 |
29 |
47 |
48 | {{ $cell }} 49 |
50 |
57 | 58 |
59 | {{end}} 60 | -------------------------------------------------------------------------------- /views/_table_large_append.html: -------------------------------------------------------------------------------- 1 | {{ $wrap := "whitespace-nowrap" }} 2 | {{ range $j, $cell := index . "cells" }} 3 | {{ range $i, $c := $cell }} 4 | 5 |
{{ $c }}
6 | 7 | {{ end }} 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /views/_table_small.html: -------------------------------------------------------------------------------- 1 | {{ define "_table_small" }} 2 | 3 | 4 | {{ $cells := index . "cells" }} 5 | {{ $headers := index . "headers" }} 6 | {{ range $j, $c := $cells }} 7 | 8 | {{ if ne $j 0 }} 9 | 10 | 13 | 16 | 17 | {{ end }} 18 | {{ range $i, $h := $headers }} 19 | 20 | 23 | 26 | 27 | {{ end }} 28 | {{ end }} 29 | 30 |
11 |   12 | 14 |
15 |
21 | {{ $h }} 22 | 24 | {{ index $c $i}} 25 |
31 | {{end}} 32 | -------------------------------------------------------------------------------- /views/_table_small_append.html: -------------------------------------------------------------------------------- 1 | {{ $cells := index . "cells" }} 2 | {{ $headers := index . "headers" }} 3 | {{ range $j, $c := $cells }} 4 | {{ range $i, $h := $headers }} 5 | 6 | 7 | {{ $h }} 8 | 9 | 10 | {{ index $c $i}} 11 | 12 | 13 | {{ end }} 14 | {{ end }} 15 | -------------------------------------------------------------------------------- /views/_welcome_show_cols.html: -------------------------------------------------------------------------------- 1 | {{ define "_welcome_col1" }} 2 | {{ $row := index . "row" }} 3 | {{ index $row "name" }} 4 | {{ end }} 5 | 6 | {{ define "_welcome_col2" }} 7 | {{ $row := index . "row" }} 8 | {{ index $row "age" }} 9 | {{ end }} 10 | 11 | {{ define "_welcome_col3" }} 12 | {{ $row := index . "row" }} 13 | {{ index $row "species" }} 14 | {{ end }} 15 | 16 | {{ define "_welcome_col4" }} 17 | {{ $row := index . "row" }} 18 | {{ index $row "home_planet" }} 19 | {{ end }} 20 | 21 | {{ define "_welcome_col5" }} 22 | {{ $row := index . "row" }} 23 | {{ index $row "language" }} 24 | {{ end }} 25 | 26 | {{ define "_welcome_col6" }} 27 | {{ $row := index . "row" }} 28 | {{ index $row "occupation" }} 29 | {{ end }} 30 | -------------------------------------------------------------------------------- /views/about_index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |

About Feedback

6 |

This is a "go on rails" framework.

7 |
8 |

9 | A sample app using this framework is here. 10 | 11 | You can start with its main.go file and build your own app. 12 |

13 |

14 | This sample app is live at https://remoterenters.com/ 15 |

16 |
17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /views/admin_index.html: -------------------------------------------------------------------------------- 1 | hi 2 | -------------------------------------------------------------------------------- /views/admin_users_index.html: -------------------------------------------------------------------------------- 1 | users 2 | -------------------------------------------------------------------------------- /views/application_layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $build := index . "build" }} 4 | {{ $og := index . "og" }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{ if index . "USE_LIVE_TEMPLATES" }} 13 | 15 | 16 | {{ end }} 17 | 19 | 22 | 23 | {{ index . "title" }} 24 | 25 | {{ index . "viewport" }} 26 | 27 | 28 |
29 | {{ index . "flash" }} 30 |
31 |
32 | {{ index . "content" }} 33 |
34 |
35 | 54 |
55 | {{ index . "wasm" }} 56 | 57 | 58 | -------------------------------------------------------------------------------- /views/fields_show.html: -------------------------------------------------------------------------------- 1 | {{ $model := index . "model"}} 2 | {{ $field := index . "field"}} 3 | 4 |
5 |
name 6 |
7 |
8 | flavor 9 | 10 |
11 |
12 | index 13 | 14 |
15 |
16 | required 17 | 18 |
19 |
20 | regex 21 | 22 |
23 |
24 | null 25 | 26 |
27 | 28 | 29 |
30 | -------------------------------------------------------------------------------- /views/generic_top_bottom.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ index . "top" }} 4 |
5 |
6 |
7 | {{ index . "bottom" }} 8 |
9 | -------------------------------------------------------------------------------- /views/models_edit.html: -------------------------------------------------------------------------------- 1 |
2 | {{ $row := .Row }} 3 | {{range $i, $item := .Rows}} 4 | 5 | 6 |   7 |   8 | 9 | 10 | {{$item.Name}} 11 |   12 | 13 | 14 | {{if eq $item.Flavor "text" }} 15 | 16 | {{else}} 17 | 18 | {{end}} 19 |
20 | 21 | 22 | 23 | {{end}} 24 | 25 | 26 |   27 |   28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /views/models_index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

These are tables in your database.

5 |
6 | 7 | 8 | {{template "_models" .}} 9 |
10 | New Model: 11 |
12 | 13 | 14 |
15 |
16 |
17 |

Schema Json

18 |
19 | 20 | 21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /views/models_layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ index . "title" }} 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 18 |
19 | Models 20 |
21 |
22 | 23 | {{$user := index . "user"}} 24 | {{ if index $user "id" }} 25 |
26 | 27 |
28 | {{ index $user "username" }} | logout 29 | {{ else }} 30 | login 31 | {{ end }} 32 |
33 |
34 | 35 |
36 |
{{.Flash}}
37 | {{ index . "content" }} 38 |
39 | 40 |
41 |
42 | feedback 43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /views/models_list.html: -------------------------------------------------------------------------------- 1 | {{template "_models" .}} 2 | -------------------------------------------------------------------------------- /views/sessions_new.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Login

5 |
6 |
7 | 8 | 9 | 10 | 11 |
12 | 13 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 |

Create Account

26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /views/stats_index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Stats

4 | 5 | 6 | {{range $i, $item := .}} 7 | 8 | 11 | 12 | 16 | 17 | 18 | {{end}} 19 |
9 | {{add $i 1}}. 10 | {{$item.Path}} {{$item.Remote}}
13 | {{$item.Agent}}
14 | {{$item.Referer}} 15 |
{{ago $item.Timestamp}}
20 | 21 |
22 |
23 | -------------------------------------------------------------------------------- /views/table_show.html: -------------------------------------------------------------------------------- 1 | 11 |
12 |
13 | {{ template "_table_small" . }} 14 | 15 | {{ if index . "save" }} 16 |
17 | 18 |
19 | {{ end }} 20 |
21 |
22 | -------------------------------------------------------------------------------- /views/tailwind_index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | LANDING 6 |
7 |
8 |
9 | hi 10 |
11 | 12 |
13 |
14 |
15 |
16 | LANDING 17 |
18 |
19 |
20 | hi 21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 |
29 | hi 30 |
31 | 32 |
33 | right 34 |
35 | 36 | 37 | 63 | -------------------------------------------------------------------------------- /views/users_show.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

User Show

5 |

This is /users/x where x is username.

6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
Username:{{ index . "username" }}
Created:{{ index . "created_at" }}
Created Ago:{{ index . "created_at_ago" }}
22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /views/welcome_index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Welcome

5 |

This is the very first root page views/welcome_index.html

6 |
7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /wasm/auto_click.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import "fmt" 4 | 5 | func (g *Global) AutoClick(prefix, suffix string, w *Wrapper, name string, cb func(string)) { 6 | for _, item := range w.SelectAllByClass(name) { 7 | id := item.Id[2:] 8 | click := func() { 9 | go func() { 10 | DoPost(fmt.Sprintf("/%s/%s/%s", prefix, id, suffix), nil) 11 | cb(id) 12 | }() 13 | } 14 | item.EventWithId(click) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /wasm/auto_form.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | "syscall/js" 7 | ) 8 | 9 | type AutoForm struct { 10 | ReturnPath string 11 | Path string 12 | Before func() string 13 | After func(string) 14 | Id string 15 | Method string 16 | Clear bool 17 | } 18 | 19 | func NewAutoForm(id string) *AutoForm { 20 | a := AutoForm{} 21 | a.Id = id 22 | a.Clear = true 23 | a.Method = "POST" 24 | return &a 25 | } 26 | 27 | func (g *Global) AddAutoForm(a *AutoForm) { 28 | form := g.Document.Id(a.Id) 29 | thefunc := func(this js.Value, p []js.Value) any { 30 | p[0].Call("preventDefault") 31 | q := "" 32 | if a.Before != nil { 33 | q = a.Before() 34 | } 35 | go a.Post(g, form, q) 36 | return nil 37 | } 38 | form.JValue.Set("onsubmit", js.FuncOf(thefunc)) 39 | } 40 | 41 | func (a *AutoForm) Post(g *Global, w *Wrapper, q string) { 42 | var jsonString string 43 | var code int 44 | if a.Method == "POST" { 45 | jsonString, code = DoPost(a.Path, w.MapOfInputs(a.Clear)) 46 | } else if a.Method == "PATCH" { 47 | jsonString, code = DoPatch(a.Path, w.MapOfInputs(a.Clear)) 48 | } else if a.Method == "GET" { 49 | qValue := url.QueryEscape(q) 50 | jsonString, _ = DoGet(a.Path + "?q=" + qValue) 51 | if jsonString != "" { 52 | code = 200 53 | } 54 | } 55 | var m map[string]any 56 | json.Unmarshal([]byte(jsonString), &m) 57 | if code == 200 { 58 | if a.After != nil { 59 | //val, _ := m["val"].(string) 60 | a.After(jsonString) 61 | return 62 | } 63 | g.Location.Set("href", a.ReturnPath) 64 | return 65 | } 66 | errorString, _ := m["error"].(string) 67 | g.flashThree("error: " + errorString) 68 | } 69 | -------------------------------------------------------------------------------- /wasm/class.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "strings" 5 | "syscall/js" 6 | ) 7 | 8 | func AddClass(w js.Value, className string) { 9 | currentClass := w.Get("className").String() 10 | newClass := currentClass + " " + className 11 | w.Set("className", newClass) 12 | } 13 | 14 | func RemoveClass(w js.Value, className string) { 15 | if w.IsNull() { 16 | return 17 | } 18 | currentClass := w.Get("className").String() 19 | tokens := strings.Split(currentClass, " ") 20 | buffer := []string{} 21 | for _, item := range tokens { 22 | if item == className { 23 | continue 24 | } 25 | buffer = append(buffer, item) 26 | } 27 | w.Set("className", strings.Join(buffer, " ")) 28 | } 29 | 30 | func HasClass(w js.Value, className string) bool { 31 | currentClass := w.Get("className").String() 32 | tokens := strings.Split(currentClass, " ") 33 | for _, item := range tokens { 34 | if item == className { 35 | return true 36 | } 37 | } 38 | return false 39 | } 40 | -------------------------------------------------------------------------------- /wasm/data.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | func (g *Global) LoadData(route, guid string) []any { 9 | tokens := strings.Split(guid, "-") 10 | tokens = tokens[1:] 11 | id := strings.Join(tokens, "-") 12 | jsonString, _ := DoGet(route + "&guid=" + id) 13 | var m map[string]any 14 | json.Unmarshal([]byte(jsonString), &m) 15 | items, _ := m["items"].([]any) 16 | return items 17 | } 18 | -------------------------------------------------------------------------------- /wasm/document.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "encoding/json" 5 | "syscall/js" 6 | ) 7 | 8 | type Document struct { 9 | Document js.Value 10 | } 11 | 12 | func NewDocument(g *Global) *Document { 13 | d := Document{} 14 | d.Document = g.Global.Get("document") 15 | return &d 16 | } 17 | 18 | func (d *Document) ById(id string) js.Value { 19 | return d.Document.Call("getElementById", id) 20 | } 21 | 22 | func (d *Document) Id(id string) *Wrapper { 23 | return NewWrapper(d.ById(id)) 24 | } 25 | func (d *Document) ByIdWrap(id string) *Wrapper { 26 | return NewWrapper(d.ById(id)) 27 | } 28 | func (d *Document) ByIdWrapped(id string) *Wrapper { 29 | return d.ByIdWrap(id) 30 | } 31 | 32 | func (d *Document) SelectAllFrom(id, s string) []*Wrapper { 33 | return d.ByIdWrap(id).SelectAll(s) 34 | } 35 | 36 | func (d *Document) AppendTo(id, jsonString, template string) { 37 | w := d.ByIdWrap(id) 38 | var m map[string]any 39 | json.Unmarshal([]byte(jsonString), &m) 40 | newDiv := d.RenderToNewDiv(template, m) 41 | w.AppendChild(newDiv) 42 | } 43 | 44 | func (d *Document) ByIdString(id string) string { 45 | input := d.Document.Call("getElementById", id) 46 | return input.Get("value").String() 47 | } 48 | -------------------------------------------------------------------------------- /wasm/form.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "syscall/js" 7 | "time" 8 | ) 9 | 10 | func (w *Wrapper) MapOfInputs(clearAfter bool) map[string]any { 11 | m := map[string]any{} 12 | for _, input := range w.SelectAll("input") { 13 | if input.Get("type") == "submit" { 14 | continue 15 | } 16 | if input.Get("type") == "checkbox" { 17 | m[input.Id] = input.Checked 18 | } else { 19 | m[input.Id] = strings.TrimSpace(input.Value) 20 | if clearAfter && input.Get("type") != "hidden" { 21 | input.Set("value", "") 22 | } 23 | } 24 | } 25 | for _, input := range w.SelectAll("textarea") { 26 | m[input.Id] = strings.TrimSpace(input.Value) 27 | if clearAfter { 28 | input.Set("value", "") 29 | } 30 | } 31 | for _, input := range w.SelectAll("select") { 32 | m[input.Id] = input.Value 33 | if clearAfter { 34 | input.Set("value", "") 35 | } 36 | } 37 | return m 38 | } 39 | 40 | func (g *Global) AutoForm(id, after string, before func(), cb func(id int64)) { 41 | form := g.Document.Id(id) 42 | thefunc := func(this js.Value, p []js.Value) any { 43 | p[0].Call("preventDefault") 44 | if before != nil { 45 | before() 46 | } 47 | go form.AutoFormPost(g, id, after, cb) 48 | return nil 49 | } 50 | form.JValue.Set("onsubmit", js.FuncOf(thefunc)) 51 | } 52 | 53 | func (w *Wrapper) AutoFormPost(g *Global, id, after string, cb func(id int64)) { 54 | jsonString, code := DoPost("/"+after+"/"+id, w.MapOfInputs(false)) 55 | var m map[string]any 56 | json.Unmarshal([]byte(jsonString), &m) 57 | if code == 200 { 58 | returnPath, _ := m["return"].(string) 59 | if returnPath == "" { 60 | returnPath = "/" + after 61 | } 62 | if cb != nil { 63 | id, _ := m["id"].(float64) 64 | cb(int64(id)) 65 | return 66 | } 67 | g.Location.Set("href", returnPath) 68 | return 69 | } 70 | errorString, _ := m["error"].(string) 71 | g.flashThree("error: " + errorString) 72 | } 73 | 74 | func (g *Global) AutoDel(route string, w *Wrapper, name string, cb func()) { 75 | for _, item := range w.SelectAllByClass(name) { 76 | lid := item.Id[2:] 77 | click := func() { 78 | go func() { 79 | DoDelete(route + lid) 80 | cb() 81 | }() 82 | } 83 | item.EventWithId(click) 84 | } 85 | } 86 | 87 | func (g *Global) flashThree(s string) { 88 | flash := g.Document.ById("flash") 89 | flash.Set("innerHTML", s) 90 | time.Sleep(time.Second * 3) 91 | flash.Set("innerHTML", "") 92 | } 93 | -------------------------------------------------------------------------------- /wasm/func.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import "syscall/js" 4 | 5 | func FuncOf(fn func(js.Value)) any { 6 | theFunc := func(this js.Value, p []js.Value) any { 7 | fn(p[0]) 8 | return nil 9 | } 10 | return js.FuncOf(theFunc) 11 | } 12 | func SimpleFuncOf(fn func()) any { 13 | theFunc := func(this js.Value, p []js.Value) any { 14 | fn() 15 | return nil 16 | } 17 | return js.FuncOf(theFunc) 18 | } 19 | -------------------------------------------------------------------------------- /wasm/http.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | func DoHttpRead(request *http.Request) (string, int) { 13 | client := &http.Client{Timeout: time.Second * 5} 14 | request.Header.Set("Content-Type", "application/json") 15 | //request.Header.Set("Accept-Encoding", "application/json") 16 | resp, err := client.Do(request) 17 | if err == nil { 18 | defer resp.Body.Close() 19 | body, err := io.ReadAll(resp.Body) 20 | if err != nil { 21 | fmt.Printf("\n\nERROR: %d %s\n\n", resp.StatusCode, err.Error()) 22 | return err.Error(), 500 23 | } 24 | return string(body), resp.StatusCode 25 | } 26 | fmt.Printf("\n\nERROR: %s\n\n", err.Error()) 27 | return err.Error(), 500 28 | } 29 | 30 | func DoGetMap(urlString string) map[string]any { 31 | jsonString, _ := DoGet(urlString) 32 | var m map[string]any 33 | json.Unmarshal([]byte(jsonString), &m) 34 | return m 35 | } 36 | func DoGetItems(urlString string) []map[string]any { 37 | jsonString, _ := DoGet(urlString) 38 | var m map[string]any 39 | json.Unmarshal([]byte(jsonString), &m) 40 | items := m["items"].([]any) 41 | done := []map[string]any{} 42 | for _, thing := range items { 43 | item := thing.(map[string]any) 44 | done = append(done, item) 45 | } 46 | return done 47 | } 48 | 49 | func DoGet(urlString string) (string, int) { 50 | request, err := http.NewRequest("GET", urlString, nil) 51 | if err != nil { 52 | return "", 422 53 | } 54 | 55 | jsonString, code := DoHttpRead(request) 56 | return jsonString, code 57 | } 58 | 59 | func DoPatch(urlString string, payload any) (string, int) { 60 | asBytes, _ := json.Marshal(payload) 61 | body := bytes.NewBuffer(asBytes) 62 | request, err := http.NewRequest("PATCH", urlString, body) 63 | if err != nil { 64 | return err.Error(), 500 65 | } 66 | 67 | s, code := DoHttpRead(request) 68 | return s, code 69 | } 70 | func DoPut(urlString string, payload any) (string, int) { 71 | asBytes, _ := json.Marshal(payload) 72 | body := bytes.NewBuffer(asBytes) 73 | request, err := http.NewRequest("PUT", urlString, body) 74 | if err != nil { 75 | return "", 500 76 | } 77 | 78 | s, code := DoHttpRead(request) 79 | return s, code 80 | } 81 | 82 | func DoPost(urlString string, payload any) (string, int) { 83 | asBytes, _ := json.Marshal(payload) 84 | body := bytes.NewBuffer(asBytes) 85 | request, err := http.NewRequest("POST", urlString, body) 86 | if err != nil { 87 | return "", 500 88 | } 89 | 90 | s, code := DoHttpRead(request) 91 | return s, code 92 | } 93 | 94 | func DoDelete(urlString string) int { 95 | request, err := http.NewRequest("DELETE", urlString, nil) 96 | if err != nil { 97 | return 500 98 | } 99 | 100 | _, code := DoHttpRead(request) 101 | return code 102 | } 103 | -------------------------------------------------------------------------------- /wasm/http_bearer.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | func DoHttpBearerRead(bearer string, request *http.Request) (string, int) { 13 | client := &http.Client{Timeout: time.Second * 5} 14 | request.Header.Set("Content-Type", "application/json") 15 | request.Header.Set("Authorization", "Bearer: "+bearer) 16 | resp, err := client.Do(request) 17 | if err == nil { 18 | defer resp.Body.Close() 19 | body, err := io.ReadAll(resp.Body) 20 | if err != nil { 21 | fmt.Printf("\n\nERROR: %d %s\n\n", resp.StatusCode, err.Error()) 22 | return err.Error(), 500 23 | } 24 | return string(body), resp.StatusCode 25 | } 26 | fmt.Printf("\n\nERROR: %s\n\n", err.Error()) 27 | return err.Error(), 500 28 | } 29 | 30 | func DoBearerGet(bearer, urlString string) (string, int) { 31 | request, err := http.NewRequest("GET", urlString, nil) 32 | if err != nil { 33 | return "", 500 34 | } 35 | 36 | jsonString, code := DoHttpBearerRead(bearer, request) 37 | return jsonString, code 38 | } 39 | func DoBearerPost(bearer, urlString string, payload any) (string, int) { 40 | asBytes, _ := json.Marshal(payload) 41 | body := bytes.NewBuffer(asBytes) 42 | request, err := http.NewRequest("POST", urlString, body) 43 | if err != nil { 44 | return "", 500 45 | } 46 | 47 | s, code := DoHttpBearerRead(bearer, request) 48 | return s, code 49 | } 50 | func DoBearerPatch(bearer, urlString string, payload any) int { 51 | asBytes, _ := json.Marshal(payload) 52 | body := bytes.NewBuffer(asBytes) 53 | request, err := http.NewRequest("PATCH", urlString, body) 54 | if err != nil { 55 | return 500 56 | } 57 | 58 | _, code := DoHttpBearerRead(bearer, request) 59 | return code 60 | } 61 | func DoBearerDelete(bearer, urlString string) int { 62 | request, err := http.NewRequest("DELETE", urlString, nil) 63 | if err != nil { 64 | return 500 65 | } 66 | 67 | _, code := DoHttpBearerRead(bearer, request) 68 | return code 69 | } 70 | -------------------------------------------------------------------------------- /wasm/keys.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "syscall/js" 5 | ) 6 | 7 | func GetItemMap(item js.Value, count int) map[string]any { 8 | if count > 10 { 9 | return nil 10 | } 11 | m := map[string]any{} 12 | o := js.Global().Get("Object") 13 | keys := o.Call("keys", item) 14 | 15 | for i := 0; i < keys.Length(); i++ { 16 | key := keys.Index(i).String() 17 | value := item.Get(key) 18 | if value.Type() == js.TypeNumber { 19 | m[key] = value.Float() 20 | } else if value.Type() == js.TypeString { 21 | m[key] = value.String() 22 | } else if value.Type() == js.TypeBoolean { 23 | m[key] = value.Bool() 24 | } else if value.Type() == js.TypeObject { 25 | //m[key] = GetItemMap(value, count+1) 26 | } else if value.IsNull() || value.IsUndefined() { 27 | m[key] = nil 28 | } else { 29 | m[key] = value.String() 30 | } 31 | } 32 | return m 33 | } 34 | -------------------------------------------------------------------------------- /wasm/location.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | "syscall/js" 7 | ) 8 | 9 | type Settable interface { 10 | Set(id, value string) 11 | } 12 | 13 | type Location struct { 14 | Value js.Value 15 | Href string 16 | Params url.Values 17 | } 18 | 19 | func NewLocation(g *Global) *Location { 20 | l := Location{} 21 | l.Value = g.Global.Get("location") 22 | l.Href = l.Value.Get("href").String() 23 | tokens := strings.Split(l.Href, "?") 24 | if len(tokens) == 2 { 25 | l.Params, _ = url.ParseQuery(tokens[1]) 26 | } 27 | return &l 28 | } 29 | 30 | func (l *Location) Reload() { 31 | l.Value.Call("reload") 32 | } 33 | func (l *Location) GetParam(id string) string { 34 | val := l.Params[id] 35 | if len(val) == 0 { 36 | return "" 37 | } 38 | return val[0] 39 | } 40 | 41 | func (l *Location) Set(id, value string) { 42 | l.Value.Set(id, value) 43 | } 44 | -------------------------------------------------------------------------------- /wasm/logout.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | func (g *Global) Logout(path, to string) func() { 4 | return func() { 5 | go func() { 6 | code := DoDelete(path + "/logout") 7 | if code == 200 { 8 | g.Location.Set("href", "/"+to) 9 | return 10 | } 11 | g.flashThree("error") 12 | }() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /wasm/render.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "encoding/json" 7 | "fmt" 8 | "io/fs" 9 | "strings" 10 | "syscall/js" 11 | "text/template" 12 | 13 | "github.com/andrewarrow/feedback/common" 14 | ) 15 | 16 | var EmbeddedTemplates embed.FS 17 | var AllTemplates map[string]any 18 | var UseLive = true 19 | var NamedTemplates *template.Template 20 | 21 | func (d *Document) RenderToId(id, name string, vars any) *Wrapper { 22 | div := d.ById(id) 23 | div.Set("innerHTML", d.Render(name, vars)) 24 | return NewWrapper(div) 25 | } 26 | 27 | func (d *Document) RenderAndAppend(location, template, key, jsonString string) *Wrapper { 28 | var vars map[string]any 29 | json.Unmarshal([]byte(jsonString), &vars) 30 | div := d.RenderToNewDiv(template, vars[key]) 31 | d.Id(location).Call("appendChild", div) 32 | return NewWrapper(div) 33 | } 34 | 35 | func (d *Document) RenderToNewDiv(name string, vars any) js.Value { 36 | newHTML := d.Render(name, vars) 37 | newDiv := d.Document.Call("createElement", "div") 38 | newDiv.Set("innerHTML", newHTML) 39 | return newDiv.Get("firstElementChild") 40 | } 41 | 42 | func (d *Document) NewTag(t, s string) *Wrapper { 43 | newTag := d.Document.Call("createElement", t) 44 | newTag.Set("innerHTML", s) 45 | return NewWrapper(newTag) 46 | } 47 | 48 | func (d *Document) Render(name string, vars any) string { 49 | return Render(name, vars) 50 | } 51 | 52 | func LoadTemplates(tf template.FuncMap) *template.Template { 53 | t := template.New("") 54 | t = t.Funcs(tf) 55 | 56 | templateFiles, _ := EmbeddedTemplates.ReadDir("views") 57 | for _, file := range templateFiles { 58 | name := file.Name() 59 | tokens := strings.Split(name, ".") 60 | name = tokens[0] 61 | fileContents, _ := EmbeddedTemplates.ReadFile("views/" + file.Name()) 62 | 63 | _, err := t.New(name).Parse(string(fileContents)) 64 | if err != nil { 65 | fmt.Println(file.Name(), err) 66 | } 67 | } 68 | return t 69 | } 70 | func LoadLiveTemplates(tf template.FuncMap) *template.Template { 71 | t := template.New("") 72 | t = t.Funcs(tf) 73 | 74 | for name, v := range AllTemplates { 75 | _, err := t.New(name).Parse(v.(string)) 76 | if err != nil { 77 | fmt.Println(name, err) 78 | } 79 | } 80 | 81 | return t 82 | } 83 | 84 | func Render(name string, vars any) string { 85 | if NamedTemplates == nil { 86 | if UseLive { 87 | NamedTemplates = LoadLiveTemplates(common.TemplateFunctions()) 88 | } else { 89 | NamedTemplates = LoadTemplates(common.TemplateFunctions()) 90 | } 91 | } 92 | fmt.Println(name, NamedTemplates) 93 | t := NamedTemplates.Lookup(name) 94 | content := new(bytes.Buffer) 95 | t.Execute(content, vars) 96 | //t.ExecuteTemplate(content, name, vars) 97 | cb := content.Bytes() 98 | return string(cb) 99 | } 100 | 101 | func LoadAllTemplates(files []fs.DirEntry) { 102 | 103 | AllTemplates = map[string]any{} 104 | for _, item := range files { 105 | name := item.Name() 106 | tokens := strings.Split(name, ".") 107 | AllTemplates[tokens[0]], _ = DoGet("/markup/" + name) 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /wasm/scroll.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | func (g *Global) IsBottom() bool { 4 | de := g.Document.Document.Get("documentElement") 5 | db := g.Document.Document.Get("body") 6 | scrollTop := de.Get("scrollTop").Int() 7 | if scrollTop == 0 { 8 | scrollTop = db.Get("scrollTop").Int() 9 | } 10 | windowHeight := g.Window.GetInt("innerHeight") 11 | 12 | a1 := de.Get("scrollHeight").Int() 13 | a2 := db.Get("scrollHeight").Int() 14 | a3 := de.Get("offsetHeight").Int() 15 | a4 := db.Get("offsetHeight").Int() 16 | a5 := de.Get("clientHeight").Int() 17 | a6 := db.Get("clientHeight").Int() 18 | documentHeight := max(a1, a2, a3, a4, a5, a6) 19 | 20 | return scrollTop+windowHeight >= documentHeight-100 21 | } 22 | 23 | func max(numbers ...int) int { 24 | maxValue := numbers[0] 25 | for _, num := range numbers[1:] { 26 | if num > maxValue { 27 | maxValue = num 28 | } 29 | } 30 | 31 | return maxValue 32 | } 33 | -------------------------------------------------------------------------------- /wasm/stack.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | type StackItem struct { 4 | HTML string 5 | Callback func() 6 | } 7 | 8 | func NewStackItem(s string) *StackItem { 9 | si := StackItem{} 10 | si.HTML = s 11 | return &si 12 | } 13 | -------------------------------------------------------------------------------- /wasm/toast.go: -------------------------------------------------------------------------------- 1 | package wasm 2 | 3 | import "time" 4 | 5 | func (g *Global) Toast(s string) { 6 | flash := g.Document.Id("toast") 7 | tn := g.Document.Id("toast-name") 8 | tn.Set("innerHTML", s) 9 | flash.Show() 10 | //flash.Set("innerHTML", s) 11 | time.Sleep(time.Second * 3) 12 | flash.Hide() 13 | } 14 | func (g *Global) ToastFlash(s string) { 15 | flash := g.Document.ById("flash") 16 | flash.Set("innerHTML", s) 17 | time.Sleep(time.Second * 3) 18 | flash.Set("innerHTML", "") 19 | } 20 | --------------------------------------------------------------------------------