Replace hand-built HTML strings with hiccup vectors rendered by a minimal hiccup→HTML converter (example/todomvc/pocketbook/hiccup.cljs). Supports CSS-style selectors (:div.class#id), attribute maps, void elements, and nested children.
255 lines
8.8 KiB
Clojure
255 lines
8.8 KiB
Clojure
(ns pocketbook.todomvc
|
||
"TodoMVC built on Pocketbook — offline-first, synced, Clojure-native."
|
||
(:require [pocketbook.core :as pb]
|
||
[pocketbook.store.idb :as idb]
|
||
[pocketbook.hiccup :refer [html]]
|
||
[cljs.core.async :refer [go <!]]
|
||
[clojure.string :as str]))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; State
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defonce !conn (atom nil))
|
||
(defonce !todos (atom nil)) ;; the SyncedAtom
|
||
(defonce !filter (atom :all)) ;; :all | :active | :completed
|
||
(defonce !editing (atom nil)) ;; id of todo being edited, or nil
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Helpers
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- todos-list
|
||
"Return todos as sorted vec of [id doc] pairs."
|
||
[]
|
||
(->> @@!todos
|
||
(sort-by (fn [[_ doc]] (:created doc)))
|
||
vec))
|
||
|
||
(defn- active-todos []
|
||
(remove (fn [[_ doc]] (:completed doc)) (todos-list)))
|
||
|
||
(defn- completed-todos []
|
||
(filter (fn [[_ doc]] (:completed doc)) (todos-list)))
|
||
|
||
(defn- visible-todos []
|
||
(case @!filter
|
||
:all (todos-list)
|
||
:active (active-todos)
|
||
:completed (completed-todos)))
|
||
|
||
(defn- all-completed? []
|
||
(let [ts (todos-list)]
|
||
(and (seq ts) (every? (fn [[_ doc]] (:completed doc)) ts))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Actions
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- add-todo! [text]
|
||
(let [text (str/trim text)]
|
||
(when (seq text)
|
||
(let [id (str "todo:" (random-uuid))]
|
||
(swap! @!todos assoc id
|
||
{:text text
|
||
:completed false
|
||
:created (.now js/Date)})))))
|
||
|
||
(defn- toggle-todo! [id]
|
||
(swap! @!todos update-in [id :completed] not))
|
||
|
||
(defn- toggle-all! []
|
||
(let [target (not (all-completed?))]
|
||
(swap! @!todos
|
||
(fn [m]
|
||
(reduce-kv (fn [acc k v] (assoc acc k (assoc v :completed target)))
|
||
{} m)))))
|
||
|
||
(defn- destroy-todo! [id]
|
||
(swap! @!todos dissoc id))
|
||
|
||
(defn- edit-todo! [id new-text]
|
||
(let [text (str/trim new-text)]
|
||
(if (seq text)
|
||
(swap! @!todos assoc-in [id :text] text)
|
||
(destroy-todo! id))
|
||
(reset! !editing nil)))
|
||
|
||
(defn- clear-completed! []
|
||
(swap! @!todos
|
||
(fn [m]
|
||
(into {} (remove (fn [[_ v]] (:completed v))) m))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Rendering
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- render-todo-item [[id doc]]
|
||
(let [editing? (= id @!editing)]
|
||
[:li {:class (str "todo-item"
|
||
(when (:completed doc) " completed")
|
||
(when editing? " editing"))
|
||
:data-id id}
|
||
[:div.view
|
||
[:button {:class "toggle" :data-action "toggle" :data-id id}
|
||
(if (:completed doc) "◉" "○")]
|
||
[:label {:class "todo-label" :data-action "edit-start" :data-id id}
|
||
(:text doc)]
|
||
[:button {:class "destroy" :data-action "destroy" :data-id id} "×"]]
|
||
(when editing?
|
||
[:input {:class "edit-input" :data-action "edit-input"
|
||
:data-id id :value (:text doc)}])]))
|
||
|
||
(defn- filter-btn [label filter-kw current]
|
||
[:button {:class (str "filter-btn" (when (= filter-kw current) " selected"))
|
||
:data-action "filter" :data-filter (name filter-kw)}
|
||
label])
|
||
|
||
(defn- render-footer [active-count total-count]
|
||
(let [current @!filter]
|
||
[:footer.app-footer
|
||
[:span.todo-count [:strong active-count] " "
|
||
(if (= 1 active-count) "item" "items") " left"]
|
||
[:nav.filters
|
||
(filter-btn "All" :all current)
|
||
(filter-btn "Active" :active current)
|
||
(filter-btn "Completed" :completed current)]
|
||
(when (pos? (- total-count active-count))
|
||
[:button {:class "clear-completed" :data-action "clear-completed"}
|
||
"Clear completed"])]))
|
||
|
||
(defn- render-sync-status []
|
||
(let [pending (when @!todos (pb/pending-count @!todos))
|
||
online? (.-onLine js/navigator)]
|
||
[:div.sync-bar
|
||
[:span {:class (str "sync-dot " (if online? "online" "offline"))}]
|
||
[:span.sync-text
|
||
(cond
|
||
(not online?) "Offline — changes saved locally"
|
||
(and pending (pos? pending)) (str "Syncing " pending " change"
|
||
(when (> pending 1) "s") "…")
|
||
:else "Synced")]]))
|
||
|
||
(defn- render! []
|
||
(let [container (js/document.getElementById "app")
|
||
todos (visible-todos)
|
||
total (count (todos-list))
|
||
active (count (active-todos))]
|
||
(when container
|
||
(set! (.-innerHTML container)
|
||
(html
|
||
[:div
|
||
[:header.app-header
|
||
[:h1 "todos"]
|
||
[:div.input-row
|
||
(when (pos? total)
|
||
[:button {:class (str "toggle-all" (when (all-completed?) " checked"))
|
||
:data-action "toggle-all"} "❯"])
|
||
[:input {:id "new-todo" :class "new-todo"
|
||
:placeholder "What needs to be done?" :autofocus true}]]]
|
||
(when (pos? total)
|
||
[:section.main
|
||
[:ul.todo-list
|
||
(map render-todo-item todos)]
|
||
(render-footer active total)])
|
||
(render-sync-status)])))))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Event delegation
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn- find-action
|
||
"Walk up from target to find an element with data-action."
|
||
[el]
|
||
(loop [node el]
|
||
(when (and node (not= node js/document.body))
|
||
(if-let [action (.. node -dataset -action)]
|
||
[action node]
|
||
(recur (.-parentElement node))))))
|
||
|
||
(defn- handle-click [e]
|
||
(when-let [[action el] (find-action (.-target e))]
|
||
(let [id (.. el -dataset -id)]
|
||
(case action
|
||
"toggle" (toggle-todo! id)
|
||
"destroy" (destroy-todo! id)
|
||
"toggle-all" (toggle-all!)
|
||
"clear-completed" (clear-completed!)
|
||
"filter" (reset! !filter (keyword (.. el -dataset -filter)))
|
||
nil))))
|
||
|
||
(defn- handle-dblclick [e]
|
||
(when-let [[action el] (find-action (.-target e))]
|
||
(when (= action "edit-start")
|
||
(let [id (.. el -dataset -id)]
|
||
(reset! !editing id)
|
||
(render!)
|
||
;; Focus the edit input after render
|
||
(js/requestAnimationFrame
|
||
(fn []
|
||
(when-let [input (.querySelector js/document ".edit-input")]
|
||
(.focus input)
|
||
;; Move cursor to end
|
||
(let [len (.-length (.-value input))]
|
||
(.setSelectionRange input len len)))))))))
|
||
|
||
(defn- handle-keydown [e]
|
||
(let [key (.-key e)]
|
||
(cond
|
||
;; Enter on new-todo input
|
||
(and (= key "Enter") (= "new-todo" (.. e -target -id)))
|
||
(let [input (.-target e)]
|
||
(add-todo! (.-value input))
|
||
(set! (.-value input) ""))
|
||
|
||
;; Enter on edit input
|
||
(and (= key "Enter") (.. e -target -dataset -action)
|
||
(= "edit-input" (.. e -target -dataset -action)))
|
||
(let [el (.-target e)]
|
||
(edit-todo! (.. el -dataset -id) (.-value el)))
|
||
|
||
;; Escape cancels edit
|
||
(and (= key "Escape") @!editing)
|
||
(do (reset! !editing nil)
|
||
(render!)))))
|
||
|
||
(defn- handle-blur [e]
|
||
(when (and @!editing
|
||
(.. e -target -dataset -action)
|
||
(= "edit-input" (.. e -target -dataset -action)))
|
||
(let [el (.-target e)]
|
||
(edit-todo! (.. el -dataset -id) (.-value el)))))
|
||
|
||
(defn- bind-events! []
|
||
(let [app (js/document.getElementById "app")]
|
||
(.addEventListener app "click" handle-click)
|
||
(.addEventListener app "dblclick" handle-dblclick)
|
||
(.addEventListener js/document "keydown" handle-keydown)
|
||
(.addEventListener app "focusout" handle-blur true)))
|
||
|
||
;; ---------------------------------------------------------------------------
|
||
;; Init
|
||
;; ---------------------------------------------------------------------------
|
||
|
||
(defn ^:export init []
|
||
(go
|
||
(let [store (<! (idb/open "pocketbook-todomvc"))
|
||
todos (pb/synced-atom store "todo"
|
||
{:server "http://localhost:8090/sync"
|
||
:interval 15000})]
|
||
(reset! !conn store)
|
||
(reset! !todos todos)
|
||
;; Render + bind immediately (empty or stale is fine)
|
||
(render!)
|
||
(bind-events!)
|
||
;; Re-render on any data change (fires when IDB loads + server syncs)
|
||
(add-watch todos :render (fn [_ _ _ _] (render!)))
|
||
(add-watch !filter :render (fn [_ _ _ _] (render!)))
|
||
(.addEventListener js/window "online" (fn [_] (render!)))
|
||
(.addEventListener js/window "offline" (fn [_] (render!)))
|
||
;; Wait for IDB — watch triggers render automatically
|
||
(<! (pb/ready? todos))
|
||
(js/console.log "🔶 Pocketbook TodoMVC loaded —" (count @todos) "todos"))))
|
||
|
||
(init)
|