Files
atomsync/example/todomvc/pocketbook/todomvc.cljs
Florian Schroedl c971988ce9 refactor: use hiccup instead of string concatenation in todomvc
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.
2026-04-16 19:51:41 +02:00

255 lines
8.8 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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)