I had to make a little UI to display some stats about an app at work, and decided to try using Babashka with HTMX for it. Turned out to be a really good experience.
Babashka has pretty much everything you need for a basic web app baked in, and HTMX lets you do dynamic loading on the page without having to bother with a Js frontend.
Best part is that bb can start nREPL with bb --nrepl-server
and then you can connect an editor like Calva to it and develop the script interactively.
Definitely recommend checking it out if you need to make a simple web UI.
#!/usr/bin/env bb
(require
'[clojure.string :as str]
'[org.httpkit.server :as srv]
'[hiccup2.core :as hp]
'[cheshire.core :as json]
'[babashka.pods :as pods]
'[clojure.java.io :as io]
'[clojure.edn :as edn])
(import '[java.net URLDecoder])
(pods/load-pod 'org.babashka/postgresql "0.1.0")
(require '[pod.babashka.postgresql :as pg])
(defonce server (atom nil))
(defonce conn (atom nil))
(def favicon "data:image/x-icon;base64,AAABAAEAEBAAAAAAAABoBQAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD8fwAA/H8AAPxjAAD/4wAA/+MAAMY/AADGPwAAxjEAAP/xAAD/8QAA4x8AAOMfAADjHwAA//8AAP//AAA=")
(defn list-accounts [{:keys [from to]}]
(pg/execute! @conn
["select account_id, created_at
from accounts
where created_at between to_date(?, 'yyyy-mm-dd') and to_date(?, 'yyyy-mm-dd')"
from to]))
(defn list-all-accounts [_req]
(json/encode {:accounts (pg/execute! @conn ["select account_id, created_at from accounts"])}))
(defn parse-body [{:keys [body]}]
(reduce
(fn [params param]
(let [[k v] (str/split param #"=")]
(assoc params (keyword k) (URLDecoder/decode v))))
{}
(-> body slurp (str/split #"&"))))
(defn render [html]
(str (hp/html html)))
(defn render-accounts [request]
(let [params (parse-body request)
accounts (list-accounts params)]
[:table.table {:id "accounts"}
[:thead
[:tr [:th "account id"] [:th "created at"]]]
[:tbody
(for [{:accounts/keys [account_id created_at]} accounts]
[:tr [:td account_id] [:td (str created_at)]])]]))
(defn date-str [date]
(let [fmt (java.text.SimpleDateFormat. "yyyy-MM-dd")]
(.format fmt date)))
(defn account-stats []
[:section.hero
[:div.hero-body
[:div.container
[:div.columns
[:div.column
[:form.box
{:hx-post "/accounts-in-range"
:hx-target "#accounts"
:hx-swap "outerHTML"}
[:h1.title "Accounts"]
[:div.field
[:label.label {:for "from"} [:b "from "]]
[:input.control {:type "date" :id "from" :name "from" :value (date-str (java.util.Date.))}]]
[:div.field
[:label.label {:for "to"} [:b " to "]]
[:input.control {:type "date" :id "to" :name "to" :value (date-str (java.util.Date.))}]]
[:button.button {:type "submit"} "list accounts"]]
[:div.box [:table.table {:id "accounts"}]]]]]]])
(defn home-page [_req]
(render
[:html
[:head
[:link {:href favicon :rel "icon" :type "image/x-icon"}]
[:meta {:charset "UTF-8"}]
[:title "Account Stats"]
[:link {:href "https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css" :rel "stylesheet"}]
[:link {:href "https://unpkg.com/todomvc-app-css@2.4.1/index.css" :rel "stylesheet"}]
[:script {:src "https://unpkg.com/htmx.org@1.5.0/dist/htmx.min.js" :defer true}]
[:script {:src "https://unpkg.com/hyperscript.org@0.8.1/dist/_hyperscript.min.js" :defer true}]]
[:body
(account-stats)]]))
(defn handler [{:keys [uri request-method] :as req}]
(condp = [request-method uri]
[:get "/"]
{:body (home-page req)
:headers {"Content-Type" "text/html charset=utf-8"}
:status 200}
[:get "/accounts.json"]
{:body (list-all-accounts req)
:headers {"Content-Type" "application/json; charset=utf-8"}
:status 200}
[:post "/accounts-in-range"]
{:body (render (render-accounts req))
:status 200}
{:body (str "page " uri " not found")
:status 404}))
(defn read-config []
(if (.exists (io/file "config.edn"))
(edn/read-string (slurp "config.edn"))
{:port 3001
:db {:dbtype "postgresql"
:host "localhost"
:dbname "postgres"
:user "postgres"
:password "postgres"
:port 5432}}))
(defn run []
(let [{:keys [port db]} (read-config)]
(reset! conn db)
(when-let [server @server]
(server))
(reset! server
(srv/run-server #(handler %) {:port port}))
(println "started on port:" port)))
;; ensures process doesn't exit when running from command line
(when (= "start" (first *command-line-args*))
(run)
@(promise))
(comment
;; restart server
(do
(when-let [instance @server] (instance))
(reset! server nil)
(run)))