├── .gitignore ├── Caddyfile ├── LICENSE ├── README.md ├── bin ├── build ├── build-production ├── check ├── check-scripts ├── deploy-production ├── format └── serve ├── config └── production │ └── _redirects ├── devbox.json ├── devbox.lock ├── elm.json ├── public ├── index.css └── index.html ├── screenshot.png └── src └── Main.elm /.gitignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .devbox/ 3 | elm-stuff/ 4 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :{$port} 2 | 3 | root * {$build}/application 4 | 5 | try_files {path} / 6 | 7 | file_server { 8 | browse 9 | precompressed br gzip 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018-present Dwayne Crooks 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elm Todos - [Live Demo](https://elm-todos.netlify.app/) 2 | 3 | ![A screenshot of Elm Todos](/screenshot.png) 4 | 5 | An [Elm](https://elm-lang.org/) implementation of the [TodoMVC](https://todomvc.com/)'s to-do list web application. 6 | 7 | ## Develop 8 | 9 | An isolated, reproducible development environment is provided with [Devbox](https://www.jetify.com/devbox). 10 | 11 | You can enter its development environment as follows: 12 | 13 | ```bash 14 | $ devbox shell 15 | ``` 16 | 17 | **N.B.** *To run the Bash scripts mentioned below you will need to enter the development environment.* 18 | 19 | ## Build 20 | 21 | To build the development version of the application: 22 | 23 | ```bash 24 | $ build 25 | ``` 26 | 27 | To build the production version of the application: 28 | 29 | ```bash 30 | $ build-production 31 | ``` 32 | 33 | ## Serve 34 | 35 | To serve the development or production version of the application: 36 | 37 | ```bash 38 | $ serve 39 | ``` 40 | 41 | ## Deploy 42 | 43 | To deploy the production version of the application to [Netlify](https://www.netlify.com/): 44 | 45 | ```bash 46 | $ deploy-production 47 | ``` 48 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: [optimize_html=0*|1] [optimize_css=0*|1] [optimize_js=0*|1|2|3] [compress=0*|1] build 5 | # 6 | 7 | set -euo pipefail 8 | 9 | optimize_html="${optimize_html:-0}" 10 | optimize_css="${optimize_css:-0}" 11 | optimize_js="${optimize_js:-0}" 12 | compress="${compress:-0}" 13 | 14 | src="${project:?}" 15 | out="${build:?}/application" 16 | 17 | clean () { 18 | rm -rf "$out" 19 | } 20 | 21 | prepare () { 22 | mkdir -p "$out" 23 | } 24 | 25 | build_html () { 26 | if [[ "$optimize_html" = 0 ]]; then 27 | cp "$src/public/index.html" "$out" 28 | else 29 | html-minifier \ 30 | --collapse-boolean-attributes \ 31 | --collapse-inline-tag-whitespace \ 32 | --collapse-whitespace \ 33 | --decode-entities \ 34 | --minify-js \ 35 | --remove-comments \ 36 | --remove-empty-attributes \ 37 | --remove-redundant-attributes \ 38 | --remove-script-type-attributes \ 39 | --remove-style-link-type-attributes \ 40 | --remove-tag-whitespace \ 41 | --file-ext html \ 42 | --input-dir "$src/public" \ 43 | --output-dir "$out" 44 | fi 45 | } 46 | 47 | build_css () { 48 | if [[ "$optimize_css" = 0 ]]; then 49 | cp "$src/public/index.css" "$out" 50 | else 51 | lightningcss --minify "$src/public/index.css" --output-file "$out/index.css" 52 | fi 53 | } 54 | 55 | build_js () { 56 | case "${optimize_js}" in 57 | 1|2|3) 58 | func=build_js_optimize_"$optimize_js" 59 | ;; 60 | *) 61 | func=build_js_debug 62 | ;; 63 | esac 64 | 65 | "$func" "$src/src/Main.elm" "$out/app.js" 66 | } 67 | 68 | build_js_debug () { 69 | elm make "$1" --debug --output "$2" 70 | } 71 | 72 | build_js_optimize_1 () { 73 | elm make "$1" --optimize --output "$2" 74 | minify "$2" 75 | } 76 | 77 | build_js_optimize_2 () { 78 | elm-optimize-level-2 "$1" --output "$2" 79 | minify "$2" 80 | } 81 | 82 | build_js_optimize_3 () { 83 | elm-optimize-level-2 "$1" --optimize-speed --output "$2" 84 | minify "$2" 85 | } 86 | 87 | minify () { 88 | js="$1" 89 | min="${js%.js}.min.js" 90 | 91 | terser "$js" --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | terser --mangle --output "$min" 92 | mv "$min" "$js" 93 | } 94 | 95 | compress_assets () { 96 | if [[ "$compress" != 0 ]]; then 97 | cd "$out" && find . \( -name '*.html' -o -name '*.css' -o -name '*.js' \) -exec brotli "{}" \; -exec zopfli "{}" \; 98 | fi 99 | } 100 | 101 | clean && prepare && build_html && build_css && build_js && compress_assets 102 | -------------------------------------------------------------------------------- /bin/build-production: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: build-production 5 | # 6 | 7 | set -euo pipefail 8 | 9 | optimize_html=1 optimize_css=1 optimize_js=2 compress=1 build 10 | -------------------------------------------------------------------------------- /bin/check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: check 5 | # 6 | 7 | set -euo pipefail 8 | 9 | fail () { 10 | echo "$1" >&2 11 | exit 1 12 | } 13 | 14 | # Check scripts 15 | 16 | if ! check-scripts &>/dev/null; then 17 | check-scripts 18 | fi 19 | 20 | # Format 21 | 22 | if ! format --validate &>/dev/null; then 23 | fail "Your code needs to be formatted. Run: format" 24 | fi 25 | 26 | # Build 27 | 28 | if ! build &>/dev/null; then 29 | build 30 | fi 31 | -------------------------------------------------------------------------------- /bin/check-scripts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: check-scripts 5 | # 6 | 7 | set -euo pipefail 8 | 9 | project="${project:?}" 10 | 11 | shellcheck --norc -xP "$project/bin" "$project/bin/"* 12 | # 13 | # --no-rc Don't look for .shellcheckrc files 14 | # -x Allow 'source' outside of FILES 15 | # -P "$project/bin" Specify path when looking for sourced files ("SCRIPTDIR" for script's dir) 16 | # "$project"/bin/* FILES... 17 | # 18 | -------------------------------------------------------------------------------- /bin/deploy-production: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: [simulate=0*|1] deploy-production 5 | # 6 | 7 | set -euo pipefail 8 | 9 | simulate="${simulate:-0}" 10 | 11 | if [[ "$simulate" != 0 ]]; then 12 | echo "ATTENTION!!! This is only a simulation." 13 | set -x 14 | fi 15 | 16 | 17 | # CHECK BRANCH 18 | 19 | 20 | current_branch="$(git branch --show-current)" 21 | if [[ "$current_branch" != master ]]; then 22 | echo "You are currently on the branch: $current_branch" 23 | # 24 | # NOTE: 25 | # 26 | # Usually you'd want to deploy from the master branch. On rare 27 | # occassions you'd deploy from another branch. So deploying 28 | # from a different branch could be a mistake and that's why we 29 | # verify if you want to continue with the deploy. 30 | # 31 | read -r -n 1 -t 30 -p "Are you sure want to continue? (y/N) " 32 | case $REPLY in 33 | y | Y ) 34 | echo 35 | ;; 36 | 37 | * ) 38 | exit 1 39 | esac 40 | fi 41 | 42 | 43 | # BUILD 44 | 45 | 46 | build-production 47 | 48 | 49 | # PREPARE DEPLOY DIRECTORY 50 | 51 | 52 | out="$(mktemp -d -t deploy-XXXXX)" 53 | echo "Prepared the deploy directory: $out" 54 | 55 | 56 | # PREPARE WORKTREE 57 | 58 | 59 | branch_name="netlify" 60 | 61 | if [[ "$simulate" = 0 ]]; then 62 | git worktree add "$out" "$branch_name" 63 | git -C "$out" pull 64 | fi 65 | 66 | 67 | # DEPLOY 68 | 69 | 70 | src="${build:?}/application" 71 | hash="$(git log -n 1 --format='%h' "$current_branch")" 72 | message="Site updated to commit $hash from the $current_branch branch" 73 | 74 | cp -r "$src/"* "$out" 75 | cp -r "${project:?}/config/production/"* "$out" 76 | 77 | if [[ "$simulate" != 0 ]]; then 78 | echo "$message" 79 | else 80 | git -C "$out" add -N . 81 | 82 | if git -C "$out" diff --quiet; then 83 | echo "No changes detected." 84 | else 85 | git -C "$out" add . 86 | git -C "$out" commit -m "$message" 87 | git -C "$out" push -u origin HEAD 88 | fi 89 | fi 90 | 91 | 92 | # CLEAN UP 93 | 94 | 95 | if [[ "$simulate" != 0 ]]; then 96 | echo "Please run \"rm -rf $out\" when you're done" 97 | else 98 | git worktree remove --force "$out" 99 | rm -rf "$out" 100 | 101 | echo "Success!" 102 | fi 103 | -------------------------------------------------------------------------------- /bin/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: format [args-for-elm-format] 5 | # 6 | 7 | set -euo pipefail 8 | 9 | elm-format "${project:?}/src" "${@:---yes}" 10 | -------------------------------------------------------------------------------- /bin/serve: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Usage: serve [8000] 5 | # 6 | 7 | set -euo pipefail 8 | 9 | port="${1:-8000}" 10 | 11 | xdg-open "http://localhost:$port" && \ 12 | port="$port" caddy run --config "${project:?}/Caddyfile" 13 | -------------------------------------------------------------------------------- /config/production/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /devbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | "brotli": "latest", 4 | "caddy": { 5 | "version": "latest", 6 | "disable_plugin": true 7 | }, 8 | "elmPackages.elm": "latest", 9 | "elmPackages.elm-format": "latest", 10 | "elmPackages.elm-optimize-level-2": "latest", 11 | "html-minifier": "latest", 12 | "lightningcss": "latest", 13 | "shellcheck": "latest", 14 | "terser": "latest", 15 | "xdg-utils": "latest", 16 | "zopfli": "latest" 17 | }, 18 | "env": { 19 | "project": "$PWD", 20 | "build": "$PWD/.build", 21 | "PATH": "$PWD/bin:$PATH" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /devbox.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfile_version": "1", 3 | "packages": { 4 | "brotli@latest": { 5 | "last_modified": "2024-06-03T07:19:07Z", 6 | "resolved": "github:NixOS/nixpkgs/4a4ecb0ab415c9fccfb005567a215e6a9564cdf5#brotli", 7 | "source": "devbox-search", 8 | "version": "1.1.0", 9 | "systems": { 10 | "aarch64-darwin": { 11 | "store_path": "/nix/store/nvdacalh5vfgpwgyvx4drydq4gwip9wm-brotli-1.1.0" 12 | }, 13 | "aarch64-linux": { 14 | "store_path": "/nix/store/yxlzmr9ikp9li6vffhwgicj6k661gafm-brotli-1.1.0" 15 | }, 16 | "x86_64-darwin": { 17 | "store_path": "/nix/store/nic4hha0s64pc58pxfng2pidm1p9xjz0-brotli-1.1.0" 18 | }, 19 | "x86_64-linux": { 20 | "store_path": "/nix/store/0xj7i7pnp1xyk09qg8dgsvl60gz1psyl-brotli-1.1.0" 21 | } 22 | } 23 | }, 24 | "caddy@latest": { 25 | "last_modified": "2024-06-04T00:03:09Z", 26 | "resolved": "github:NixOS/nixpkgs/3b01abcc24846ae49957b30f4345bab4b3f1d14b#caddy", 27 | "source": "devbox-search", 28 | "version": "2.8.4", 29 | "systems": { 30 | "aarch64-darwin": { 31 | "store_path": "/nix/store/2lvssmmrmcpsic5gv755x33amh603c3d-caddy-2.8.4" 32 | }, 33 | "aarch64-linux": { 34 | "store_path": "/nix/store/iavbj399v34qfd6pw6rsmh1rqfbyclac-caddy-2.8.4" 35 | }, 36 | "x86_64-darwin": { 37 | "store_path": "/nix/store/wik7wspkxs7q88sjqyfxd4d3ixinwhmz-caddy-2.8.4" 38 | }, 39 | "x86_64-linux": { 40 | "store_path": "/nix/store/1iz89fy5fi998g43z1m4j7s5f095di68-caddy-2.8.4" 41 | } 42 | } 43 | }, 44 | "elmPackages.elm-format@latest": { 45 | "last_modified": "2024-05-26T09:30:02Z", 46 | "resolved": "github:NixOS/nixpkgs/e2dd4e18cc1c7314e24154331bae07df76eb582f#elmPackages.elm-format", 47 | "source": "devbox-search", 48 | "version": "0.8.7", 49 | "systems": { 50 | "aarch64-darwin": { 51 | "store_path": "/nix/store/dzwp16fsk1naxr607b36ig8v7c1q5z9q-elm-format-0.8.7" 52 | }, 53 | "aarch64-linux": { 54 | "store_path": "/nix/store/2s8gfpd8xmf28z9zzk49kxcw7mkjbgk9-elm-format-0.8.7" 55 | }, 56 | "x86_64-darwin": { 57 | "store_path": "/nix/store/krsk8p97dm9f03z1xswn8qdzx61pgqfs-elm-format-0.8.7" 58 | }, 59 | "x86_64-linux": { 60 | "store_path": "/nix/store/9fd994mb4c6z5ixmhplia58b6jrfhbff-elm-format-0.8.7" 61 | } 62 | } 63 | }, 64 | "elmPackages.elm-optimize-level-2@latest": { 65 | "last_modified": "2024-05-22T06:18:38Z", 66 | "resolved": "github:NixOS/nixpkgs/3f316d2a50699a78afe5e77ca486ad553169061e#elmPackages.elm-optimize-level-2", 67 | "source": "devbox-search", 68 | "version": "2-0.3.5", 69 | "systems": { 70 | "aarch64-darwin": { 71 | "store_path": "/nix/store/l4f2pwvpqxnshqamwy7764ilf45lp8ld-elm-optimize-level-2-0.3.5" 72 | }, 73 | "aarch64-linux": { 74 | "store_path": "/nix/store/x2bbn25699ai2g7pf7x7kyw5y1jsvbpk-elm-optimize-level-2-0.3.5" 75 | }, 76 | "x86_64-darwin": { 77 | "store_path": "/nix/store/f7ccpq5zllj8qjplg8l60gfbwgxglipr-elm-optimize-level-2-0.3.5" 78 | }, 79 | "x86_64-linux": { 80 | "store_path": "/nix/store/h3hbwm3qhvj148c991glh6j00fq5wisb-elm-optimize-level-2-0.3.5" 81 | } 82 | } 83 | }, 84 | "elmPackages.elm@latest": { 85 | "last_modified": "2024-05-26T09:30:02Z", 86 | "resolved": "github:NixOS/nixpkgs/e2dd4e18cc1c7314e24154331bae07df76eb582f#elmPackages.elm", 87 | "source": "devbox-search", 88 | "version": "0.19.1", 89 | "systems": { 90 | "aarch64-darwin": { 91 | "store_path": "/nix/store/s76kssmpdmwq1sga48wj5k8hv4q5dbx0-elm-0.19.1" 92 | }, 93 | "aarch64-linux": { 94 | "store_path": "/nix/store/zsl1sgi578yr16nhb6a9d7iayy9v7n0v-elm-0.19.1" 95 | }, 96 | "x86_64-darwin": { 97 | "store_path": "/nix/store/xfx3lqm5x33kmnj4s2n6pr0q6jxjghy5-elm-0.19.1" 98 | }, 99 | "x86_64-linux": { 100 | "store_path": "/nix/store/45ayk9m68a7gn11ygpqx7k47dh3xhcm8-elm-0.19.1" 101 | } 102 | } 103 | }, 104 | "html-minifier@latest": { 105 | "last_modified": "2024-05-22T06:18:38Z", 106 | "resolved": "github:NixOS/nixpkgs/3f316d2a50699a78afe5e77ca486ad553169061e#html-minifier", 107 | "source": "devbox-search", 108 | "version": "4.0.0", 109 | "systems": { 110 | "aarch64-darwin": { 111 | "store_path": "/nix/store/jx8jd3dymnkdvnyhcf97d0r0287xhqma-html-minifier-4.0.0" 112 | }, 113 | "aarch64-linux": { 114 | "store_path": "/nix/store/b1m4786h5rk062cvww4xdlpj6yw0jd6l-html-minifier-4.0.0" 115 | }, 116 | "x86_64-darwin": { 117 | "store_path": "/nix/store/1a5wjlgpx82p1szngjp7dci5f5s8vhjk-html-minifier-4.0.0" 118 | }, 119 | "x86_64-linux": { 120 | "store_path": "/nix/store/r9vf6k8nfpx5irl26k1s0v5w9n9a5a39-html-minifier-4.0.0" 121 | } 122 | } 123 | }, 124 | "lightningcss@latest": { 125 | "last_modified": "2024-05-25T15:35:15Z", 126 | "resolved": "github:NixOS/nixpkgs/c5187508b11177ef4278edf19616f44f21cc8c69#lightningcss", 127 | "source": "devbox-search", 128 | "version": "1.25.1", 129 | "systems": { 130 | "aarch64-darwin": { 131 | "store_path": "/nix/store/gilbrbnkagfwv1lzbv1ryd6aww1dfaq7-lightningcss-1.25.1" 132 | }, 133 | "x86_64-darwin": { 134 | "store_path": "/nix/store/f12llla7w7w7hdc24piws3dlnrpy0hw3-lightningcss-1.25.1" 135 | }, 136 | "x86_64-linux": { 137 | "store_path": "/nix/store/k3vqcm4fvh2ym33rq41k8xbqbz03avi5-lightningcss-1.25.1" 138 | } 139 | } 140 | }, 141 | "shellcheck@latest": { 142 | "last_modified": "2024-05-26T09:30:02Z", 143 | "resolved": "github:NixOS/nixpkgs/e2dd4e18cc1c7314e24154331bae07df76eb582f#shellcheck", 144 | "source": "devbox-search", 145 | "version": "0.10.0", 146 | "systems": { 147 | "aarch64-darwin": { 148 | "store_path": "/nix/store/z31g3b6dz9np69b9wkv8d2jmc1sxjv4d-shellcheck-0.10.0-bin" 149 | }, 150 | "aarch64-linux": { 151 | "store_path": "/nix/store/kl5v5bmjdph4im1y5gxm7yc8r5n6hgyl-shellcheck-0.10.0-bin" 152 | }, 153 | "x86_64-darwin": { 154 | "store_path": "/nix/store/s6mrjj51g7clys555kp6ydr6dhf7smkg-shellcheck-0.10.0-bin" 155 | }, 156 | "x86_64-linux": { 157 | "store_path": "/nix/store/n4ig66d8xjbkliyfawm2mvr31d8zwc9d-shellcheck-0.10.0-bin" 158 | } 159 | } 160 | }, 161 | "terser@latest": { 162 | "last_modified": "2024-05-25T15:35:15Z", 163 | "resolved": "github:NixOS/nixpkgs/c5187508b11177ef4278edf19616f44f21cc8c69#terser", 164 | "source": "devbox-search", 165 | "version": "5.31.0", 166 | "systems": { 167 | "aarch64-darwin": { 168 | "store_path": "/nix/store/fg7afj8iclr6lfzhp5zsr4llv8d1g39j-terser-5.31.0" 169 | }, 170 | "aarch64-linux": { 171 | "store_path": "/nix/store/8700nbwc7jhrf7hh4b2nnkb8r6s39wsw-terser-5.31.0" 172 | }, 173 | "x86_64-darwin": { 174 | "store_path": "/nix/store/k1vrgf68fkn7w65xmm8j7726f3r1nnnx-terser-5.31.0" 175 | }, 176 | "x86_64-linux": { 177 | "store_path": "/nix/store/pxvgzzyss24z6ibjh98ayiw9sayi6fk3-terser-5.31.0" 178 | } 179 | } 180 | }, 181 | "xdg-utils@latest": { 182 | "last_modified": "2024-05-22T06:18:38Z", 183 | "resolved": "github:NixOS/nixpkgs/3f316d2a50699a78afe5e77ca486ad553169061e#xdg-utils", 184 | "source": "devbox-search", 185 | "version": "1.2.1", 186 | "systems": { 187 | "aarch64-darwin": { 188 | "store_path": "/nix/store/kqmcyb069bc70gki3xbh9xb5qjbhab9m-xdg-utils-1.2.1" 189 | }, 190 | "aarch64-linux": { 191 | "store_path": "/nix/store/ij4pk1cdm1iwp9kiyv8bxchqq3pasd66-xdg-utils-1.2.1" 192 | }, 193 | "x86_64-darwin": { 194 | "store_path": "/nix/store/ffif7q6kp3jbdvxbrbvqkhdh3pj47raj-xdg-utils-1.2.1" 195 | }, 196 | "x86_64-linux": { 197 | "store_path": "/nix/store/6a6205zvzyqgmad3a43j6mlyh0kwkac1-xdg-utils-1.2.1" 198 | } 199 | } 200 | }, 201 | "zopfli@latest": { 202 | "last_modified": "2024-05-29T18:25:13Z", 203 | "resolved": "github:NixOS/nixpkgs/f7a63cf975cc66559d5f488ffe6367c987a79826#zopfli", 204 | "source": "devbox-search", 205 | "version": "1.0.3", 206 | "systems": { 207 | "aarch64-darwin": { 208 | "store_path": "/nix/store/r9kmjx0hmmrxfy3wsyv4hig4gnqwhqrc-zopfli-1.0.3" 209 | }, 210 | "aarch64-linux": { 211 | "store_path": "/nix/store/4mddyiqlrbdwkygf3caz57qjbq590g7j-zopfli-1.0.3" 212 | }, 213 | "x86_64-darwin": { 214 | "store_path": "/nix/store/ksfybnqixn4dcxkzyr9wjirf9j0aiqwc-zopfli-1.0.3" 215 | }, 216 | "x86_64-linux": { 217 | "store_path": "/nix/store/bbx73by6f4p5pzvd3c68jkmi0vgj13bp-zopfli-1.0.3" 218 | } 219 | } 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.5", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3", 13 | "elm/url": "1.0.0" 14 | }, 15 | "indirect": { 16 | "elm/time": "1.0.0", 17 | "elm/virtual-dom": "1.0.3" 18 | } 19 | }, 20 | "test-dependencies": { 21 | "direct": {}, 22 | "indirect": {} 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | 3 | html, 4 | body { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | button { 10 | margin: 0; 11 | padding: 0; 12 | border: 0; 13 | background: none; 14 | font-size: 100%; 15 | vertical-align: baseline; 16 | font-family: inherit; 17 | font-weight: inherit; 18 | color: inherit; 19 | -webkit-appearance: none; 20 | appearance: none; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | } 24 | 25 | body { 26 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 27 | line-height: 1.4em; 28 | background: #f5f5f5; 29 | color: #111111; 30 | min-width: 230px; 31 | max-width: 550px; 32 | margin: 0 auto; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | font-weight: 300; 36 | } 37 | 38 | .hidden { 39 | display: none; 40 | } 41 | 42 | .todoapp { 43 | background: #fff; 44 | margin: 130px 0 40px 0; 45 | position: relative; 46 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 47 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 48 | } 49 | 50 | .todoapp input::-webkit-input-placeholder { 51 | font-style: italic; 52 | font-weight: 400; 53 | color: rgba(0, 0, 0, 0.4); 54 | } 55 | 56 | .todoapp input::-moz-placeholder { 57 | font-style: italic; 58 | font-weight: 400; 59 | color: rgba(0, 0, 0, 0.4); 60 | } 61 | 62 | .todoapp input::input-placeholder { 63 | font-style: italic; 64 | font-weight: 400; 65 | color: rgba(0, 0, 0, 0.4); 66 | } 67 | 68 | .todoapp h1 { 69 | position: absolute; 70 | top: -140px; 71 | width: 100%; 72 | font-size: 80px; 73 | font-weight: 200; 74 | text-align: center; 75 | color: #b83f45; 76 | -webkit-text-rendering: optimizeLegibility; 77 | -moz-text-rendering: optimizeLegibility; 78 | text-rendering: optimizeLegibility; 79 | } 80 | 81 | .new-todo, 82 | .edit { 83 | position: relative; 84 | margin: 0; 85 | width: 100%; 86 | font-size: 24px; 87 | font-family: inherit; 88 | font-weight: inherit; 89 | line-height: 1.4em; 90 | color: inherit; 91 | padding: 6px; 92 | border: 1px solid #999; 93 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 94 | box-sizing: border-box; 95 | -webkit-font-smoothing: antialiased; 96 | -moz-osx-font-smoothing: grayscale; 97 | } 98 | 99 | .new-todo { 100 | padding: 16px 16px 16px 60px; 101 | height: 65px; 102 | border: none; 103 | background: rgba(0, 0, 0, 0.003); 104 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 105 | } 106 | 107 | .main { 108 | position: relative; 109 | z-index: 2; 110 | border-top: 1px solid #e6e6e6; 111 | } 112 | 113 | .toggle-all { 114 | width: 1px; 115 | height: 1px; 116 | border: none; /* Mobile Safari */ 117 | opacity: 0; 118 | position: absolute; 119 | right: 100%; 120 | bottom: 100%; 121 | } 122 | 123 | .toggle-all + label { 124 | display: flex; 125 | align-items: center; 126 | justify-content: center; 127 | width: 45px; 128 | height: 65px; 129 | font-size: 0; 130 | position: absolute; 131 | top: -65px; 132 | left: -0; 133 | } 134 | 135 | .toggle-all + label:before { 136 | content: '❯'; 137 | display: inline-block; 138 | font-size: 22px; 139 | color: #949494; 140 | padding: 10px 27px 10px 27px; 141 | -webkit-transform: rotate(90deg); 142 | transform: rotate(90deg); 143 | } 144 | 145 | .toggle-all:checked + label:before { 146 | color: #484848; 147 | } 148 | 149 | .todo-list { 150 | margin: 0; 151 | padding: 0; 152 | list-style: none; 153 | } 154 | 155 | .todo-list li { 156 | position: relative; 157 | font-size: 24px; 158 | border-bottom: 1px solid #ededed; 159 | } 160 | 161 | .todo-list li:last-child { 162 | border-bottom: none; 163 | } 164 | 165 | .todo-list li.editing { 166 | border-bottom: none; 167 | padding: 0; 168 | } 169 | 170 | .todo-list li.editing .edit { 171 | display: block; 172 | width: calc(100% - 43px); 173 | padding: 12px 16px; 174 | margin: 0 0 0 43px; 175 | } 176 | 177 | .todo-list li.editing .view { 178 | display: none; 179 | } 180 | 181 | .todo-list li .toggle { 182 | text-align: center; 183 | width: 40px; 184 | /* auto, since non-WebKit browsers doesn't support input styling */ 185 | height: auto; 186 | position: absolute; 187 | top: 0; 188 | bottom: 0; 189 | margin: auto 0; 190 | border: none; /* Mobile Safari */ 191 | -webkit-appearance: none; 192 | appearance: none; 193 | } 194 | 195 | .todo-list li .toggle { 196 | opacity: 0; 197 | } 198 | 199 | .todo-list li .toggle + label { 200 | /* 201 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 202 | 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/ 203 | */ 204 | 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%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 205 | background-repeat: no-repeat; 206 | background-position: center left; 207 | } 208 | 209 | .todo-list li .toggle:checked + label { 210 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%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%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); 211 | } 212 | 213 | .todo-list li label { 214 | overflow-wrap: break-word; 215 | padding: 15px 15px 15px 60px; 216 | display: block; 217 | line-height: 1.2; 218 | transition: color 0.4s; 219 | font-weight: 400; 220 | color: #484848; 221 | } 222 | 223 | .todo-list li.completed label { 224 | color: #949494; 225 | text-decoration: line-through; 226 | } 227 | 228 | .todo-list li .destroy { 229 | display: none; 230 | position: absolute; 231 | top: 0; 232 | right: 10px; 233 | bottom: 0; 234 | width: 40px; 235 | height: 40px; 236 | margin: auto 0; 237 | font-size: 30px; 238 | color: #949494; 239 | transition: color 0.2s ease-out; 240 | } 241 | 242 | .todo-list li .destroy:hover, 243 | .todo-list li .destroy:focus { 244 | color: #C18585; 245 | } 246 | 247 | .todo-list li .destroy:after { 248 | content: '×'; 249 | display: block; 250 | height: 100%; 251 | line-height: 1.1; 252 | } 253 | 254 | .todo-list li:hover .destroy { 255 | display: block; 256 | } 257 | 258 | .todo-list li .edit { 259 | display: none; 260 | } 261 | 262 | .todo-list li.editing:last-child { 263 | margin-bottom: -1px; 264 | } 265 | 266 | .footer { 267 | padding: 10px 15px; 268 | height: 20px; 269 | text-align: center; 270 | font-size: 15px; 271 | border-top: 1px solid #e6e6e6; 272 | } 273 | 274 | .footer:before { 275 | content: ''; 276 | position: absolute; 277 | right: 0; 278 | bottom: 0; 279 | left: 0; 280 | height: 50px; 281 | overflow: hidden; 282 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 283 | 0 8px 0 -3px #f6f6f6, 284 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 285 | 0 16px 0 -6px #f6f6f6, 286 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 287 | } 288 | 289 | .todo-count { 290 | float: left; 291 | text-align: left; 292 | } 293 | 294 | .todo-count strong { 295 | font-weight: 300; 296 | } 297 | 298 | .filters { 299 | margin: 0; 300 | padding: 0; 301 | list-style: none; 302 | position: absolute; 303 | right: 0; 304 | left: 0; 305 | } 306 | 307 | .filters li { 308 | display: inline; 309 | } 310 | 311 | .filters li a { 312 | color: inherit; 313 | margin: 3px; 314 | padding: 3px 7px; 315 | text-decoration: none; 316 | border: 1px solid transparent; 317 | border-radius: 3px; 318 | } 319 | 320 | .filters li a:hover { 321 | border-color: #DB7676; 322 | } 323 | 324 | .filters li a.selected { 325 | border-color: #CE4646; 326 | } 327 | 328 | .clear-completed, 329 | html .clear-completed:active { 330 | float: right; 331 | position: relative; 332 | line-height: 19px; 333 | text-decoration: none; 334 | cursor: pointer; 335 | } 336 | 337 | .clear-completed:hover { 338 | text-decoration: underline; 339 | } 340 | 341 | .info { 342 | margin: 65px auto 0; 343 | color: #4d4d4d; 344 | font-size: 11px; 345 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 346 | text-align: center; 347 | } 348 | 349 | .info p { 350 | line-height: 1; 351 | } 352 | 353 | .info a { 354 | color: inherit; 355 | text-decoration: none; 356 | font-weight: 400; 357 | } 358 | 359 | .info a:hover { 360 | text-decoration: underline; 361 | } 362 | 363 | /* 364 | Hack to remove background from Mobile Safari. 365 | Can't use it globally since it destroys checkboxes in Firefox 366 | */ 367 | @media screen and (-webkit-min-device-pixel-ratio:0) { 368 | .toggle-all, 369 | .todo-list li .toggle { 370 | background: none; 371 | } 372 | 373 | .todo-list li .toggle { 374 | height: 40px; 375 | } 376 | } 377 | 378 | @media (max-width: 430px) { 379 | .footer { 380 | height: 50px; 381 | } 382 | 383 | .filters { 384 | bottom: 10px; 385 | } 386 | } 387 | 388 | :focus, 389 | .toggle:focus + label, 390 | .toggle-all:focus + label { 391 | box-shadow: 0 0 2px 2px #CF7D7D; 392 | outline: 0; 393 | } 394 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Elm Todos 8 | 9 | 10 | 11 | 12 |
13 | 14 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwayne/elm-todos/383664d3d2c3c883bf5ebf586808033f895953c5/screenshot.png -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Browser as B 4 | import Browser.Dom as BD 5 | import Browser.Navigation as BN 6 | import Html as H 7 | import Html.Attributes as HA 8 | import Html.Events as HE 9 | import Json.Decode as JD 10 | import Json.Encode as JE 11 | import Task 12 | import Url exposing (Url) 13 | 14 | 15 | main : Program Flags Model Msg 16 | main = 17 | B.application 18 | { init = init 19 | , update = updateAndSave 20 | , view = view 21 | , subscriptions = always Sub.none 22 | , onUrlRequest = ClickedLink 23 | , onUrlChange = ChangedUrl 24 | } 25 | 26 | 27 | 28 | -- MODEL 29 | 30 | 31 | type alias Model = 32 | { url : Url 33 | , key : BN.Key 34 | , uid : Int 35 | , description : String 36 | , mode : Mode 37 | , visibility : Visibility 38 | , entries : List Entry 39 | } 40 | 41 | 42 | type Mode 43 | = Normal 44 | | Edit Int String 45 | 46 | 47 | type alias Entry = 48 | { uid : Int 49 | , description : String 50 | , completed : Bool 51 | } 52 | 53 | 54 | type Visibility 55 | = All 56 | | Active 57 | | Completed 58 | 59 | 60 | type alias Flags = 61 | JE.Value 62 | 63 | 64 | init : Flags -> Url -> BN.Key -> ( Model, Cmd msg ) 65 | init data url key = 66 | let 67 | initModel = 68 | Model url key 0 "" Normal (toVisibility url) [] 69 | in 70 | ( case JD.decodeValue (modelDecoder url key) data of 71 | Ok (Just model) -> 72 | model 73 | 74 | _ -> 75 | initModel 76 | , Cmd.none 77 | ) 78 | 79 | 80 | 81 | -- UPDATE 82 | 83 | 84 | type Msg 85 | = ClickedLink B.UrlRequest 86 | | ChangedUrl Url 87 | | ChangedDescription String 88 | | SubmittedDescription 89 | | CheckedEntry Int Bool 90 | | ClickedRemoveButton Int 91 | | CheckedMarkAllCompleted Bool 92 | | ClickedRemoveCompletedEntriesButton 93 | | DoubleClickedDescription Int String 94 | | ChangedEntryDescription Int String 95 | | SubmittedEditedDescription 96 | | FocusedEntry 97 | | BlurredEntry 98 | | EscapedEntry 99 | 100 | 101 | updateAndSave : Msg -> Model -> ( Model, Cmd Msg ) 102 | updateAndSave msg model = 103 | let 104 | ( nextModel, cmd ) = 105 | update msg model 106 | in 107 | ( nextModel 108 | , Cmd.batch 109 | [ cmd 110 | , save (encodeModel nextModel) 111 | ] 112 | ) 113 | 114 | 115 | update : Msg -> Model -> ( Model, Cmd Msg ) 116 | update msg model = 117 | case msg of 118 | ClickedLink urlRequest -> 119 | case urlRequest of 120 | B.Internal url -> 121 | ( model 122 | , BN.pushUrl model.key (Url.toString url) 123 | ) 124 | 125 | B.External href -> 126 | ( model 127 | , BN.load href 128 | ) 129 | 130 | ChangedUrl url -> 131 | ( { model | visibility = toVisibility url } 132 | , Cmd.none 133 | ) 134 | 135 | ChangedDescription description -> 136 | ( { model | description = description } 137 | , Cmd.none 138 | ) 139 | 140 | SubmittedDescription -> 141 | let 142 | cleanDescription = 143 | String.trim model.description 144 | in 145 | if String.isEmpty cleanDescription then 146 | ( model 147 | , Cmd.none 148 | ) 149 | 150 | else 151 | ( { model 152 | | uid = model.uid + 1 153 | , description = "" 154 | , entries = model.entries ++ [ Entry model.uid cleanDescription False ] 155 | } 156 | , Cmd.none 157 | ) 158 | 159 | CheckedEntry uid isChecked -> 160 | let 161 | updateEntry entry = 162 | if uid == entry.uid then 163 | { entry | completed = isChecked } 164 | 165 | else 166 | entry 167 | in 168 | ( { model | entries = List.map updateEntry model.entries } 169 | , Cmd.none 170 | ) 171 | 172 | ClickedRemoveButton uid -> 173 | ( { model 174 | | entries = List.filter (\entry -> entry.uid /= uid) model.entries 175 | } 176 | , Cmd.none 177 | ) 178 | 179 | CheckedMarkAllCompleted isChecked -> 180 | let 181 | updateEntry entry = 182 | { entry | completed = isChecked } 183 | in 184 | ( { model | entries = List.map updateEntry model.entries } 185 | , Cmd.none 186 | ) 187 | 188 | ClickedRemoveCompletedEntriesButton -> 189 | ( { model | entries = List.filter (not << .completed) model.entries } 190 | , Cmd.none 191 | ) 192 | 193 | DoubleClickedDescription uid description -> 194 | ( { model | mode = Edit uid description } 195 | , focus (entryEditId uid) FocusedEntry 196 | ) 197 | 198 | ChangedEntryDescription uid description -> 199 | ( { model | mode = Edit uid description } 200 | , Cmd.none 201 | ) 202 | 203 | SubmittedEditedDescription -> 204 | case model.mode of 205 | Normal -> 206 | ( model 207 | , Cmd.none 208 | ) 209 | 210 | Edit uid description -> 211 | let 212 | cleanDescription = 213 | String.trim description 214 | in 215 | if String.isEmpty cleanDescription then 216 | ( { model 217 | | mode = Normal 218 | , entries = List.filter (\entry -> entry.uid /= uid) model.entries 219 | } 220 | , Cmd.none 221 | ) 222 | 223 | else 224 | let 225 | updateEntry entry = 226 | if uid == entry.uid then 227 | { entry | description = cleanDescription } 228 | 229 | else 230 | entry 231 | in 232 | ( { model 233 | | mode = Normal 234 | , entries = List.map updateEntry model.entries 235 | } 236 | , Cmd.none 237 | ) 238 | 239 | FocusedEntry -> 240 | ( model 241 | , Cmd.none 242 | ) 243 | 244 | BlurredEntry -> 245 | ( { model | mode = Normal } 246 | , Cmd.none 247 | ) 248 | 249 | EscapedEntry -> 250 | ( { model | mode = Normal } 251 | , Cmd.none 252 | ) 253 | 254 | 255 | 256 | -- PORTS 257 | 258 | 259 | port save : JE.Value -> Cmd msg 260 | 261 | 262 | 263 | -- ENCODERS 264 | 265 | 266 | encodeModel : Model -> JE.Value 267 | encodeModel { uid, entries } = 268 | JE.object 269 | [ ( "uid", JE.int uid ) 270 | , ( "entries", JE.list encodeEntry entries ) 271 | ] 272 | 273 | 274 | encodeEntry : Entry -> JE.Value 275 | encodeEntry { uid, description, completed } = 276 | JE.object 277 | [ ( "uid", JE.int uid ) 278 | , ( "description", JE.string description ) 279 | , ( "completed", JE.bool completed ) 280 | ] 281 | 282 | 283 | 284 | -- DECODERS 285 | 286 | 287 | modelDecoder : Url -> BN.Key -> JD.Decoder (Maybe Model) 288 | modelDecoder url key = 289 | JD.nullable <| 290 | JD.map2 (\uid entries -> Model url key uid "" Normal (toVisibility url) entries) 291 | (JD.field "uid" JD.int) 292 | (JD.field "entries" <| JD.list entryDecoder) 293 | 294 | 295 | entryDecoder : JD.Decoder Entry 296 | entryDecoder = 297 | JD.map3 Entry 298 | (JD.field "uid" JD.int) 299 | (JD.field "description" JD.string) 300 | (JD.field "completed" JD.bool) 301 | 302 | 303 | 304 | -- VIEW 305 | 306 | 307 | view : Model -> B.Document Msg 308 | view { description, mode, visibility, entries } = 309 | { title = "Elm Todos" 310 | , body = 311 | [ H.section [ HA.class "todoapp" ] <| 312 | viewPrompt description 313 | :: viewMain mode visibility entries 314 | , viewFooter 315 | ] 316 | } 317 | 318 | 319 | viewPrompt : String -> H.Html Msg 320 | viewPrompt description = 321 | H.header [ HA.class "header" ] 322 | [ H.h1 [] [ H.text "todos" ] 323 | , H.form [ HE.onSubmit SubmittedDescription ] 324 | [ H.input 325 | [ HA.type_ "text" 326 | , HA.autofocus True 327 | , HA.placeholder "What needs to be done?" 328 | , HA.class "new-todo" 329 | , HA.value description 330 | , HE.onInput ChangedDescription 331 | ] 332 | [] 333 | ] 334 | ] 335 | 336 | 337 | viewMain : Mode -> Visibility -> List Entry -> List (H.Html Msg) 338 | viewMain mode visibility entries = 339 | if List.isEmpty entries then 340 | [] 341 | 342 | else 343 | [ H.section [ HA.class "main" ] 344 | [ H.input 345 | [ HA.type_ "checkbox" 346 | , HA.id "toggle-all" 347 | , HA.class "toggle-all" 348 | , HA.checked (List.all .completed entries) 349 | , HE.onCheck CheckedMarkAllCompleted 350 | ] 351 | [] 352 | , H.label [ HA.for "toggle-all" ] [ H.text "Mark all as completed" ] 353 | , H.ul [ HA.class "todo-list" ] <| 354 | List.map 355 | (\entry -> 356 | H.li 357 | [ HA.classList 358 | [ ( "completed", entry.completed ) 359 | , ( "editing", isEditing mode entry ) 360 | ] 361 | ] 362 | [ viewEntry mode entry ] 363 | ) 364 | (keep visibility entries) 365 | ] 366 | , H.footer [ HA.class "footer" ] <| 367 | List.concat 368 | [ [ viewStatus entries 369 | , viewVisibilityFilters visibility 370 | ] 371 | , viewClearCompleted entries 372 | ] 373 | ] 374 | 375 | 376 | viewEntry : Mode -> Entry -> H.Html Msg 377 | viewEntry mode entry = 378 | case mode of 379 | Normal -> 380 | viewEntryNormal entry 381 | 382 | Edit uid description -> 383 | if uid == entry.uid then 384 | viewEntryEdit uid description 385 | 386 | else 387 | viewEntryNormal entry 388 | 389 | 390 | viewEntryNormal : Entry -> H.Html Msg 391 | viewEntryNormal { uid, description, completed } = 392 | H.div [ HA.class "view" ] 393 | [ H.input 394 | [ HA.type_ "checkbox" 395 | , HA.checked completed 396 | , HA.class "toggle" 397 | , HE.onCheck (CheckedEntry uid) 398 | ] 399 | [] 400 | , H.label [ HE.onDoubleClick (DoubleClickedDescription uid description) ] 401 | [ H.text description ] 402 | , H.button 403 | [ HA.type_ "button" 404 | , HA.class "destroy" 405 | , HE.onClick (ClickedRemoveButton uid) 406 | ] 407 | [] 408 | ] 409 | 410 | 411 | viewEntryEdit : Int -> String -> H.Html Msg 412 | viewEntryEdit uid description = 413 | H.form [ HE.onSubmit SubmittedEditedDescription ] 414 | [ H.input 415 | [ HA.type_ "text" 416 | , HA.id (entryEditId uid) 417 | , HA.value description 418 | , HA.class "edit" 419 | , HE.onInput (ChangedEntryDescription uid) 420 | , HE.onBlur BlurredEntry 421 | , onEsc EscapedEntry 422 | ] 423 | [] 424 | ] 425 | 426 | 427 | viewStatus : List Entry -> H.Html msg 428 | viewStatus entries = 429 | let 430 | n = 431 | entries 432 | |> List.filter (not << .completed) 433 | |> List.length 434 | in 435 | H.span [ HA.class "todo-count" ] 436 | [ H.strong [] [ H.text (String.fromInt n) ] 437 | , H.text <| " " ++ pluralize n "item" "items" ++ " left" 438 | ] 439 | 440 | 441 | viewVisibilityFilters : Visibility -> H.Html msg 442 | viewVisibilityFilters selected = 443 | H.ul [ HA.class "filters" ] 444 | [ H.li [] [ viewVisibilityFilter "All" "#/" All selected ] 445 | , H.li [] [ viewVisibilityFilter "Active" "#/active" Active selected ] 446 | , H.li [] [ viewVisibilityFilter "Completed" "#/completed" Completed selected ] 447 | ] 448 | 449 | 450 | viewVisibilityFilter : String -> String -> Visibility -> Visibility -> H.Html msg 451 | viewVisibilityFilter name url current selected = 452 | H.a 453 | [ HA.href url 454 | , HA.classList [ ( "selected", current == selected ) ] 455 | ] 456 | [ H.text name ] 457 | 458 | 459 | viewClearCompleted : List Entry -> List (H.Html Msg) 460 | viewClearCompleted entries = 461 | let 462 | completedEntries = 463 | List.filter .completed entries 464 | 465 | numCompletedEntries = 466 | List.length completedEntries 467 | in 468 | if numCompletedEntries == 0 then 469 | [] 470 | 471 | else 472 | [ H.button 473 | [ HA.type_ "button" 474 | , HA.class "clear-completed" 475 | , HE.onClick ClickedRemoveCompletedEntriesButton 476 | ] 477 | [ H.text <| "Clear completed (" ++ String.fromInt numCompletedEntries ++ ")" ] 478 | ] 479 | 480 | 481 | viewFooter : H.Html msg 482 | viewFooter = 483 | H.footer [ HA.class "info" ] 484 | [ H.p [] [ H.text "Double-click to edit a todo" ] 485 | , H.p [] 486 | [ H.text "Written by " 487 | , H.a [ HA.href "https://github.com/dwayne" ] [ H.text "Dwayne Crooks" ] 488 | ] 489 | ] 490 | 491 | 492 | 493 | -- HELPERS 494 | 495 | 496 | toVisibility : Url -> Visibility 497 | toVisibility url = 498 | case ( url.path, url.fragment ) of 499 | ( "/", Just "/active" ) -> 500 | Active 501 | 502 | ( "/", Just "/completed" ) -> 503 | Completed 504 | 505 | _ -> 506 | All 507 | 508 | 509 | entryEditId : Int -> String 510 | entryEditId uid = 511 | "entry-edit-" ++ String.fromInt uid 512 | 513 | 514 | isEditing : Mode -> Entry -> Bool 515 | isEditing mode entry = 516 | case mode of 517 | Normal -> 518 | False 519 | 520 | Edit uid _ -> 521 | uid == entry.uid 522 | 523 | 524 | keep : Visibility -> List Entry -> List Entry 525 | keep visibility entries = 526 | case visibility of 527 | All -> 528 | entries 529 | 530 | Active -> 531 | List.filter (not << .completed) entries 532 | 533 | Completed -> 534 | List.filter .completed entries 535 | 536 | 537 | pluralize : Int -> String -> String -> String 538 | pluralize n singular plural = 539 | if n == 1 then 540 | singular 541 | 542 | else 543 | plural 544 | 545 | 546 | focus : String -> msg -> Cmd msg 547 | focus id msg = 548 | BD.focus id 549 | |> Task.attempt (always msg) 550 | 551 | 552 | onEsc : msg -> H.Attribute msg 553 | onEsc msg = 554 | let 555 | decoder = 556 | HE.keyCode 557 | |> JD.andThen 558 | (\n -> 559 | case n of 560 | 27 -> 561 | JD.succeed msg 562 | 563 | _ -> 564 | JD.fail "ignored" 565 | ) 566 | in 567 | HE.on "keydown" decoder 568 | --------------------------------------------------------------------------------