├── .gitignore ├── cover.png ├── README.md └── htmx_todoapp.clj /.gitignore: -------------------------------------------------------------------------------- 1 | .cache -------------------------------------------------------------------------------- /cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prestancedesign/babashka-htmx-todoapp/HEAD/cover.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Babashka htmx todo app 2 | Quick example of a todo list application using [Babashka](https://github.com/babashka/babashka) and [htmx](https://htmx.org/). 3 | 4 | With htmx get a single page app without writing a single line of Javascript. 5 | 6 | From their own web page: 7 | > htmx allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext 8 | 9 | > htmx is small (~10k min.gz'd), dependency-free, extendable & IE11 compatible 10 | 11 | ![Babashka Htmx](https://github.com/PrestanceDesign/babashka-htmx-todoapp/blob/master/cover.png) 12 | 13 | ## Run the application 14 | 15 | Clone this repo and run with Babashka: 16 | 17 | git clone https://github.com/prestancedesign/babashka-htmx-todoapp 18 | cd babashka-htmx-todoapp 19 | bb htmx_todoapp.clj 20 | 21 | Your browser will launch automatically to the url http://localhost:3000 22 | -------------------------------------------------------------------------------- /htmx_todoapp.clj: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bb 2 | 3 | (require '[babashka.deps :as deps]) 4 | (deps/add-deps '{:deps {org.clojars.askonomm/ruuter {:mvn/version "1.3.4"}}}) 5 | 6 | (require '[org.httpkit.server :as srv] 7 | '[clojure.java.browse :as browse] 8 | '[ruuter.core :as ruuter] 9 | '[clojure.pprint :refer [cl-format]] 10 | '[clojure.string :as str] 11 | '[hiccup.core :as h]) 12 | 13 | (import '[java.net URLDecoder]) 14 | 15 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 16 | ;; Config 17 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 18 | 19 | (def port 3000) 20 | 21 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 22 | ;; Mimic DB (in-memory) 23 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 24 | 25 | (def todos (atom (sorted-map 1 {:id 1 :name "Taste htmx with Babashka" :done true} 26 | 2 {:id 2 :name "Buy a unicorn" :done false}))) 27 | 28 | (def todos-id (atom (count @todos))) 29 | 30 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 31 | ;; "DB" queries 32 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 33 | 34 | (defn add-todo! [name] 35 | (let [id (swap! todos-id inc)] 36 | (swap! todos assoc id {:id id :name name :done false}))) 37 | 38 | (defn update-todo! [id name] 39 | (swap! todos assoc-in [(Integer. id) :name] name)) 40 | 41 | (defn toggle-todo! [id] 42 | (swap! todos update-in [(Integer. id) :done] not)) 43 | 44 | (defn remove-todo! [id] 45 | (swap! todos dissoc (Integer. id))) 46 | 47 | (defn filtered-todo [filter-name todos] 48 | (case filter-name 49 | "active" (remove #(:done (val %)) todos) 50 | "completed" (filter #(:done (val %)) todos) 51 | "all" todos 52 | todos)) 53 | 54 | (defn get-items-left [] 55 | (count (remove #(:done (val %)) @todos))) 56 | 57 | (defn todos-completed [] 58 | (count (filter #(:done (val %)) @todos))) 59 | 60 | (defn remove-all-completed-todo [] 61 | (reset! todos (into {} (remove #(:done (val %)) @todos)))) 62 | 63 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 64 | ;; Template and components 65 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 66 | 67 | (defn todo-item [{:keys [id name done]}] 68 | [:li {:id (str "todo-" id) 69 | :class (when done "completed")} 70 | [:div.view 71 | [:input.toggle {:hx-patch (str "/todos/" id) 72 | :type "checkbox" 73 | :checked done 74 | :hx-target (str "#todo-" id) 75 | :hx-swap "outerHTML"}] 76 | [:label {:hx-get (str "/todos/edit/" id) 77 | :hx-target (str "#todo-" id) 78 | :hx-swap "outerHTML"} name] 79 | [:button.destroy {:hx-delete (str "/todos/" id) 80 | :_ (str "on htmx:afterOnLoad remove #todo-" id)}]]]) 81 | 82 | (defn todo-list [todos] 83 | (for [todo todos] 84 | (todo-item (val todo)))) 85 | 86 | (defn todo-edit [id name] 87 | [:form {:hx-patch (str "/todos/update/" id)} 88 | [:input.edit {:type "text" 89 | :name "name" 90 | :value name}]]) 91 | 92 | (defn item-count [] 93 | (let [items-left (get-items-left)] 94 | [:span#todo-count.todo-count {:hx-swap-oob "true"} 95 | [:strong items-left] (cl-format nil " item~p " items-left) "left"])) 96 | 97 | (defn todo-filters [filter] 98 | [:ul#filters.filters {:hx-swap-oob "true"} 99 | [:li [:a {:hx-get "/?filter=all" 100 | :hx-push-url "true" 101 | :hx-target "#todo-list" 102 | :class (when (= filter "all") "selected")} "All"]] 103 | [:li [:a {:hx-get "/?filter=active" 104 | :hx-push-url "true" 105 | :hx-target "#todo-list" 106 | :class (when (= filter "active") "selected")} "Active"]] 107 | [:li [:a {:hx-get "/?filter=completed" 108 | :hx-push-url "true" 109 | :hx-target "#todo-list" 110 | :class (when (= filter "completed") "selected")} "Completed"]]]) 111 | 112 | (defn clear-completed-button [] 113 | [:button#clear-completed.clear-completed 114 | {:hx-delete "/todos" 115 | :hx-target "#todo-list" 116 | :hx-swap-oob "true" 117 | :hx-push-url "/" 118 | :class (when-not (pos? (todos-completed)) "hidden")} 119 | "Clear completed"]) 120 | 121 | (defn template [filter] 122 | (list 123 | "" 124 | (h/html 125 | [:head 126 | [:meta {:charset "UTF-8"}] 127 | [:title "Htmx + Babashka"] 128 | [:link {:href "https://unpkg.com/todomvc-app-css@2.4.1/index.css" :rel "stylesheet"}] 129 | [:script {:src "https://unpkg.com/htmx.org@1.5.0/dist/htmx.min.js" :defer true}] 130 | [:script {:src "https://unpkg.com/hyperscript.org@0.8.1/dist/_hyperscript.min.js" :defer true}]] 131 | [:body 132 | [:section.todoapp 133 | [:header.header 134 | [:h1 "todos"] 135 | [:form 136 | {:hx-post "/todos" 137 | :hx-target "#todo-list" 138 | :hx-swap "beforeend" 139 | :_ "on htmx:afterOnLoad set #txtTodo.value to ''"} 140 | [:input#txtTodo.new-todo 141 | {:name "todo" 142 | :placeholder "What needs to be done?" 143 | :autofocus ""}]]] 144 | [:section.main 145 | [:input#toggle-all.toggle-all {:type "checkbox"}] 146 | [:label {:for "toggle-all"} "Mark all as complete"]] 147 | [:ul#todo-list.todo-list 148 | (todo-list (filtered-todo filter @todos))] 149 | [:footer.footer 150 | (item-count) 151 | (todo-filters filter) 152 | (clear-completed-button)]] 153 | [:footer.info 154 | [:p "Click to edit a todo"] 155 | [:p "Created by " 156 | [:a {:href "https://twitter.com/PrestanceDesign"} "Michaël Sλlihi"]] 157 | [:p "Part of " 158 | [:a {:href "http://todomvc.com"} "TodoMVC"]]]]))) 159 | 160 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 161 | ;; Helpers 162 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 163 | 164 | (defn parse-body [body] 165 | (-> body 166 | slurp 167 | (str/split #"=") 168 | second 169 | URLDecoder/decode)) 170 | 171 | (defn parse-query-string [query-string] 172 | (when query-string 173 | (-> query-string 174 | (str/split #"=") 175 | second))) 176 | 177 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 178 | ;; Handlers 179 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 180 | 181 | (defn render [handler & [status]] 182 | {:status (or status 200) 183 | :body (h/html handler)}) 184 | 185 | (defn app-index [{:keys [query-string headers]}] 186 | (let [filter (parse-query-string query-string) 187 | ajax-request? (get headers "hx-request")] 188 | (if (and filter ajax-request?) 189 | (render (list (todo-list (filtered-todo filter @todos)) 190 | (todo-filters filter))) 191 | (render (template filter))))) 192 | 193 | (defn add-item [{body :body}] 194 | (let [name (parse-body body) 195 | todo (add-todo! name)] 196 | (render (list (todo-item (val (last todo))) 197 | (item-count))))) 198 | 199 | (defn edit-item [{{id :id} :params}] 200 | (let [{:keys [id name]} (get @todos (Integer. id))] 201 | (render (todo-edit id name)))) 202 | 203 | (defn update-item [{{id :id} :params body :body}] 204 | (let [name (parse-body body) 205 | todo (update-todo! id name)] 206 | (render (todo-item (get todo (Integer. id)))))) 207 | 208 | (defn patch-item [{{id :id} :params}] 209 | (let [todo (toggle-todo! id)] 210 | (render (list (todo-item (get todo (Integer. id))) 211 | (item-count) 212 | (clear-completed-button))))) 213 | 214 | (defn delete-item [{{id :id} :params}] 215 | (remove-todo! id) 216 | (render (item-count))) 217 | 218 | (defn clear-completed [_] 219 | (remove-all-completed-todo) 220 | (render (list (todo-list @todos) 221 | (item-count) 222 | (clear-completed-button)))) 223 | 224 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 225 | ;; Routes 226 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 227 | 228 | (def routes [{:path "/" 229 | :method :get 230 | :response app-index} 231 | {:path "/todos/edit/:id" 232 | :method :get 233 | :response edit-item} 234 | {:path "/todos" 235 | :method :post 236 | :response add-item} 237 | {:path "/todos/update/:id" 238 | :method :patch 239 | :response update-item} 240 | {:path "/todos/:id" 241 | :method :patch 242 | :response patch-item} 243 | {:path "/todos/:id" 244 | :method :delete 245 | :response delete-item} 246 | {:path "/todos" 247 | :method :delete 248 | :response clear-completed}]) 249 | 250 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 251 | ;; Server 252 | ;;;;;;;;;;;;;;;;;;;;;;;;;; 253 | 254 | (when (= *file* (System/getProperty "babashka.file")) 255 | (let [url (str "http://localhost:" port "/")] 256 | (srv/run-server #(ruuter/route routes %) {:port port}) 257 | (println "serving" url) 258 | (browse/browse-url url) 259 | @(promise))) 260 | --------------------------------------------------------------------------------