├── .dockerignore ├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── Dockerfile ├── README.md ├── fly.toml ├── gleam.toml ├── manifest.toml ├── priv └── static │ ├── assets │ ├── favicon.ico │ ├── gleam-logo.jpg │ └── main.css │ └── vendor │ └── htmx.min.js ├── src ├── todomvc.gleam ├── todomvc │ ├── database.gleam │ ├── error.gleam │ ├── item.gleam │ ├── router.gleam │ ├── templates │ │ ├── completed_cleared.gleam │ │ ├── completed_cleared.matcha │ │ ├── home.gleam │ │ ├── home.matcha │ │ ├── item.gleam │ │ ├── item.matcha │ │ ├── item_changed.gleam │ │ ├── item_changed.matcha │ │ ├── item_created.gleam │ │ ├── item_created.matcha │ │ ├── item_deleted.gleam │ │ └── item_deleted.matcha │ ├── user.gleam │ └── web.gleam └── todomvc_ffi.erl └── test ├── todomvc ├── item_test.gleam ├── routes_test.gleam ├── tests.gleam └── user_test.gleam ├── todomvc_test.gleam └── todomvc_test_helper.erl /.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | bin 3 | test 4 | README.md 5 | secrets.env 6 | Dockerfile 7 | fly.toml 8 | .github 9 | .gitignore 10 | .git 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.matcha linguist-language=html 2 | src/todomvc/templates/**/*.gleam linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0" 18 | gleam-version: "1.3.2" 19 | rebar3-version: "3" 20 | - run: gleam format --check src test 21 | - run: gleam test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | *.sqlite3 4 | build 5 | erl_crash.dump 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": {"*.matcha": "html"} 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "gleam test", 8 | "type": "shell", 9 | "command": "gleam test", 10 | "problemMatcher": [], 11 | "group": { 12 | "kind": "test", 13 | "isDefault": true 14 | }, 15 | "presentation": { 16 | "echo": false, 17 | "reveal": "always", 18 | "focus": false, 19 | "panel": "shared", 20 | "showReuseMessage": false, 21 | "clear": true 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/gleam-lang/gleam:v1.3.2-erlang-alpine 2 | 3 | # Add project code 4 | COPY . /build/ 5 | 6 | # Compile the Gleam application 7 | RUN apk add gcc build-base \ 8 | && cd /build \ 9 | && gleam export erlang-shipment \ 10 | && mv build/erlang-shipment /app \ 11 | && rm -r /build \ 12 | && apk del gcc build-base \ 13 | && addgroup -S todomvc \ 14 | && adduser -S todomvc -G todomvc \ 15 | && chown -R todomvc /app 16 | 17 | # Run the application 18 | USER todomvc 19 | WORKDIR /app 20 | ENTRYPOINT ["/app/entrypoint.sh"] 21 | CMD ["run"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TodoMVC 2 | 3 | An example codebase that shows how to create a web application in Gleam. It is a 4 | backend based implementation of [TodoMVC](https://todomvc.com/) and demonstrates 5 | these features: 6 | 7 | - A HTTP server 8 | - Routing 9 | - CRUD 10 | - Use of a SQLite database 11 | - HTML templates 12 | - Form parsing 13 | - Signed cookie based authentication 14 | - Serving static assets 15 | - Logging 16 | - Testing 17 | 18 | Rather than demonstrate any particular frontend web framework this project uses 19 | [HTMX](https://htmx.org/), a library that adds some new HTML attributes for 20 | declaratively performing AJAX requests. 21 | 22 | ## HTML templates 23 | 24 | The HTML templates are compiled using [matcha](https://github.com/michaeljones/matcha). 25 | 26 | To regenerate the Gleam code from the templates run: 27 | 28 | ```shell 29 | matcha && gleam format . 30 | ``` 31 | 32 | ## Thanks 33 | 34 | Special thanks to [GregGreg](https://gitlab.com/greggreg/gleam_todo) for the 35 | first version of TodoMVC in Gleam. 36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "gleam-todomvc" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [env] 7 | 8 | [experimental] 9 | allowed_public_ports = [] 10 | auto_rollback = true 11 | private_network = true 12 | 13 | [deploy] 14 | strategy = "canary" 15 | 16 | [[services]] 17 | http_checks = [] 18 | internal_port = 3000 19 | processes = ["app"] 20 | protocol = "tcp" 21 | script_checks = [] 22 | 23 | [services.concurrency] 24 | hard_limit = 25 25 | soft_limit = 20 26 | type = "connections" 27 | 28 | [[services.ports]] 29 | handlers = ["http"] 30 | port = 80 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "todomvc" 2 | version = "0.1.0" 3 | gleam = ">= 0.32.0" 4 | 5 | # Fill out these fields if you intend to generate HTML documentation or publish 6 | # your project to the Hex package manager. 7 | # 8 | # licences = ["Apache-2.0"] 9 | # description = "A Gleam library..." 10 | # repository = { type = "github", user = "username", repo = "project" } 11 | # links = [{ title = "Website", href = "https://gleam.run" }] 12 | 13 | [dependencies] 14 | gleam_stdlib = "~> 0.31" 15 | gleam_http = "~> 3.5" 16 | gleam_erlang = "~> 0.22" 17 | gleam_crypto = "~> 1.0" 18 | mist = "~> 1.2" 19 | sqlight = "~> 0.8" 20 | wisp = "~> 0.5" 21 | 22 | [dev-dependencies] 23 | gleeunit = "~> 1.2" 24 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, 6 | { name = "esqlite", version = "0.8.8", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "374902457C7D94DC9409C98D3BDD1CA0D50A60DC9F3BDF1FD8EB74C0DCDF02D6" }, 7 | { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, 8 | { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, 9 | { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, 10 | { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 11 | { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, 12 | { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, 13 | { name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" }, 14 | { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, 15 | { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 16 | { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, 17 | { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, 18 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 19 | { name = "logging", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "FCB111401BDB4703A440A94FF8CC7DA521112269C065F219C2766998333E7738" }, 20 | { name = "marceau", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "5188D643C181EE350D8A20A3BDBD63AF7B6C505DE333CFBE05EF642ADD88A59B" }, 21 | { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, 22 | { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, 23 | { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" }, 24 | { name = "sqlight", version = "0.9.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "2D9C9BA420A5E7DCE7DB2DAAE4CAB0BE6218BEB48FD1531C583550B3D1316E94" }, 25 | { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 26 | { name = "wisp", version = "0.15.0", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "33D17A50276FE0A10E4F694E4EF7D99836954DC2D920D4B5741B1E0EBCAE403F" }, 27 | ] 28 | 29 | [requirements] 30 | gleam_crypto = { version = "~> 1.0" } 31 | gleam_erlang = { version = "~> 0.22" } 32 | gleam_http = { version = "~> 3.5" } 33 | gleam_stdlib = { version = "~> 0.31" } 34 | gleeunit = { version = "~> 1.2" } 35 | mist = { version = "~> 1.2" } 36 | sqlight = { version = "~> 0.8" } 37 | wisp = { version = "~> 0.5" } 38 | -------------------------------------------------------------------------------- /priv/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleam-lang/example-todomvc/6087b311ae45b57a3a63378a7a8062efe7d82f34/priv/static/assets/favicon.ico -------------------------------------------------------------------------------- /priv/static/assets/gleam-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gleam-lang/example-todomvc/6087b311ae45b57a3a63378a7a8062efe7d82f34/priv/static/assets/gleam-logo.jpg -------------------------------------------------------------------------------- /priv/static/assets/main.css: -------------------------------------------------------------------------------- 1 | hr { 2 | margin: 20px 0; 3 | border: 0; 4 | border-top: 1px dashed #c5c5c5; 5 | border-bottom: 1px dashed #f7f7f7; 6 | } 7 | 8 | .learn a { 9 | font-weight: normal; 10 | text-decoration: none; 11 | color: #b83f45; 12 | } 13 | 14 | .learn a:hover { 15 | text-decoration: underline; 16 | color: #787e7e; 17 | } 18 | 19 | .learn h3, 20 | .learn h4, 21 | .learn h5 { 22 | margin: 10px 0; 23 | font-weight: 500; 24 | line-height: 1.2; 25 | color: #000; 26 | } 27 | 28 | .learn h3 { 29 | font-size: 24px; 30 | } 31 | 32 | .learn h4 { 33 | font-size: 18px; 34 | } 35 | 36 | .learn h5 { 37 | margin-bottom: 0; 38 | font-size: 14px; 39 | } 40 | 41 | .learn ul { 42 | padding: 0; 43 | margin: 0 0 30px 25px; 44 | } 45 | 46 | .learn li { 47 | line-height: 20px; 48 | } 49 | 50 | .learn p { 51 | font-size: 15px; 52 | font-weight: 300; 53 | line-height: 1.3; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | } 57 | 58 | #issue-count { 59 | display: none; 60 | } 61 | 62 | .quote { 63 | border: none; 64 | margin: 20px 0 60px 0; 65 | } 66 | 67 | .quote p { 68 | font-style: italic; 69 | } 70 | 71 | .quote p:before { 72 | content: '“'; 73 | font-size: 50px; 74 | opacity: .15; 75 | position: absolute; 76 | top: -20px; 77 | left: 3px; 78 | } 79 | 80 | .quote p:after { 81 | content: '”'; 82 | font-size: 50px; 83 | opacity: .15; 84 | position: absolute; 85 | bottom: -42px; 86 | right: 3px; 87 | } 88 | 89 | .quote footer { 90 | position: absolute; 91 | bottom: -40px; 92 | right: 0; 93 | } 94 | 95 | .quote footer img { 96 | border-radius: 3px; 97 | } 98 | 99 | .quote footer a { 100 | margin-left: 5px; 101 | vertical-align: middle; 102 | } 103 | 104 | .speech-bubble { 105 | position: relative; 106 | padding: 10px; 107 | background: rgba(0, 0, 0, .04); 108 | border-radius: 5px; 109 | } 110 | 111 | .speech-bubble:after { 112 | content: ''; 113 | position: absolute; 114 | top: 100%; 115 | right: 30px; 116 | border: 13px solid transparent; 117 | border-top-color: rgba(0, 0, 0, .04); 118 | } 119 | 120 | .learn-bar > .learn { 121 | position: absolute; 122 | width: 272px; 123 | top: 8px; 124 | left: -300px; 125 | padding: 10px; 126 | border-radius: 5px; 127 | background-color: rgba(255, 255, 255, .6); 128 | transition-property: left; 129 | transition-duration: 500ms; 130 | } 131 | 132 | @media (min-width: 899px) { 133 | .learn-bar { 134 | width: auto; 135 | padding-left: 300px; 136 | } 137 | 138 | .learn-bar > .learn { 139 | left: 8px; 140 | } 141 | } 142 | 143 | 144 | html, 145 | body { 146 | margin: 0; 147 | padding: 0; 148 | } 149 | 150 | button { 151 | margin: 0; 152 | padding: 0; 153 | border: 0; 154 | background: none; 155 | font-size: 100%; 156 | vertical-align: baseline; 157 | font-family: inherit; 158 | font-weight: inherit; 159 | color: inherit; 160 | -webkit-appearance: none; 161 | appearance: none; 162 | -webkit-font-smoothing: antialiased; 163 | -moz-osx-font-smoothing: grayscale; 164 | } 165 | 166 | body { 167 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 168 | line-height: 1.4em; 169 | background: #f5f5f5; 170 | color: #4d4d4d; 171 | min-width: 230px; 172 | max-width: 550px; 173 | margin: 0 auto; 174 | -webkit-font-smoothing: antialiased; 175 | -moz-osx-font-smoothing: grayscale; 176 | font-weight: 300; 177 | } 178 | 179 | :focus { 180 | outline: 0; 181 | } 182 | 183 | .hidden { 184 | display: none; 185 | } 186 | 187 | .todoapp { 188 | background: #fff; 189 | margin: 130px 0 40px 0; 190 | position: relative; 191 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 192 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 193 | } 194 | 195 | .todoapp input::-webkit-input-placeholder { 196 | font-style: italic; 197 | font-weight: 300; 198 | color: #e6e6e6; 199 | } 200 | 201 | .todoapp input::-moz-placeholder { 202 | font-style: italic; 203 | font-weight: 300; 204 | color: #e6e6e6; 205 | } 206 | 207 | .todoapp input::input-placeholder { 208 | font-style: italic; 209 | font-weight: 300; 210 | color: #e6e6e6; 211 | } 212 | 213 | .todoapp h1 { 214 | position: absolute; 215 | top: -155px; 216 | width: 100%; 217 | font-size: 100px; 218 | font-weight: 100; 219 | text-align: center; 220 | color: rgba(175, 47, 47, 0.15); 221 | -webkit-text-rendering: optimizeLegibility; 222 | -moz-text-rendering: optimizeLegibility; 223 | text-rendering: optimizeLegibility; 224 | } 225 | 226 | .new-todo, 227 | .edit { 228 | position: relative; 229 | margin: 0; 230 | width: 100%; 231 | font-size: 24px; 232 | font-family: inherit; 233 | font-weight: inherit; 234 | line-height: 1.4em; 235 | border: 0; 236 | color: inherit; 237 | padding: 6px; 238 | border: 1px solid #999; 239 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 240 | box-sizing: border-box; 241 | -webkit-font-smoothing: antialiased; 242 | -moz-osx-font-smoothing: grayscale; 243 | } 244 | 245 | .new-todo { 246 | padding: 16px 16px 16px 60px; 247 | border: none; 248 | background: rgba(0, 0, 0, 0.003); 249 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 250 | } 251 | 252 | .main { 253 | position: relative; 254 | z-index: 2; 255 | border-top: 1px solid #e6e6e6; 256 | } 257 | 258 | .toggle-all { 259 | text-align: center; 260 | border: none; /* Mobile Safari */ 261 | opacity: 0; 262 | position: absolute; 263 | } 264 | 265 | .toggle-all + label { 266 | width: 60px; 267 | height: 34px; 268 | font-size: 0; 269 | position: absolute; 270 | top: -52px; 271 | left: -13px; 272 | -webkit-transform: rotate(90deg); 273 | transform: rotate(90deg); 274 | } 275 | 276 | .toggle-all + label:before { 277 | content: '❯'; 278 | font-size: 22px; 279 | color: #e6e6e6; 280 | padding: 10px 27px 10px 27px; 281 | } 282 | 283 | .toggle-all:checked + label:before { 284 | color: #737373; 285 | } 286 | 287 | .todo-list { 288 | margin: 0; 289 | padding: 0; 290 | list-style: none; 291 | } 292 | 293 | .todo-list li { 294 | position: relative; 295 | font-size: 24px; 296 | border-bottom: 1px solid #ededed; 297 | } 298 | 299 | .todo-list li:last-child { 300 | border-bottom: none; 301 | } 302 | 303 | .todo-list li.editing { 304 | border-bottom: none; 305 | padding: 0; 306 | } 307 | 308 | .todo-list li.editing .edit { 309 | display: block; 310 | width: 506px; 311 | padding: 12px 16px; 312 | margin: 0 0 0 43px; 313 | } 314 | 315 | .todo-list li.editing .view { 316 | display: none; 317 | } 318 | 319 | .todo-list li .toggle { 320 | text-align: center; 321 | width: 40px; 322 | /* auto, since non-WebKit browsers doesn't support input styling */ 323 | height: auto; 324 | position: absolute; 325 | top: 0; 326 | bottom: 0; 327 | margin: auto 0; 328 | border: none; /* Mobile Safari */ 329 | -webkit-appearance: none; 330 | appearance: none; 331 | } 332 | 333 | .todo-list li .toggle { 334 | opacity: 0; 335 | } 336 | 337 | .todo-list li .toggle + label { 338 | /* 339 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 340 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 341 | */ 342 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 343 | background-repeat: no-repeat; 344 | background-position: center left; 345 | } 346 | 347 | .todo-list li .toggle:checked + label { 348 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 349 | } 350 | 351 | .todo-list li label { 352 | word-break: break-all; 353 | padding: 15px 15px 15px 60px; 354 | display: block; 355 | line-height: 1.2; 356 | transition: color 0.4s; 357 | } 358 | 359 | .todo-list li.completed label { 360 | color: #d9d9d9; 361 | text-decoration: line-through; 362 | } 363 | 364 | .todo-list li .destroy { 365 | display: none; 366 | position: absolute; 367 | top: 0; 368 | right: 10px; 369 | bottom: 0; 370 | width: 40px; 371 | height: 40px; 372 | margin: auto 0; 373 | font-size: 30px; 374 | color: #cc9a9a; 375 | margin-bottom: 11px; 376 | transition: color 0.2s ease-out; 377 | } 378 | 379 | .todo-list li .destroy:hover { 380 | display: block; 381 | color: #af5b5e; 382 | cursor: pointer; 383 | } 384 | 385 | .todo-list li .destroy:after { 386 | content: 'x'; 387 | } 388 | 389 | .todo-list li:hover .destroy { 390 | display: block; 391 | } 392 | 393 | .todo-list li .edit { 394 | display: none; 395 | } 396 | 397 | .todo-list li.editing:last-child { 398 | margin-bottom: -1px; 399 | } 400 | 401 | .footer { 402 | color: #777; 403 | padding: 10px 15px; 404 | height: 20px; 405 | text-align: center; 406 | border-top: 1px solid #e6e6e6; 407 | } 408 | 409 | .footer:before { 410 | content: ''; 411 | position: absolute; 412 | right: 0; 413 | bottom: 0; 414 | left: 0; 415 | height: 50px; 416 | overflow: hidden; 417 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 418 | 0 8px 0 -3px #f6f6f6, 419 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 420 | 0 16px 0 -6px #f6f6f6, 421 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 422 | } 423 | 424 | .todo-count { 425 | float: left; 426 | text-align: left; 427 | } 428 | 429 | .todo-count strong { 430 | font-weight: 300; 431 | } 432 | 433 | .filters { 434 | margin: 0; 435 | padding: 0; 436 | list-style: none; 437 | position: absolute; 438 | right: 0; 439 | left: 0; 440 | } 441 | 442 | .filters li { 443 | display: inline; 444 | } 445 | 446 | .filters li a { 447 | color: inherit; 448 | margin: 3px; 449 | padding: 3px 7px; 450 | text-decoration: none; 451 | border: 1px solid transparent; 452 | border-radius: 3px; 453 | } 454 | 455 | .filters li a:hover { 456 | border-color: rgba(175, 47, 47, 0.1); 457 | } 458 | 459 | .filters li a.selected { 460 | border-color: rgba(175, 47, 47, 0.2); 461 | } 462 | 463 | .clear-completed, 464 | html .clear-completed:active { 465 | float: right; 466 | position: relative; 467 | line-height: 20px; 468 | text-decoration: none; 469 | cursor: pointer; 470 | } 471 | 472 | .clear-completed:hover { 473 | text-decoration: underline; 474 | } 475 | 476 | .info { 477 | margin: 65px auto 0; 478 | color: #bfbfbf; 479 | font-size: 10px; 480 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 481 | text-align: center; 482 | } 483 | 484 | .info p { 485 | line-height: 1; 486 | } 487 | 488 | .info a { 489 | color: inherit; 490 | text-decoration: none; 491 | font-weight: 400; 492 | } 493 | 494 | .info a:hover { 495 | text-decoration: underline; 496 | } 497 | 498 | /* 499 | Hack to remove background from Mobile Safari. 500 | Can't use it globally since it destroys checkboxes in Firefox 501 | */ 502 | @media screen and (-webkit-min-device-pixel-ratio:0) { 503 | .toggle-all, 504 | .todo-list li .toggle { 505 | background: none; 506 | } 507 | 508 | .todo-list li .toggle { 509 | height: 40px; 510 | } 511 | } 512 | 513 | @media (max-width: 430px) { 514 | .footer { 515 | height: 50px; 516 | } 517 | 518 | .filters { 519 | bottom: 10px; 520 | } 521 | } 522 | 523 | 524 | /* override styles */ 525 | form.todo-mark { 526 | position: absolute; 527 | top: 0; 528 | bottom: 0; 529 | left: 0; 530 | width: 49px; 531 | } 532 | 533 | form.todo-mark button { 534 | height: 100%; 535 | width: 100%; 536 | cursor: pointer; 537 | } 538 | 539 | #logo { 540 | width: 26% 541 | } 542 | 543 | .todo-list li .edit-btn { 544 | display: none; 545 | position: absolute; 546 | top: 0; 547 | right: 36px; 548 | bottom: 0; 549 | width: 40px; 550 | height: 40px; 551 | margin: auto 0; 552 | margin-bottom: auto; 553 | font-size: 26px; 554 | color: #cc9a9a; 555 | margin-bottom: -3px; 556 | transition: color 0.2s ease-out; 557 | text-decoration: none; 558 | } 559 | 560 | .todo-list li:hover .edit-btn { 561 | display: block; 562 | cursor: pointer; 563 | } 564 | 565 | .todo-list li .edit-btn:hover { 566 | color: #af5b5e; 567 | } 568 | -------------------------------------------------------------------------------- /priv/static/vendor/htmx.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else{e.htmx=t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var D={onLoad:t,process:rt,on:N,off:I,trigger:lt,ajax:$t,find:w,findAll:S,closest:O,values:function(e,t){var r=Ot(e,t||"post");return r.values},remove:E,addClass:C,removeClass:R,toggleClass:q,takeClass:L,defineExtension:Qt,removeExtension:er,logAll:b,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth"},parseInterval:h,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){return new WebSocket(e,[])},version:"1.6.1"};var r=["get","post","put","delete","patch"];var n=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function h(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}return parseFloat(e)||undefined}function c(e,t){return e.getAttribute&&e.getAttribute(t)}function s(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function F(e,t){return c(e,t)||c(e,"data-"+t)}function l(e){return e.parentElement}function P(){return document}function d(e,t){if(t(e)){return e}else if(l(e)){return d(l(e),t)}else{return null}}function X(e,t){var r=null;d(e,function(e){return r=F(e,t)});if(r!=="unset"){return r}}function v(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function i(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function o(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=P().createDocumentFragment()}return i}function u(e){if(D.config.useTemplateFragments){var t=o("",0);return t.querySelector("template").content}else{var r=i(e);switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return o(""+e+"
",1);case"col":return o(""+e+"
",2);case"tr":return o(""+e+"
",2);case"td":case"th":return o(""+e+"
",3);case"script":return o("
"+e+"
",1);default:return o(e,0)}}}function U(e){if(e){e()}}function a(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function f(e){return a(e,"Function")}function g(e){return a(e,"Object")}function j(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function p(e){var t=[];if(e){for(var r=0;r=0}function z(e){return P().body.contains(e)}function y(e){return e.trim().split(/\s+/)}function V(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function x(e){try{return JSON.parse(e)}catch(e){ut(e);return null}}function e(e){return Ut(P().body,function(){return eval(e)})}function t(t){var e=D.on("htmx:load",function(e){t(e.detail.elt)});return e}function b(){D.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function w(e,t){if(t){return e.querySelector(t)}else{return w(P(),e)}}function S(e,t){if(t){return e.querySelectorAll(t)}else{return S(P(),e)}}function E(e,t){e=H(e);if(t){setTimeout(function(){E(e)},t)}else{e.parentElement.removeChild(e)}}function C(e,t,r){e=H(e);if(r){setTimeout(function(){C(e,t)},r)}else{e.classList&&e.classList.add(t)}}function R(e,t,r){e=H(e);if(r){setTimeout(function(){R(e,t)},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function q(e,t){e=H(e);e.classList.toggle(t)}function L(e,t){e=H(e);B(e.parentElement.children,function(e){R(e,t)});C(e,t)}function O(e,t){e=H(e);if(e.closest){return e.closest(t)}else{do{if(e==null||v(e,t)){return e}}while(e=e&&l(e))}}function T(e,t){if(t.indexOf("closest ")===0){return[O(e,t.substr(8))]}else if(t.indexOf("find ")===0){return[w(e,t.substr(5))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else{return P().querySelectorAll(t)}}function A(e,t){if(t){return T(e,t)[0]}else{return T(P().body,e)[0]}}function H(e){if(a(e,"String")){return w(e)}else{return e}}function k(e,t,r){if(f(t)){return{target:P().body,event:e,listener:t}}else{return{target:H(e),event:t,listener:r}}}function N(t,r,n){rr(function(){var e=k(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=f(r);return e?r:n}function I(t,r,n){rr(function(){var e=k(t,r,n);e.target.removeEventListener(e.event,e.listener)});return f(r)?r:n}function _(e){var t=d(e,function(e){return F(e,"hx-target")!==null});if(t){var r=F(t,"hx-target");if(r==="this"){return t}else{return A(e,r)}}else{var n=j(e);if(n.boosted){return P().body}else{return e}}}function M(e){var t=D.config.attributesToSettle;for(var r=0;r0){i=e.substr(0,e.indexOf(":"));n=e.substr(e.indexOf(":")+1,e.length)}else{i=e}var o=P().querySelector(n);if(o){var a;a=P().createDocumentFragment();a.appendChild(t);if(!$(i,o)){a=t}le(i,o,o,a,r)}else{t.parentNode.removeChild(t);ot(P().body,"htmx:oobErrorNoTarget",{content:t})}return e}function Z(e,r){B(S(e,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){var t=F(e,"hx-swap-oob");if(t!=null){J(t,e,r)}})}function G(e){B(S(e,"[hx-preserve], [data-hx-preserve]"),function(e){var t=F(e,"id");var r=P().getElementById(t);if(r!=null){e.parentNode.replaceChild(r,e)}})}function K(n,e,i){B(e.querySelectorAll("[id]"),function(e){if(e.id&&e.id.length>0){var t=n.querySelector(e.tagName+"[id='"+e.id+"']");if(t&&t!==n){var r=e.cloneNode();W(e,t);i.tasks.push(function(){W(e,r)})}}})}function Y(e){return function(){R(e,D.config.addedClass);rt(e);Ke(e);Q(e);lt(e,"htmx:load")}}function Q(e){var t="[autofocus]";var r=v(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function ee(e,t,r,n){K(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;C(i,D.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Y(i))}}}function te(t){var e=j(t);if(e.webSocket){e.webSocket.close()}if(e.sseEventSource){e.sseEventSource.close()}if(e.listenerInfos){B(e.listenerInfos,function(e){if(t!==e.on){e.on.removeEventListener(e.trigger,e.listener)}})}if(t.children){B(t.children,function(e){te(e)})}}function re(e,t,r){if(e.tagName==="BODY"){return se(e,t,r)}else{var n=e.previousSibling;ee(l(e),e,t,r);if(n==null){var i=l(e).firstChild}else{var i=n.nextSibling}j(e).replacedWith=i;r.elts=[];while(i&&i!==e){if(i.nodeType===Node.ELEMENT_NODE){r.elts.push(i)}i=i.nextElementSibling}te(e);l(e).removeChild(e)}}function ne(e,t,r){return ee(e,e.firstChild,t,r)}function ie(e,t,r){return ee(l(e),e,t,r)}function oe(e,t,r){return ee(e,null,t,r)}function ae(e,t,r){return ee(l(e),e.nextSibling,t,r)}function se(e,t,r){var n=e.firstChild;ee(e,n,t,r);if(n){while(n.nextSibling){te(n.nextSibling);e.removeChild(n.nextSibling)}te(n);e.removeChild(n)}}function ue(e,t){var r=X(e,"hx-select");if(r){var n=P().createDocumentFragment();B(t.querySelectorAll(r),function(e){n.appendChild(e)});t=n}return t}function le(e,t,r,n,i){switch(e){case"none":return;case"outerHTML":re(r,n,i);return;case"afterbegin":ne(r,n,i);return;case"beforebegin":ie(r,n,i);return;case"beforeend":oe(r,n,i);return;case"afterend":ae(r,n,i);return;default:var o=tr(t);for(var a=0;a-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function ce(e,t,r,n,i){var o=fe(n);if(o){var a=w("title");if(a){a.innerHTML=o}else{window.document.title=o}}var s=u(n);if(s){Z(s,i);s=ue(r,s);G(s);return le(e,r,t,s,i)}}function he(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=x(n);for(var o in i){if(i.hasOwnProperty(o)){var a=i[o];if(!g(a)){a={value:a}}lt(r,o,a)}}}else{lt(r,n,[])}}var de=/\s/;var ve=/[\s,]/;var ge=/[_$a-zA-Z]/;var pe=/[_$a-zA-Z0-9]/;var me=['"',"'","/"];var ye=/[^\s]/;function xe(e){var t=[];var r=0;while(r0){var a=t[0];if(a==="]"){n--;if(n===0){if(o===null){i=i+"true"}t.shift();i+=")})";try{var s=Ut(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){ot(P().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(a==="["){n++}if(be(a,o,r)){i+="(("+r+"."+a+") ? ("+r+"."+a+") : (window."+a+"))"}else{i=i+a}o=t.shift()}}}function Se(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}var Ee="input, textarea, select";function Ce(e){var t=F(e,"hx-trigger");var r=[];if(t){var n=xe(t);do{Se(n,ye);var i=n.length;var o=Se(n,/[,\[\s]/);if(o!==""){if(o==="every"){var a={trigger:"every"};Se(n,ye);a.pollInterval=h(Se(n,/[,\[\s]/));Se(n,ye);var s=we(e,n,"event");if(s){a.eventFilter=s}r.push(a)}else if(o.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var s=we(e,n,"event");if(s){u.eventFilter=s}while(n.length>0&&n[0]!==","){Se(n,ye);var l=n.shift();if(l==="changed"){u.changed=true}else if(l==="once"){u.once=true}else if(l==="consume"){u.consume=true}else if(l==="delay"&&n[0]===":"){n.shift();u.delay=h(Se(n,ve))}else if(l==="from"&&n[0]===":"){n.shift();let e=Se(n,ve);if(e==="closest"||e==="find"){n.shift();e+=" "+Se(n,ve)}u.from=e}else if(l==="target"&&n[0]===":"){n.shift();u.target=Se(n,ve)}else if(l==="throttle"&&n[0]===":"){n.shift();u.throttle=h(Se(n,ve))}else if(l==="queue"&&n[0]===":"){n.shift();u.queue=Se(n,ve)}else if((l==="root"||l==="threshold")&&n[0]===":"){n.shift();u[l]=Se(n,ve)}else{ot(e,"htmx:syntax:error",{token:n.shift()})}}r.push(u)}}if(n.length===i){ot(e,"htmx:syntax:error",{token:n.shift()})}Se(n,ye)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(v(e,"form")){return[{trigger:"submit"}]}else if(v(e,Ee)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function Re(e){j(e).cancelled=true}function qe(e,t,r,n){var i=j(e);i.timeout=setTimeout(function(){if(z(e)&&i.cancelled!==true){if(!He(n,it("hx:poll:trigger",{triggerSpec:n}))){Zt(t,r,e)}qe(e,t,F(e,"hx-"+t),n)}},n.pollInterval)}function Le(e){return location.hostname===e.hostname&&c(e,"href")&&c(e,"href").indexOf("#")!==0}function Oe(t,r,e){if(t.tagName==="A"&&Le(t)&&t.target===""||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=c(t,"href");r.pushURL=true}else{var o=c(t,"method");n=o?o.toLowerCase():"get";if(n==="get"){r.pushURL=true}i=c(t,"action")}e.forEach(function(e){ke(t,n,i,r,e,true)})}}function Te(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(v(t,'input[type="submit"], button')&&O(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function Ae(e,t){return j(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function He(e,t){var r=e.eventFilter;if(r){try{return r(t)!==true}catch(e){ot(P().body,"htmx:eventFilter:error",{error:e,source:r.source});return true}}return false}function ke(o,a,s,e,u,l){var t;if(u.from){t=T(o,u.from)}else{t=[o]}B(t,function(n){var i=function(e){if(!z(o)){n.removeEventListener(u.trigger,i);return}if(Ae(o,e)){return}if(l||Te(e,o)){e.preventDefault()}if(He(u,e)){return}var t=j(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}var r=j(o);if(t.handledFor.indexOf(o)<0){t.handledFor.push(o);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!v(e.target,u.target)){return}}if(u.once){if(r.triggeredOnce){return}else{r.triggeredOnce=true}}if(u.changed){if(r.lastValue===o.value){return}else{r.lastValue=o.value}}if(r.delayed){clearTimeout(r.delayed)}if(r.throttle){return}if(u.throttle){if(!r.throttle){Zt(a,s,o,e);r.throttle=setTimeout(function(){r.throttle=null},u.throttle)}}else if(u.delay){r.delayed=setTimeout(function(){Zt(a,s,o,e)},u.delay)}else{Zt(a,s,o,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:n});n.addEventListener(u.trigger,i)})}var Ne=false;var Ie=null;function Me(){if(!Ie){Ie=function(){Ne=true};window.addEventListener("scroll",Ie);setInterval(function(){if(Ne){Ne=false;B(P().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){De(e)})}},200)}}function De(e){if(!s(e,"data-hx-revealed")&&m(e)){e.setAttribute("data-hx-revealed","true");var t=j(e);if(t.initialized){Zt(t.verb,t.path,e)}else{e.addEventListener("htmx:afterProcessNode",function(){Zt(t.verb,t.path,e)},{once:true})}}}function Fe(e,t,r){var n=y(r);for(var i=0;i=0){var t=je(n);setTimeout(function(){Pe(s,r,n+1)},t)}};t.onopen=function(e){n=0};j(s).webSocket=t;t.addEventListener("message",function(e){if(Xe(s)){return}var t=e.data;st(s,function(e){t=e.transformResponse(t,null,s)});var r=Ft(s);var n=u(t);var i=p(n.children);for(var o=0;o0){lt(l,"htmx:validation:halted",i);return}t.send(JSON.stringify(u));if(Te(e,l)){e.preventDefault()}})}else{ot(l,"htmx:noWebSocketSourceError")}}function je(e){var t=D.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}ut('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function Be(e,t,r){var n=y(r);for(var i=0;iD.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ot(P().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function dt(e){var t=x(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){lt(P().body,"htmx:historyCacheMissLoad",i);var e=u(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=ct();var r=Ft(t);se(t,e,r);mt(r.tasks);ft=n;lt(P().body,"htmx:historyRestore",{path:n})}else{ot(P().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function xt(e){gt();e=e||location.pathname+location.search;var t=dt(e);if(t){var r=u(t.content);var n=ct();var i=Ft(n);se(n,r,i);mt(i.tasks);document.title=t.title;window.scrollTo(0,t.scroll);ft=e;lt(P().body,"htmx:historyRestore",{path:e})}else{if(D.config.refreshOnHistoryMiss){window.location.reload(true)}else{yt(e)}}}function bt(e){var t=X(e,"hx-push-url");return t&&t!=="false"||j(e).boosted&&j(e).pushURL}function wt(e){var t=X(e,"hx-push-url");return t==="true"||t==="false"?null:t}function St(e){var t=X(e,"hx-indicator");if(t){var r=T(e,t)}else{r=[e]}B(r,function(e){e.classList["add"].call(e.classList,D.config.requestClass)});return r}function Et(e){B(e,function(e){e.classList["remove"].call(e.classList,D.config.requestClass)})}function Ct(e,t){for(var r=0;r=0}function Mt(e){var t=X(e,"hx-swap");var r={swapStyle:j(e).boosted?"innerHTML":D.config.defaultSwapStyle,swapDelay:D.config.defaultSwapDelay,settleDelay:D.config.defaultSettleDelay};if(j(e).boosted&&!It(e)){r["show"]="top"}if(t){var n=y(t);if(n.length>0){r["swapStyle"]=n[0];for(var i=1;i0?s.join(":"):null;r["scroll"]=u;r["scrollTarget"]=l}if(o.indexOf("show:")===0){var f=o.substr(5);var s=f.split(":");var c=s.pop();var l=s.length>0?s.join(":"):null;r["show"]=c;r["showTarget"]=l}}}}return r}function Dt(t,r,n){var i=null;st(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(X(r,"hx-encoding")==="multipart/form-data"||v(r,"form")&&c(r,"enctype")==="multipart/form-data"){return Ht(n)}else{return At(n)}}}function Ft(e){return{tasks:[],elts:[e]}}function Pt(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=A(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var o=t.showTarget;if(t.showTarget==="window"){o="body"}i=A(r,o)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:D.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:D.config.scrollBehavior})}}}function Xt(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=F(e,t);if(i){var o=i.trim();var a=r;if(o.indexOf("javascript:")===0){o=o.substr(11);a=true}else if(o.indexOf("js:")===0){o=o.substr(3);a=true}if(o.indexOf("{")!==0){o="{"+o+"}"}var s;if(a){s=Ut(e,function(){return Function("return ("+o+")")()},{})}else{s=x(o)}for(var u in s){if(s.hasOwnProperty(u)){if(n[u]==null){n[u]=s[u]}}}}return Xt(l(e),t,r,n)}function Ut(e,t,r){if(D.config.allowEval){return t()}else{ot(e,"htmx:evalDisallowedError");return r}}function jt(e,t){return Xt(e,"hx-vars",true,t)}function Bt(e,t){return Xt(e,"hx-vals",false,t)}function zt(e){return V(jt(e),Bt(e))}function Vt(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function _t(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ot(P().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function Wt(e,t){return e.getAllResponseHeaders().match(t)}function $t(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||a(r,"String")){return Zt(e,t,null,null,{targetOverride:H(r),returnPromise:true})}else{return Zt(e,t,H(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:H(r.target),returnPromise:true})}}else{return Zt(e,t,null,null,{returnPromise:true})}}function Jt(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function Zt(e,t,n,r,i){var o=null;var a=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var s=new Promise(function(e,t){o=e;a=t})}if(n==null){n=P().body}var u=i.handler||Gt;if(!z(n)){return}var l=i.targetOverride||_(n);if(l==null){ot(n,"htmx:targetError",{target:F(n,"hx-target")});return}var f=j(n);if(f.requestInFlight){var c="last";if(r){var h=j(r);if(h&&h.triggerSpec&&h.triggerSpec.queue){c=h.triggerSpec.queue}}if(f.queuedRequests==null){f.queuedRequests=[]}if(c==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){Zt(e,t,n,r,i)})}else if(c==="all"){f.queuedRequests.push(function(){Zt(e,t,n,r,i)})}else if(c==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){Zt(e,t,n,r,i)})}return}else{f.requestInFlight=true}var d=function(){f.requestInFlight=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var v=X(n,"hx-prompt");if(v){var g=prompt(v);if(g===null||!lt(n,"htmx:prompt",{prompt:g,target:l})){U(o);d();return s}}var p=X(n,"hx-confirm");if(p){if(!confirm(p)){U(o);d();return s}}var m=new XMLHttpRequest;var y=kt(n,l,g);if(i.headers){y=V(y,i.headers)}var x=Ot(n,e);var b=x.errors;var w=x.values;if(i.values){w=V(w,i.values)}var S=zt(n);var E=V(w,S);var C=Nt(E,n);if(e!=="get"&&X(n,"hx-encoding")==null){y["Content-Type"]="application/x-www-form-urlencoded; charset=UTF-8"}if(t==null||t===""){t=P().location.href}var R=Xt(n,"hx-request");var q={parameters:C,unfilteredParameters:E,headers:y,target:l,verb:e,errors:b,withCredentials:i.credentials||R.credentials||D.config.withCredentials,timeout:i.timeout||R.timeout||D.config.timeout,path:t,triggeringEvent:r};if(!lt(n,"htmx:configRequest",q)){U(o);d();return s}t=q.path;e=q.verb;y=q.headers;C=q.parameters;b=q.errors;if(b&&b.length>0){lt(n,"htmx:validation:halted",q);U(o);d();return s}var L=t.split("#");var O=L[0];var T=L[1];if(e==="get"){var A=O;var H=Object.keys(C).length!==0;if(H){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=At(C);if(T){A+="#"+T}}m.open("GET",A,true)}else{m.open(e.toUpperCase(),t,true)}m.overrideMimeType("text/html");m.withCredentials=q.withCredentials;m.timeout=q.timeout;if(R.noHeaders){}else{for(var k in y){if(y.hasOwnProperty(k)){var N=y[k];Vt(m,k,N)}}}var I={xhr:m,target:l,requestConfig:q,pathInfo:{path:t,finalPath:A,anchor:T}};m.onload=function(){try{var e=Jt(n);u(n,I);Et(M);lt(n,"htmx:afterRequest",I);lt(n,"htmx:afterOnLoad",I);if(!z(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(z(r)){t=r}}if(t){lt(t,"htmx:afterRequest",I);lt(t,"htmx:afterOnLoad",I)}}U(o);d()}catch(e){ot(n,"htmx:onLoadError",V({error:e},I));throw e}};m.onerror=function(){Et(M);ot(n,"htmx:afterRequest",I);ot(n,"htmx:sendError",I);U(a);d()};m.onabort=function(){Et(M);ot(n,"htmx:afterRequest",I);ot(n,"htmx:sendAbort",I);U(a);d()};m.ontimeout=function(){Et(M);ot(n,"htmx:afterRequest",I);ot(n,"htmx:timeout",I);U(a);d()};if(!lt(n,"htmx:beforeRequest",I)){U(o);d();return s}var M=St(n);B(["loadstart","loadend","progress","abort"],function(t){B([m,m.upload],function(e){e.addEventListener(t,function(e){lt(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});lt(n,"htmx:beforeSend",I);m.send(e==="get"?null:Dt(m,n,C));return s}function Gt(a,s){var u=s.xhr;var l=s.target;if(!lt(a,"htmx:beforeOnLoad",s))return;if(Wt(u,/HX-Trigger:/i)){he(u,"HX-Trigger",a)}if(Wt(u,/HX-Push:/i)){var f=u.getResponseHeader("HX-Push")}if(Wt(u,/HX-Redirect:/i)){window.location.href=u.getResponseHeader("HX-Redirect");return}if(Wt(u,/HX-Refresh:/i)){if("true"===u.getResponseHeader("HX-Refresh")){location.reload();return}}if(Wt(u,/HX-Retarget:/i)){s.target=P().querySelector(u.getResponseHeader("HX-Retarget"))}var c=bt(a)||f;var e=u.status>=200&&u.status<400&&u.status!==204;var h=u.response;var t=u.status>=400;var r=V({shouldSwap:e,serverResponse:h,isError:t},s);if(!lt(l,"htmx:beforeSwap",r))return;l=r.target;h=r.serverResponse;t=r.isError;s.failed=t;s.successful=!t;if(r.shouldSwap){if(u.status===286){Re(a)}st(a,function(e){h=e.transformResponse(h,u,a)});if(c){gt()}var d=Mt(a);l.classList.add(D.config.swappingClass);var n=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r=Ft(l);ce(d.swapStyle,l,a,h,r);if(t.elt&&!z(t.elt)&&t.elt.id){var n=document.getElementById(t.elt.id);if(n){if(t.start&&n.setSelectionRange){n.setSelectionRange(t.start,t.end)}n.focus()}}l.classList.remove(D.config.swappingClass);B(r.elts,function(e){if(e.classList){e.classList.add(D.config.settlingClass)}lt(e,"htmx:afterSwap",s)});if(s.pathInfo.anchor){location.hash=s.pathInfo.anchor}if(Wt(u,/HX-Trigger-After-Swap:/i)){var i=a;if(!z(a)){i=P().body}he(u,"HX-Trigger-After-Swap",i)}var o=function(){B(r.tasks,function(e){e.call()});B(r.elts,function(e){if(e.classList){e.classList.remove(D.config.settlingClass)}lt(e,"htmx:afterSettle",s)});if(c){var e=f||wt(a)||_t(u)||s.pathInfo.finalPath||s.pathInfo.path;pt(e);lt(P().body,"htmx:pushedIntoHistory",{path:e})}Pt(r.elts,d);if(Wt(u,/HX-Trigger-After-Settle:/i)){var t=a;if(!z(a)){t=P().body}he(u,"HX-Trigger-After-Settle",t)}};if(d.settleDelay>0){setTimeout(o,d.settleDelay)}else{o()}}catch(e){ot(a,"htmx:swapError",s);throw e}};if(d.swapDelay>0){setTimeout(n,d.swapDelay)}else{n()}}if(t){ot(a,"htmx:responseError",V({error:"Response Status Error Code "+u.status+" from "+s.pathInfo.path},s))}}var Kt={};function Yt(){return{onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Qt(e,t){Kt[e]=V(Yt(),t)}function er(e){delete Kt[e]}function tr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=F(e,"hx-ext");if(t){B(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Kt[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return tr(l(e),r,n)}function rr(e){if(P().readyState!=="loading"){e()}else{P().addEventListener("DOMContentLoaded",e)}}function nr(){if(D.config.includeIndicatorStyles!==false){P().head.insertAdjacentHTML("beforeend","")}}function ir(){var e=P().querySelector('meta[name="htmx-config"]');if(e){return x(e.content)}else{return null}}function or(){var e=ir();if(e){D.config=V(D.config,e)}}rr(function(){or();nr();var e=P().body;rt(e);window.onpopstate=function(e){if(e.state&&e.state.htmx){xt()}};setTimeout(function(){lt(e,"htmx:load",{})},0)});return D}()}); -------------------------------------------------------------------------------- /src/todomvc.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/os 2 | import gleam/erlang/process 3 | import gleam/int 4 | import gleam/result 5 | import mist 6 | import todomvc/database 7 | import todomvc/router 8 | import todomvc/web.{Context} 9 | import wisp 10 | 11 | const db_name = "todomvc.sqlite3" 12 | 13 | pub fn main() { 14 | wisp.configure_logger() 15 | 16 | let port = load_port() 17 | let secret_key_base = load_application_secret() 18 | let assert Ok(priv) = wisp.priv_directory("todomvc") 19 | let assert Ok(_) = database.with_connection(db_name, database.migrate_schema) 20 | 21 | let handle_request = fn(req) { 22 | use db <- database.with_connection(db_name) 23 | let ctx = Context(user_id: 0, db: db, static_path: priv <> "/static") 24 | router.handle_request(req, ctx) 25 | } 26 | 27 | let assert Ok(_) = 28 | wisp.mist_handler(handle_request, secret_key_base) 29 | |> mist.new 30 | |> mist.port(port) 31 | |> mist.start_http 32 | 33 | process.sleep_forever() 34 | } 35 | 36 | fn load_application_secret() -> String { 37 | os.get_env("APPLICATION_SECRET") 38 | |> result.unwrap("27434b28994f498182d459335258fb6e") 39 | } 40 | 41 | fn load_port() -> Int { 42 | os.get_env("PORT") 43 | |> result.then(int.parse) 44 | |> result.unwrap(3000) 45 | } 46 | -------------------------------------------------------------------------------- /src/todomvc/database.gleam: -------------------------------------------------------------------------------- 1 | import gleam/result 2 | import sqlight 3 | import todomvc/error.{type AppError} 4 | 5 | pub type Connection = 6 | sqlight.Connection 7 | 8 | pub fn with_connection(name: String, f: fn(sqlight.Connection) -> a) -> a { 9 | use db <- sqlight.with_connection(name) 10 | let assert Ok(_) = sqlight.exec("pragma foreign_keys = on;", db) 11 | f(db) 12 | } 13 | 14 | /// Run some idempotent DDL to ensure we have the PostgreSQL database schema 15 | /// that we want. This should be run when the application starts. 16 | pub fn migrate_schema(db: sqlight.Connection) -> Result(Nil, AppError) { 17 | sqlight.exec( 18 | " 19 | create table if not exists users ( 20 | id integer primary key autoincrement not null 21 | ) strict; 22 | 23 | create table if not exists items ( 24 | id integer primary key autoincrement not null, 25 | 26 | inserted_at text not null 27 | default current_timestamp, 28 | 29 | completed integer 30 | not null 31 | default 0, 32 | 33 | content text 34 | not null 35 | constraint empty_content check (content != ''), 36 | 37 | user_id integer not null, 38 | foreign key (user_id) 39 | references users (id) 40 | ) strict; 41 | 42 | create index if not exists items_user_id_completed 43 | on items ( 44 | user_id, 45 | completed 46 | );", 47 | db, 48 | ) 49 | |> result.map_error(error.SqlightError) 50 | } 51 | -------------------------------------------------------------------------------- /src/todomvc/error.gleam: -------------------------------------------------------------------------------- 1 | import sqlight 2 | 3 | pub type AppError { 4 | NotFound 5 | MethodNotAllowed 6 | UserNotFound 7 | BadRequest 8 | UnprocessableEntity 9 | ContentRequired 10 | SqlightError(sqlight.Error) 11 | } 12 | -------------------------------------------------------------------------------- /src/todomvc/item.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bool 2 | import gleam/dynamic 3 | import gleam/list 4 | import gleam/result 5 | import sqlight 6 | import todomvc/error.{type AppError} 7 | 8 | pub type Item { 9 | Item(id: Int, completed: Bool, content: String) 10 | } 11 | 12 | pub type Category { 13 | All 14 | Active 15 | Completed 16 | } 17 | 18 | pub type Counts { 19 | Counts(completed: Int, active: Int) 20 | } 21 | 22 | /// Decode an item from a database row. 23 | /// 24 | pub fn item_row_decoder() -> dynamic.Decoder(Item) { 25 | dynamic.decode3( 26 | Item, 27 | dynamic.element(0, dynamic.int), 28 | dynamic.element(1, sqlight.decode_bool), 29 | dynamic.element(2, dynamic.string), 30 | ) 31 | } 32 | 33 | /// Count the number of completed and active items in the database for a user. 34 | /// 35 | pub fn get_counts(user_id: Int, db: sqlight.Connection) -> Counts { 36 | let sql = 37 | " 38 | select 39 | completed, 40 | count(*) 41 | from 42 | items 43 | where 44 | items.user_id = ?1 45 | group by 46 | completed 47 | order by 48 | completed asc 49 | " 50 | let assert Ok(rows) = 51 | sqlight.query( 52 | sql, 53 | on: db, 54 | with: [sqlight.int(user_id)], 55 | expecting: dynamic.tuple2(sqlight.decode_bool, dynamic.int), 56 | ) 57 | let completed = 58 | rows 59 | |> list.key_find(True) 60 | |> result.unwrap(0) 61 | let active = 62 | rows 63 | |> list.key_find(False) 64 | |> result.unwrap(0) 65 | Counts(active: active, completed: completed) 66 | } 67 | 68 | /// Insert a new item for a given user. 69 | /// 70 | pub fn insert_item( 71 | content: String, 72 | user_id: Int, 73 | db: sqlight.Connection, 74 | ) -> Result(Int, AppError) { 75 | let sql = 76 | " 77 | insert into items 78 | (content, user_id) 79 | values 80 | (?1, ?2) 81 | returning 82 | id 83 | " 84 | use rows <- result.then( 85 | sqlight.query( 86 | sql, 87 | on: db, 88 | with: [sqlight.text(content), sqlight.int(user_id)], 89 | expecting: dynamic.element(0, dynamic.int), 90 | ) 91 | |> result.map_error(fn(error) { 92 | case error.code, error.message { 93 | sqlight.ConstraintCheck, "CHECK constraint failed: empty_content" -> 94 | error.ContentRequired 95 | sqlight.ConstraintForeignkey, _ -> error.UserNotFound 96 | _, _ -> error.BadRequest 97 | } 98 | }), 99 | ) 100 | 101 | let assert [id] = rows 102 | Ok(id) 103 | } 104 | 105 | /// Get a specific item for a user. 106 | /// 107 | pub fn get_item( 108 | item_id: Int, 109 | user_id: Int, 110 | db: sqlight.Connection, 111 | ) -> Result(Item, AppError) { 112 | let sql = 113 | " 114 | select 115 | id, 116 | completed, 117 | content 118 | from 119 | items 120 | where 121 | id = ?1 122 | and 123 | user_id = ?2 124 | " 125 | 126 | let assert Ok(rows) = 127 | sqlight.query( 128 | sql, 129 | on: db, 130 | with: [sqlight.int(item_id), sqlight.int(user_id)], 131 | expecting: item_row_decoder(), 132 | ) 133 | 134 | case rows { 135 | [item] -> Ok(item) 136 | _ -> Error(error.NotFound) 137 | } 138 | } 139 | 140 | /// List all the items for a user that have a particular completion state. 141 | /// 142 | pub fn filtered_items( 143 | user_id: Int, 144 | completed: Bool, 145 | db: sqlight.Connection, 146 | ) -> List(Item) { 147 | let sql = 148 | " 149 | select 150 | id, 151 | completed, 152 | content 153 | from 154 | items 155 | where 156 | user_id = ?1 157 | and 158 | completed = ?2 159 | order by 160 | inserted_at asc 161 | " 162 | 163 | let assert Ok(rows) = 164 | sqlight.query( 165 | sql, 166 | on: db, 167 | with: [sqlight.int(user_id), sqlight.bool(completed)], 168 | expecting: item_row_decoder(), 169 | ) 170 | 171 | rows 172 | } 173 | 174 | /// List all the items for a user. 175 | /// 176 | pub fn list_items(user_id: Int, db: sqlight.Connection) -> List(Item) { 177 | let sql = 178 | " 179 | select 180 | id, 181 | completed, 182 | content 183 | from 184 | items 185 | where 186 | user_id = ?1 187 | order by 188 | inserted_at asc 189 | " 190 | 191 | let assert Ok(rows) = 192 | sqlight.query( 193 | sql, 194 | on: db, 195 | with: [sqlight.int(user_id)], 196 | expecting: item_row_decoder(), 197 | ) 198 | 199 | rows 200 | } 201 | 202 | /// Delete a specific item belonging to a user. 203 | /// 204 | pub fn delete_item(item_id: Int, user_id: Int, db: sqlight.Connection) -> Nil { 205 | let sql = 206 | " 207 | delete from 208 | items 209 | where 210 | id = ?1 211 | and 212 | user_id = ?2 213 | " 214 | let assert Ok(_) = 215 | sqlight.query( 216 | sql, 217 | on: db, 218 | with: [sqlight.int(item_id), sqlight.int(user_id)], 219 | expecting: Ok, 220 | ) 221 | Nil 222 | } 223 | 224 | /// Update the content of a specific item belonging to a user. 225 | /// 226 | pub fn update_item( 227 | item_id: Int, 228 | user_id: Int, 229 | content: String, 230 | db: sqlight.Connection, 231 | ) -> Result(Item, AppError) { 232 | let sql = 233 | " 234 | update 235 | items 236 | set 237 | content = ?3 238 | where 239 | id = ?1 240 | and 241 | user_id = ?2 242 | returning 243 | id, 244 | completed, 245 | content 246 | " 247 | let assert Ok(rows) = 248 | sqlight.query( 249 | sql, 250 | on: db, 251 | with: [sqlight.int(item_id), sqlight.int(user_id), sqlight.text(content)], 252 | expecting: item_row_decoder(), 253 | ) 254 | case rows { 255 | [item] -> Ok(item) 256 | _ -> Error(error.NotFound) 257 | } 258 | } 259 | 260 | /// Delete a specific item belonging to a user. 261 | /// 262 | pub fn delete_completed(user_id: Int, db: sqlight.Connection) -> Nil { 263 | let sql = 264 | " 265 | delete from 266 | items 267 | where 268 | user_id = ?1 269 | and 270 | completed = true 271 | " 272 | let assert Ok(_) = 273 | sqlight.query(sql, on: db, with: [sqlight.int(user_id)], expecting: Ok) 274 | Nil 275 | } 276 | 277 | /// Toggle the completion state for specific item belonging to a user. 278 | /// 279 | pub fn toggle_completion( 280 | item_id: Int, 281 | user_id: Int, 282 | db: sqlight.Connection, 283 | ) -> Result(Item, AppError) { 284 | let sql = 285 | " 286 | update 287 | items 288 | set 289 | completed = not completed 290 | where 291 | id = ?1 292 | and 293 | user_id = ?2 294 | returning 295 | id, 296 | completed, 297 | content 298 | " 299 | let assert Ok(rows) = 300 | sqlight.query( 301 | sql, 302 | on: db, 303 | with: [sqlight.int(item_id), sqlight.int(user_id)], 304 | expecting: item_row_decoder(), 305 | ) 306 | 307 | case rows { 308 | [completed] -> Ok(completed) 309 | _ -> Error(error.NotFound) 310 | } 311 | } 312 | 313 | pub fn any_completed(counts: Counts) -> Bool { 314 | counts.completed > 0 315 | } 316 | 317 | pub fn is_member(item: Item, category: Category) -> Bool { 318 | case category { 319 | All -> True 320 | Completed -> item.completed 321 | Active -> bool.negate(item.completed) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/todomvc/router.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http 2 | import gleam/list 3 | import gleam/result 4 | import gleam/string 5 | import todomvc/item.{type Category, Item} 6 | import todomvc/templates/completed_cleared as completed_cleared_template 7 | import todomvc/templates/home as home_template 8 | import todomvc/templates/item as item_template 9 | import todomvc/templates/item_changed as item_changed_template 10 | import todomvc/templates/item_created as item_created_template 11 | import todomvc/templates/item_deleted as item_deleted_template 12 | import todomvc/web.{type Context} 13 | import wisp.{type Request, type Response} 14 | 15 | pub fn handle_request(req: Request, ctx: Context) -> Response { 16 | let req = wisp.method_override(req) 17 | use <- wisp.log_request(req) 18 | use <- wisp.rescue_crashes 19 | use req <- wisp.handle_head(req) 20 | use ctx <- web.authenticate(req, ctx) 21 | use <- wisp.serve_static(req, under: "/", from: ctx.static_path) 22 | 23 | case wisp.path_segments(req) { 24 | [] -> home(ctx, item.All) 25 | ["active"] -> home(ctx, item.Active) 26 | ["completed"] -> completed(req, ctx) 27 | ["todos"] -> todos(req, ctx) 28 | ["todos", id] -> todo_item(req, ctx, id) 29 | ["todos", id, "completion"] -> item_completion(req, ctx, id) 30 | _ -> wisp.not_found() 31 | } 32 | } 33 | 34 | fn home(ctx: Context, category: Category) -> Response { 35 | let items = case category { 36 | item.All -> item.list_items(ctx.user_id, ctx.db) 37 | item.Active -> item.filtered_items(ctx.user_id, False, ctx.db) 38 | item.Completed -> item.filtered_items(ctx.user_id, True, ctx.db) 39 | } 40 | let counts = item.get_counts(ctx.user_id, ctx.db) 41 | 42 | home_template.render_builder(items, counts, category) 43 | |> wisp.html_response(200) 44 | } 45 | 46 | fn completed(request: Request, ctx: Context) -> Response { 47 | case request.method { 48 | http.Get -> home(ctx, item.Completed) 49 | http.Delete -> delete_completed(request, ctx) 50 | _ -> wisp.method_not_allowed([http.Get, http.Delete]) 51 | } 52 | } 53 | 54 | fn delete_completed(request: Request, ctx: Context) -> Response { 55 | item.delete_completed(ctx.user_id, ctx.db) 56 | let counts = item.get_counts(ctx.user_id, ctx.db) 57 | let items = case current_category(request) { 58 | item.All | item.Active -> item.list_items(ctx.user_id, ctx.db) 59 | item.Completed -> [] 60 | } 61 | 62 | completed_cleared_template.render_builder(items, counts) 63 | |> wisp.html_response(201) 64 | } 65 | 66 | fn todos(request: Request, ctx: Context) -> Response { 67 | case request.method { 68 | http.Post -> create_todo(request, ctx) 69 | _ -> wisp.method_not_allowed([http.Post]) 70 | } 71 | } 72 | 73 | fn create_todo(request: Request, ctx: Context) -> Response { 74 | use params <- wisp.require_form(request) 75 | 76 | let result = { 77 | use content <- result.try(web.key_find(params.values, "content")) 78 | use id <- result.try(item.insert_item(content, ctx.user_id, ctx.db)) 79 | Ok(Item(id: id, completed: False, content: content)) 80 | } 81 | use item <- web.require_ok(result) 82 | 83 | let counts = item.get_counts(ctx.user_id, ctx.db) 84 | let display = item.is_member(item, current_category(request)) 85 | 86 | item_created_template.render_builder(item, counts, display) 87 | |> wisp.html_response(201) 88 | } 89 | 90 | fn todo_item(request: Request, ctx: Context, id: String) -> Response { 91 | case request.method { 92 | http.Get -> get_todo_edit_form(ctx, id) 93 | http.Delete -> delete_item(ctx, id) 94 | http.Patch -> update_todo(request, ctx, id) 95 | _ -> wisp.method_not_allowed([http.Get, http.Delete, http.Patch]) 96 | } 97 | } 98 | 99 | fn get_todo_edit_form(ctx: Context, id: String) -> Response { 100 | let result = { 101 | use id <- result.try(web.parse_int(id)) 102 | item.get_item(id, ctx.user_id, ctx.db) 103 | } 104 | use item <- web.require_ok(result) 105 | 106 | item_template.render_builder(item, True) 107 | |> wisp.html_response(200) 108 | } 109 | 110 | fn update_todo(request: Request, ctx: Context, id: String) -> Response { 111 | use form <- wisp.require_form(request) 112 | let result = { 113 | use id <- result.try(web.parse_int(id)) 114 | use content <- result.try(web.key_find(form.values, "content")) 115 | item.update_item(id, ctx.user_id, content, ctx.db) 116 | } 117 | use item <- web.require_ok(result) 118 | 119 | item_template.render_builder(item, True) 120 | |> wisp.html_response(200) 121 | } 122 | 123 | fn delete_item(ctx: Context, id: String) -> Response { 124 | use id <- web.require_ok(web.parse_int(id)) 125 | item.delete_item(id, ctx.user_id, ctx.db) 126 | 127 | item.get_counts(ctx.user_id, ctx.db) 128 | |> item_deleted_template.render_builder 129 | |> wisp.html_response(200) 130 | } 131 | 132 | fn item_completion(request: Request, ctx: Context, id: String) -> Response { 133 | let result = { 134 | use id <- result.try(web.parse_int(id)) 135 | item.toggle_completion(id, ctx.user_id, ctx.db) 136 | } 137 | use item <- web.require_ok(result) 138 | 139 | let counts = item.get_counts(ctx.user_id, ctx.db) 140 | let display = item.is_member(item, current_category(request)) 141 | 142 | item_changed_template.render_builder(item, counts, display) 143 | |> wisp.html_response(200) 144 | } 145 | 146 | fn current_category(request: Request) -> Category { 147 | let current_url = 148 | request.headers 149 | |> list.key_find("hx-current-url") 150 | |> result.unwrap("") 151 | case string.contains(current_url, "/active") { 152 | True -> item.Active 153 | False -> 154 | case string.contains(current_url, "/completed") { 155 | True -> item.Completed 156 | False -> item.All 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/todomvc/templates/completed_cleared.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from completed_cleared.matcha 2 | 3 | import gleam/int 4 | import gleam/list 5 | import gleam/string_builder.{type StringBuilder} 6 | import todomvc/item.{type Counts, type Item} 7 | import todomvc/templates/item as item_template 8 | 9 | pub fn render_builder( 10 | items items: List(Item), 11 | counts counts: Counts, 12 | ) -> StringBuilder { 13 | let builder = string_builder.from_string("") 14 | let builder = 15 | string_builder.append( 16 | builder, 17 | " 18 | ", 19 | ) 20 | let builder = 21 | string_builder.append( 22 | builder, 23 | " 24 |
    25 | ", 26 | ) 27 | let builder = 28 | list.fold(items, builder, fn(builder, item: Item) { 29 | let builder = 30 | string_builder.append( 31 | builder, 32 | " 33 | ", 34 | ) 35 | let builder = 36 | string_builder.append_builder( 37 | builder, 38 | item_template.render_builder(item, False), 39 | ) 40 | let builder = 41 | string_builder.append( 42 | builder, 43 | " 44 | ", 45 | ) 46 | 47 | builder 48 | }) 49 | let builder = 50 | string_builder.append( 51 | builder, 52 | " 53 |
54 | 55 | 56 | ", 57 | ) 58 | let builder = string_builder.append(builder, int.to_string(counts.active)) 59 | let builder = 60 | string_builder.append( 61 | builder, 62 | " todos left 63 | 64 | ", 65 | ) 66 | 67 | builder 68 | } 69 | 70 | pub fn render(items items: List(Item), counts counts: Counts) -> String { 71 | string_builder.to_string(render_builder(items: items, counts: counts)) 72 | } 73 | -------------------------------------------------------------------------------- /src/todomvc/templates/completed_cleared.matcha: -------------------------------------------------------------------------------- 1 | {> with items as List(Item) 2 | {> with counts as Counts 3 | 4 | {> import todomvc/templates/item as item_template 5 | {> import todomvc/item.{Item, Counts} 6 | {> import gleam/int 7 | 8 |
    9 | {% for item as Item in items %} 10 | {[ item_template.render_builder(item,False) ]} 11 | {% endfor %} 12 |
13 | 14 | 15 | {{ int.to_string(counts.active) }} todos left 16 | 17 | -------------------------------------------------------------------------------- /src/todomvc/templates/home.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from home.matcha 2 | 3 | import gleam/int 4 | import gleam/list 5 | import gleam/string_builder.{type StringBuilder} 6 | import todomvc/item.{type Category, type Counts, type Item} 7 | import todomvc/templates/item as item_template 8 | 9 | pub fn render_builder( 10 | items items: List(Item), 11 | counts counts: Counts, 12 | category category: Category, 13 | ) -> StringBuilder { 14 | let builder = string_builder.from_string("") 15 | let builder = 16 | string_builder.append( 17 | builder, 18 | " 19 | ", 20 | ) 21 | let builder = 22 | string_builder.append( 23 | builder, 24 | " 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | TodoMVC in Gleam 34 | 35 | 36 | 64 | 65 |
66 |
67 |
68 |

todos

69 |
70 | 79 |
80 |
81 | 82 |
83 |
    84 | ", 85 | ) 86 | let builder = 87 | list.fold(items, builder, fn(builder, item: Item) { 88 | let builder = 89 | string_builder.append( 90 | builder, 91 | " 92 | ", 93 | ) 94 | let builder = 95 | string_builder.append_builder( 96 | builder, 97 | item_template.render_builder(item, False), 98 | ) 99 | let builder = 100 | string_builder.append( 101 | builder, 102 | " 103 | ", 104 | ) 105 | 106 | builder 107 | }) 108 | let builder = 109 | string_builder.append( 110 | builder, 111 | " 112 |
113 |
114 | 115 | 218 |
219 | 220 | 227 |
228 | 229 | 230 | ", 231 | ) 232 | 233 | builder 234 | } 235 | 236 | pub fn render( 237 | items items: List(Item), 238 | counts counts: Counts, 239 | category category: Category, 240 | ) -> String { 241 | string_builder.to_string(render_builder( 242 | items: items, 243 | counts: counts, 244 | category: category, 245 | )) 246 | } 247 | -------------------------------------------------------------------------------- /src/todomvc/templates/home.matcha: -------------------------------------------------------------------------------- 1 | {> with items as List(Item) 2 | {> with counts as Counts 3 | {> with category as Category 4 | 5 | {> import todomvc/templates/item as item_template 6 | {> import todomvc/item.{Item, Counts, Category} 7 | {> import gleam/int 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | TodoMVC in Gleam 18 | 19 | 20 | 48 | 49 |
50 |
51 |
52 |

todos

53 |
54 | 63 |
64 |
65 | 66 |
67 |
    68 | {% for item as Item in items %} 69 | {[ item_template.render_builder(item,False) ]} 70 | {% endfor %} 71 |
72 |
73 | 74 |
75 | 76 | {{ int.to_string(counts.active) }} todos left 77 | 78 | 89 | 90 | 99 |
100 |
101 | 102 | 109 |
110 | 111 | 112 | -------------------------------------------------------------------------------- /src/todomvc/templates/item.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from item.matcha 2 | 3 | import gleam/int 4 | import gleam/string_builder.{type StringBuilder} 5 | import todomvc/item.{type Item} 6 | import wisp 7 | 8 | pub fn render_builder(item item: Item, editing editing: Bool) -> StringBuilder { 9 | let builder = string_builder.from_string("") 10 | let builder = 11 | string_builder.append( 12 | builder, 13 | " 14 | ", 15 | ) 16 | let builder = 17 | string_builder.append( 18 | builder, 19 | " 20 |
  • { 32 | let builder = string_builder.append(builder, "completed") 33 | 34 | builder 35 | } 36 | False -> { 37 | builder 38 | } 39 | } 40 | let builder = string_builder.append(builder, " ") 41 | let builder = case editing { 42 | True -> { 43 | let builder = string_builder.append(builder, "editing") 44 | 45 | builder 46 | } 47 | False -> { 48 | builder 49 | } 50 | } 51 | let builder = 52 | string_builder.append( 53 | builder, 54 | "\" 55 | > 56 |
    57 | { 65 | let builder = string_builder.append(builder, "checked") 66 | 67 | builder 68 | } 69 | False -> { 70 | builder 71 | } 72 | } 73 | let builder = 74 | string_builder.append( 75 | builder, 76 | " 77 | > 78 | 79 | 88 | 89 | 106 | 107 | 124 | 125 | 126 |
    144 | 145 |
    146 |
    147 | 148 | { 153 | let builder = 154 | string_builder.append( 155 | builder, 156 | " 157 | autofocus 158 | onfocus=\"this.setSelectionRange(this.value.length,this.value.length)\" 159 | ", 160 | ) 161 | 162 | builder 163 | } 164 | False -> { 165 | builder 166 | } 167 | } 168 | let builder = 169 | string_builder.append( 170 | builder, 171 | " 172 | required 173 | maxlength=\"500\" 174 | class=\"edit\" 175 | type=\"text\" 176 | name=\"content\" 177 | value=\"", 178 | ) 179 | let builder = string_builder.append(builder, wisp.escape_html(item.content)) 180 | let builder = 181 | string_builder.append( 182 | builder, 183 | "\" 184 | hx-patch=\"/todos/", 185 | ) 186 | let builder = string_builder.append(builder, int.to_string(item.id)) 187 | let builder = 188 | string_builder.append( 189 | builder, 190 | "\" 191 | hx-target=\"#item-", 192 | ) 193 | let builder = string_builder.append(builder, int.to_string(item.id)) 194 | let builder = 195 | string_builder.append( 196 | builder, 197 | "\" 198 | hx-swap=\"outerHTML\" 199 | hx-trigger=\"blur,keypress[key == 'Enter']\" 200 | > 201 | ", 202 | ) 203 | 204 | builder 205 | } 206 | 207 | pub fn render(item item: Item, editing editing: Bool) -> String { 208 | string_builder.to_string(render_builder(item: item, editing: editing)) 209 | } 210 | -------------------------------------------------------------------------------- /src/todomvc/templates/item.matcha: -------------------------------------------------------------------------------- 1 | {> with item as Item 2 | {> with editing as Bool 3 | 4 | {> import gleam/int 5 | {> import todomvc/item.{Item} 6 | {> import wisp 7 | 8 |
  • 12 |
    13 | 19 | 20 | 23 | 24 | 29 | 30 | 35 | 36 | 37 |
    43 | 44 |
    45 |
    46 | 47 | 63 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_changed.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from item_changed.matcha 2 | 3 | import gleam/int 4 | import gleam/string_builder.{type StringBuilder} 5 | import todomvc/item.{type Counts, type Item} 6 | import todomvc/templates/item as item_template 7 | 8 | pub fn render_builder( 9 | item item: Item, 10 | counts counts: Counts, 11 | display display: Bool, 12 | ) -> StringBuilder { 13 | let builder = string_builder.from_string("") 14 | let builder = 15 | string_builder.append( 16 | builder, 17 | " 18 | ", 19 | ) 20 | let builder = 21 | string_builder.append( 22 | builder, 23 | " 24 | ", 25 | ) 26 | let builder = case display { 27 | True -> { 28 | let builder = 29 | string_builder.append( 30 | builder, 31 | " 32 | ", 33 | ) 34 | let builder = 35 | string_builder.append_builder( 36 | builder, 37 | item_template.render_builder(item, False), 38 | ) 39 | let builder = 40 | string_builder.append( 41 | builder, 42 | " 43 | ", 44 | ) 45 | 46 | builder 47 | } 48 | False -> { 49 | builder 50 | } 51 | } 52 | let builder = 53 | string_builder.append( 54 | builder, 55 | " 56 | 57 |
    58 | ", 59 | ) 60 | let builder = case item.any_completed(counts) { 61 | True -> { 62 | let builder = 63 | string_builder.append( 64 | builder, 65 | " 66 | Clear Completed (", 67 | ) 68 | let builder = 69 | string_builder.append(builder, int.to_string(counts.completed)) 70 | let builder = 71 | string_builder.append( 72 | builder, 73 | ") 74 | ", 75 | ) 76 | 77 | builder 78 | } 79 | False -> { 80 | builder 81 | } 82 | } 83 | let builder = 84 | string_builder.append( 85 | builder, 86 | " 87 |
    88 | 89 | 90 | ", 91 | ) 92 | let builder = string_builder.append(builder, int.to_string(counts.active)) 93 | let builder = 94 | string_builder.append( 95 | builder, 96 | " todos left 97 | 98 | ", 99 | ) 100 | 101 | builder 102 | } 103 | 104 | pub fn render( 105 | item item: Item, 106 | counts counts: Counts, 107 | display display: Bool, 108 | ) -> String { 109 | string_builder.to_string(render_builder( 110 | item: item, 111 | counts: counts, 112 | display: display, 113 | )) 114 | } 115 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_changed.matcha: -------------------------------------------------------------------------------- 1 | {> with item as Item 2 | {> with counts as Counts 3 | {> with display as Bool 4 | 5 | {> import todomvc/templates/item as item_template 6 | {> import todomvc/item.{Item, Counts} 7 | {> import gleam/int 8 | 9 | {% if display %} 10 | {[ item_template.render_builder(item,False) ]} 11 | {% endif %} 12 | 13 |
    14 | {% if item.any_completed(counts) %} 15 | Clear Completed ({{ int.to_string(counts.completed) }}) 16 | {% endif %} 17 |
    18 | 19 | 20 | {{ int.to_string(counts.active) }} todos left 21 | 22 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_created.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from item_created.matcha 2 | 3 | import gleam/int 4 | import gleam/string_builder.{type StringBuilder} 5 | import todomvc/item.{type Counts, type Item} 6 | import todomvc/templates/item as item_template 7 | 8 | pub fn render_builder( 9 | item item: Item, 10 | counts counts: Counts, 11 | display display: Bool, 12 | ) -> StringBuilder { 13 | let builder = string_builder.from_string("") 14 | let builder = 15 | string_builder.append( 16 | builder, 17 | " 18 | ", 19 | ) 20 | let builder = 21 | string_builder.append( 22 | builder, 23 | " 24 | 33 | 34 | ", 35 | ) 36 | let builder = case display { 37 | True -> { 38 | let builder = 39 | string_builder.append( 40 | builder, 41 | " 42 |
    43 | ", 44 | ) 45 | let builder = 46 | string_builder.append_builder( 47 | builder, 48 | item_template.render_builder(item, False), 49 | ) 50 | let builder = 51 | string_builder.append( 52 | builder, 53 | " 54 |
    55 | ", 56 | ) 57 | 58 | builder 59 | } 60 | False -> { 61 | builder 62 | } 63 | } 64 | let builder = 65 | string_builder.append( 66 | builder, 67 | " 68 | 69 |
    70 | ", 71 | ) 72 | let builder = case item.any_completed(counts) { 73 | True -> { 74 | let builder = 75 | string_builder.append( 76 | builder, 77 | " 78 | Clear Completed (", 79 | ) 80 | let builder = 81 | string_builder.append(builder, int.to_string(counts.completed)) 82 | let builder = 83 | string_builder.append( 84 | builder, 85 | ") 86 | ", 87 | ) 88 | 89 | builder 90 | } 91 | False -> { 92 | builder 93 | } 94 | } 95 | let builder = 96 | string_builder.append( 97 | builder, 98 | " 99 |
    100 | 101 | 102 | ", 103 | ) 104 | let builder = string_builder.append(builder, int.to_string(counts.active)) 105 | let builder = 106 | string_builder.append( 107 | builder, 108 | " todos left 109 | 110 | ", 111 | ) 112 | 113 | builder 114 | } 115 | 116 | pub fn render( 117 | item item: Item, 118 | counts counts: Counts, 119 | display display: Bool, 120 | ) -> String { 121 | string_builder.to_string(render_builder( 122 | item: item, 123 | counts: counts, 124 | display: display, 125 | )) 126 | } 127 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_created.matcha: -------------------------------------------------------------------------------- 1 | {> with item as Item 2 | {> with counts as Counts 3 | {> with display as Bool 4 | 5 | {> import todomvc/templates/item as item_template 6 | {> import todomvc/item.{Item, Counts} 7 | {> import gleam/int 8 | 9 | 18 | 19 | {% if display %} 20 |
    21 | {[ item_template.render_builder(item,False) ]} 22 |
    23 | {% endif %} 24 | 25 |
    26 | {% if item.any_completed(counts) %} 27 | Clear Completed ({{ int.to_string(counts.completed) }}) 28 | {% endif %} 29 |
    30 | 31 | 32 | {{ int.to_string(counts.active) }} todos left 33 | 34 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_deleted.gleam: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT: Code generated by matcha from item_deleted.matcha 2 | 3 | import gleam/int 4 | import gleam/string_builder.{type StringBuilder} 5 | import todomvc/item.{type Counts} 6 | 7 | pub fn render_builder(counts counts: Counts) -> StringBuilder { 8 | let builder = string_builder.from_string("") 9 | let builder = 10 | string_builder.append( 11 | builder, 12 | " 13 | ", 14 | ) 15 | let builder = 16 | string_builder.append( 17 | builder, 18 | " 19 |
    20 | ", 21 | ) 22 | let builder = case item.any_completed(counts) { 23 | True -> { 24 | let builder = 25 | string_builder.append( 26 | builder, 27 | " 28 | Clear Completed (", 29 | ) 30 | let builder = 31 | string_builder.append(builder, int.to_string(counts.completed)) 32 | let builder = 33 | string_builder.append( 34 | builder, 35 | ") 36 | ", 37 | ) 38 | 39 | builder 40 | } 41 | False -> { 42 | builder 43 | } 44 | } 45 | let builder = 46 | string_builder.append( 47 | builder, 48 | " 49 |
    50 | 51 | 52 | ", 53 | ) 54 | let builder = string_builder.append(builder, int.to_string(counts.active)) 55 | let builder = 56 | string_builder.append( 57 | builder, 58 | " todos left 59 | 60 | ", 61 | ) 62 | 63 | builder 64 | } 65 | 66 | pub fn render(counts counts: Counts) -> String { 67 | string_builder.to_string(render_builder(counts: counts)) 68 | } 69 | -------------------------------------------------------------------------------- /src/todomvc/templates/item_deleted.matcha: -------------------------------------------------------------------------------- 1 | {> with counts as Counts 2 | 3 | {> import todomvc/item.{Counts} 4 | {> import gleam/int 5 | 6 |
    7 | {% if item.any_completed(counts) %} 8 | Clear Completed ({{ int.to_string(counts.completed) }}) 9 | {% endif %} 10 |
    11 | 12 | 13 | {{ int.to_string(counts.active) }} todos left 14 | 15 | -------------------------------------------------------------------------------- /src/todomvc/user.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic 2 | import sqlight 3 | 4 | /// Insert a new user, returning their id. 5 | /// 6 | pub fn insert_user(db: sqlight.Connection) -> Int { 7 | let sql = 8 | " 9 | insert into users 10 | default values 11 | returning id; 12 | " 13 | let assert Ok([id]) = 14 | sqlight.query( 15 | sql, 16 | on: db, 17 | with: [], 18 | expecting: dynamic.element(0, dynamic.int), 19 | ) 20 | 21 | id 22 | } 23 | -------------------------------------------------------------------------------- /src/todomvc/web.gleam: -------------------------------------------------------------------------------- 1 | //// Various helper functions for use in the web interface of the application. 2 | //// 3 | 4 | import gleam/http.{Http} 5 | import gleam/http/cookie 6 | import gleam/http/response 7 | import gleam/int 8 | import gleam/list 9 | import gleam/option 10 | import gleam/result 11 | import todomvc/database 12 | import todomvc/error.{type AppError} 13 | import todomvc/user 14 | import wisp.{type Request, type Response} 15 | 16 | pub type Context { 17 | Context(db: database.Connection, user_id: Int, static_path: String) 18 | } 19 | 20 | const uid_cookie = "uid" 21 | 22 | /// Load the user from the `uid` cookie if set, otherwise create a new user row 23 | /// and assign that in the response cookies. 24 | /// 25 | /// The `uid` cookie is signed to prevent tampering. 26 | /// 27 | pub fn authenticate( 28 | req: Request, 29 | ctx: Context, 30 | next: fn(Context) -> Response, 31 | ) -> Response { 32 | let id = 33 | wisp.get_cookie(req, uid_cookie, wisp.Signed) 34 | |> result.try(int.parse) 35 | |> option.from_result 36 | 37 | let #(id, new_user) = case id { 38 | option.None -> { 39 | wisp.log_info("Creating a new user") 40 | let user = user.insert_user(ctx.db) 41 | #(user, True) 42 | } 43 | option.Some(id) -> #(id, False) 44 | } 45 | let context = Context(..ctx, user_id: id) 46 | let resp = next(context) 47 | 48 | case new_user { 49 | True -> { 50 | let id = int.to_string(id) 51 | let year = 60 * 60 * 24 * 365 52 | wisp.set_cookie(resp, req, uid_cookie, id, wisp.Signed, year) 53 | } 54 | False -> resp 55 | } 56 | } 57 | 58 | pub type AppResult = 59 | Result(Response, AppError) 60 | 61 | pub fn result_to_response(result: AppResult) -> Response { 62 | case result { 63 | Ok(response) -> response 64 | Error(error) -> error_to_response(error) 65 | } 66 | } 67 | 68 | pub fn try_(result: Result(t, AppError), next: fn(t) -> Response) -> Response { 69 | case result { 70 | Ok(t) -> next(t) 71 | Error(error) -> error_to_response(error) 72 | } 73 | } 74 | 75 | /// Return an appropriate HTTP response for a given error. 76 | /// 77 | pub fn error_to_response(error: AppError) -> Response { 78 | case error { 79 | error.UserNotFound -> user_not_found() 80 | error.NotFound -> wisp.not_found() 81 | error.MethodNotAllowed -> wisp.method_not_allowed([]) 82 | error.BadRequest -> wisp.bad_request() 83 | error.UnprocessableEntity | error.ContentRequired -> 84 | wisp.unprocessable_entity() 85 | error.SqlightError(_) -> wisp.internal_server_error() 86 | } 87 | } 88 | 89 | pub fn user_not_found() -> Response { 90 | let attributes = 91 | cookie.Attributes(..cookie.defaults(Http), max_age: option.Some(0)) 92 | wisp.not_found() 93 | |> response.set_cookie("uid", "", attributes) 94 | } 95 | 96 | pub fn key_find(list: List(#(k, v)), key: k) -> Result(v, AppError) { 97 | list 98 | |> list.key_find(key) 99 | |> result.replace_error(error.UnprocessableEntity) 100 | } 101 | 102 | pub fn parse_int(string: String) -> Result(Int, AppError) { 103 | string 104 | |> int.parse 105 | |> result.replace_error(error.BadRequest) 106 | } 107 | 108 | pub fn require_ok(t: Result(t, AppError), next: fn(t) -> Response) -> Response { 109 | case t { 110 | Ok(t) -> next(t) 111 | Error(error) -> error_to_response(error) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/todomvc_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(todomvc_ffi). 2 | 3 | -export([configure_logger_backend/0, priv_directory/0]). 4 | 5 | configure_logger_backend() -> 6 | ok = logger:set_primary_config(level, info), 7 | ok = logger:set_handler_config(default, formatter, {logger_formatter, #{ 8 | template => [level, ": ", msg, "\n"] 9 | }}), 10 | ok = logger:set_application_level(stdlib, notice), 11 | nil. 12 | 13 | priv_directory() -> 14 | list_to_binary(code:priv_dir(todomvc)). 15 | -------------------------------------------------------------------------------- /test/todomvc/item_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit/should 2 | import todomvc/error 3 | import todomvc/item.{Item} 4 | import todomvc/tests 5 | import todomvc/user 6 | 7 | pub fn item_creation_test() { 8 | use db <- tests.with_db("") 9 | let user_id = user.insert_user(db) 10 | 11 | // A user starts with no items 12 | item.list_items(user_id, db) 13 | |> should.equal([]) 14 | 15 | // Items can be added 16 | let assert Ok(id1) = item.insert_item("One", user_id, db) 17 | let assert Ok(id2) = item.insert_item("Two", user_id, db) 18 | 19 | item.list_items(user_id, db) 20 | |> should.equal([ 21 | Item(id: id1, content: "One", completed: False), 22 | Item(id: id2, content: "Two", completed: False), 23 | ]) 24 | } 25 | 26 | pub fn item_creation_without_content_test() { 27 | use db <- tests.with_db("") 28 | let user_id = user.insert_user(db) 29 | 30 | // Items cannot be added without content 31 | item.insert_item("", user_id, db) 32 | |> should.equal(Error(error.ContentRequired)) 33 | 34 | item.list_items(user_id, db) 35 | |> should.equal([]) 36 | } 37 | 38 | pub fn item_with_unknown_user_test() { 39 | use db <- tests.with_db("") 40 | // Items cannot be added for an unknown user 41 | item.insert_item("One", -1, db) 42 | |> should.equal(Error(error.UserNotFound)) 43 | } 44 | 45 | pub fn toggle_test() { 46 | use db <- tests.with_db("") 47 | let user_id = user.insert_user(db) 48 | 49 | // Items can be added 50 | let assert Ok(id1) = item.insert_item("One", user_id, db) 51 | 52 | item.toggle_completion(id1, user_id, db) 53 | |> should.equal(Ok(Item(id: id1, completed: True, content: "One"))) 54 | item.toggle_completion(id1, user_id, db) 55 | |> should.equal(Ok(Item(id: id1, completed: False, content: "One"))) 56 | } 57 | 58 | pub fn toggle_unknown_id_test() { 59 | use db <- tests.with_db("") 60 | let user_id = user.insert_user(db) 61 | 62 | item.toggle_completion(0, user_id, db) 63 | |> should.equal(Error(error.NotFound)) 64 | } 65 | 66 | pub fn toggle_user_mismatch_test() { 67 | use db <- tests.with_db("") 68 | let user_id1 = user.insert_user(db) 69 | let user_id2 = user.insert_user(db) 70 | 71 | // Items can be added 72 | let assert Ok(id1) = item.insert_item("One", user_id1, db) 73 | 74 | item.toggle_completion(id1, user_id2, db) 75 | |> should.equal(Error(error.NotFound)) 76 | } 77 | 78 | pub fn counts_test() { 79 | use db <- tests.with_db("") 80 | let user_id1 = user.insert_user(db) 81 | let user_id2 = user.insert_user(db) 82 | 83 | item.get_counts(user_id1, db) 84 | |> should.equal(item.Counts(active: 0, completed: 0)) 85 | 86 | let assert Ok(id1) = item.insert_item("x", user_id1, db) 87 | let assert Ok(id2) = item.insert_item("x", user_id1, db) 88 | let assert Ok(id3) = item.insert_item("x", user_id1, db) 89 | let assert Ok(_id) = item.insert_item("x", user_id1, db) 90 | let assert Ok(_id) = item.insert_item("x", user_id2, db) 91 | let assert Ok(_id) = item.insert_item("x", user_id2, db) 92 | 93 | item.get_counts(user_id1, db) 94 | |> should.equal(item.Counts(active: 4, completed: 0)) 95 | 96 | let assert Ok(_) = item.toggle_completion(id1, user_id1, db) 97 | let assert Ok(_) = item.toggle_completion(id2, user_id1, db) 98 | let assert Ok(_) = item.toggle_completion(id3, user_id1, db) 99 | 100 | item.get_counts(user_id1, db) 101 | |> should.equal(item.Counts(active: 1, completed: 3)) 102 | } 103 | 104 | pub fn delete_test() { 105 | use db <- tests.with_db("") 106 | let user_id = user.insert_user(db) 107 | let assert Ok(id) = item.insert_item("x", user_id, db) 108 | 109 | item.delete_item(id, user_id, db) 110 | 111 | item.list_items(user_id, db) 112 | |> should.equal([]) 113 | 114 | item.delete_item(id, user_id, db) 115 | } 116 | 117 | pub fn delete_other_users_item_test() { 118 | use db <- tests.with_db("") 119 | let user_id1 = user.insert_user(db) 120 | let user_id2 = user.insert_user(db) 121 | let assert Ok(id) = item.insert_item("x", user_id1, db) 122 | 123 | // It belongs to someone else so it can't be deleted 124 | item.delete_item(id, user_id2, db) 125 | 126 | item.list_items(user_id1, db) 127 | |> should.equal([Item(id: id, completed: False, content: "x")]) 128 | } 129 | 130 | pub fn delete_completed_test() { 131 | use db <- tests.with_db("") 132 | let user_id1 = user.insert_user(db) 133 | let user_id2 = user.insert_user(db) 134 | 135 | // Create a bunch of items for both users 136 | let assert Ok(id1) = item.insert_item("x", user_id1, db) 137 | let assert Ok(id2) = item.insert_item("x", user_id1, db) 138 | let assert Ok(id3) = item.insert_item("x", user_id1, db) 139 | let assert Ok(id4) = item.insert_item("x", user_id1, db) 140 | let assert Ok(id5) = item.insert_item("x", user_id2, db) 141 | let assert Ok(id6) = item.insert_item("x", user_id2, db) 142 | 143 | // Mark some items as completed for both users 144 | let assert Ok(_) = item.toggle_completion(id1, user_id1, db) 145 | let assert Ok(_) = item.toggle_completion(id2, user_id1, db) 146 | let assert Ok(_) = item.toggle_completion(id6, user_id2, db) 147 | 148 | // Delete completed items for the first user 149 | item.delete_completed(user_id1, db) 150 | 151 | // Completed items for that user have been deleted 152 | item.list_items(user_id1, db) 153 | |> should.equal([ 154 | Item(id: id3, completed: False, content: "x"), 155 | Item(id: id4, completed: False, content: "x"), 156 | ]) 157 | 158 | // The other user's items were not impacted 159 | item.list_items(user_id2, db) 160 | |> should.equal([ 161 | Item(id: id5, completed: False, content: "x"), 162 | Item(id: id6, completed: True, content: "x"), 163 | ]) 164 | } 165 | -------------------------------------------------------------------------------- /test/todomvc/routes_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/int 2 | import gleam/string 3 | import gleeunit/should 4 | import todomvc/item 5 | import todomvc/router 6 | import todomvc/tests 7 | import todomvc/user 8 | import wisp 9 | import wisp/testing 10 | 11 | pub fn home_test() { 12 | use ctx <- tests.with_context 13 | 14 | let uid1 = user.insert_user(ctx.db) 15 | let assert Ok(_) = item.insert_item("Wibble", uid1, ctx.db) 16 | let assert Ok(_) = item.insert_item("Wobble", uid1, ctx.db) 17 | let uid2 = user.insert_user(ctx.db) 18 | let assert Ok(_) = item.insert_item("Wabble", uid2, ctx.db) 19 | 20 | let request = 21 | testing.get("/", []) 22 | |> testing.set_cookie("uid", int.to_string(uid1), wisp.Signed) 23 | let response = router.handle_request(request, ctx) 24 | 25 | response.status 26 | |> should.equal(200) 27 | 28 | let body = testing.string_body(response) 29 | 30 | body 31 | |> string.contains("Wibble") 32 | |> should.equal(True) 33 | 34 | body 35 | |> string.contains("Wobble") 36 | |> should.equal(True) 37 | 38 | // An item belonging to another user is not included 39 | body 40 | |> string.contains("Wabble") 41 | |> should.equal(False) 42 | } 43 | -------------------------------------------------------------------------------- /test/todomvc/tests.gleam: -------------------------------------------------------------------------------- 1 | import sqlight 2 | import todomvc/database 3 | import todomvc/web.{type Context, Context} 4 | 5 | pub fn with_context(testcase: fn(Context) -> t) -> t { 6 | use db <- with_db("") 7 | let context = Context(db: db, user_id: 0, static_path: "priv/static") 8 | testcase(context) 9 | } 10 | 11 | pub fn with_db(name: String, f: fn(sqlight.Connection) -> a) -> a { 12 | use db <- database.with_connection(name) 13 | let assert Ok(_) = database.migrate_schema(db) 14 | f(db) 15 | } 16 | -------------------------------------------------------------------------------- /test/todomvc/user_test.gleam: -------------------------------------------------------------------------------- 1 | import todomvc/tests 2 | import todomvc/user 3 | 4 | pub fn user_insertion_test() { 5 | use db <- tests.with_db("") 6 | user.insert_user(db) 7 | } 8 | -------------------------------------------------------------------------------- /test/todomvc_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /test/todomvc_test_helper.erl: -------------------------------------------------------------------------------- 1 | -module(todomvc_test_helper). 2 | 3 | -export([ensure/2]). 4 | 5 | ensure(Task, Cleanup) -> 6 | try 7 | Task() 8 | after 9 | Cleanup() 10 | end. 11 | --------------------------------------------------------------------------------