From ab68a21dd6c572985c622d528615dfe51008754c Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Sat, 4 Apr 2026 16:45:14 +0200 Subject: [PATCH] feat: add TodoMVC example with editorial design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full TodoMVC implementation using Pocketbook's synced atom: - Add, toggle, edit (double-click), destroy todos - Filter: All / Active / Completed - Toggle all, clear completed - Live sync status indicator (online/offline/pending) - Data persists in IndexedDB, syncs to server via Transit Design: Ink & Paper aesthetic — cream paper background with grain texture, Instrument Serif italic header, Newsreader body text, DM Mono UI chrome, terracotta accent with green completion marks, deckled left border on card. Also: - Server now serves static files from resources/public (/ → todomvc.html) - Fix CLJS compilation: resolve close!/put! naming conflicts with core.async, use qualified async/close!, add clojure.string require - Fix unbalanced parens in do-pull! - Remove old placeholder example --- build.edn | 2 +- resources/public/index.html | 27 --- resources/public/todomvc.html | 416 ++++++++++++++++++++++++++++++++ src/pocketbook/core.cljs | 25 +- src/pocketbook/example.cljs | 75 ------ src/pocketbook/idb.cljs | 40 +-- src/pocketbook/server.clj | 55 ++++- src/pocketbook/sync.cljs | 18 +- src/pocketbook/todomvc.cljs | 264 ++++++++++++++++++++ test/pocketbook/server_test.clj | 4 +- 10 files changed, 770 insertions(+), 156 deletions(-) delete mode 100644 resources/public/index.html create mode 100644 resources/public/todomvc.html delete mode 100644 src/pocketbook/example.cljs create mode 100644 src/pocketbook/todomvc.cljs diff --git a/build.edn b/build.edn index f48c43f..ab3ccb9 100644 --- a/build.edn +++ b/build.edn @@ -1,4 +1,4 @@ -{:main pocketbook.example +{:main pocketbook.todomvc :output-to "resources/public/js/main.js" :output-dir "resources/public/js/out" :asset-path "js/out" diff --git a/resources/public/index.html b/resources/public/index.html deleted file mode 100644 index 99befff..0000000 --- a/resources/public/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Pocketbook Example - - - -
Loading...
- - - - diff --git a/resources/public/todomvc.html b/resources/public/todomvc.html new file mode 100644 index 0000000..42e4db2 --- /dev/null +++ b/resources/public/todomvc.html @@ -0,0 +1,416 @@ + + + + + + Pocketbook · TodoMVC + + + + + + +
+
+ Pocketbook · TodoMVC +
+
+
Loading from local store
+
+
+

Double-click to edit a todo

+

Built with Pocketbook — offline-first synced atoms for Clojure

+

Data persists in IndexedDB · syncs via Transit to SQLite

+
+
+ + + + diff --git a/src/pocketbook/core.cljs b/src/pocketbook/core.cljs index e98120e..55041f0 100644 --- a/src/pocketbook/core.cljs +++ b/src/pocketbook/core.cljs @@ -11,7 +11,8 @@ " (:require [pocketbook.idb :as idb] [pocketbook.sync :as sync] - [cljs.core.async :refer [go go-loop ! chan put! close! timeout alts!]])) + [clojure.string :as str] + [cljs.core.async :as async :refer [go go-loop ! chan put! timeout alts!]])) ;; --------------------------------------------------------------------------- ;; Connection (IDB handle) @@ -25,17 +26,17 @@ (go (let [db (! ch {:db db :db-name db-name}) - (close! ch))) + (async/close! ch))) ch)) -(defn close! +(defn shutdown! "Close a Pocketbook connection." [{:keys [db atoms]}] ;; Stop all sync loops (doseq [[_ sa] @(or atoms (atom {}))] (when-let [stop (:stop-fn sa)] (stop))) - (idb/close! db)) + (idb/close-db! db)) ;; --------------------------------------------------------------------------- ;; Synced Atom — implements IAtom semantics @@ -122,7 +123,7 @@ (.now js/Date)) (defn- doc-in-group? [group id] - (clojure.string/starts-with? id (prefix-str group))) + (str/starts-with? id (prefix-str group))) ;; --------------------------------------------------------------------------- ;; IDB ↔ Atom sync @@ -151,7 +152,7 @@ (str "last-sync:" (.-group sa))))] (reset! (.-last_sync sa) (or ls 0))) (put! ch true) - (close! ch))) + (async/close! ch))) ch)) (defn- write-doc-to-idb! @@ -188,12 +189,12 @@ [sa] (go (when-let [opts (.-server_opts sa)] - (let [since @(.-last_sync sa) + (let [since @(.-last_sync sa) result (Pocketbook Todos (" (count todos) ")" - "
" - "" - "" - "
" - "" - "

" - "Pending sync: " (pb/pending-count todos-atom) - "

")))) - -(defn- setup-handlers! [todos-atom] - ;; We re-setup after each render - (when-let [btn (js/document.getElementById "add-btn")] - (.addEventListener btn "click" - (fn [_] - (let [input (js/document.getElementById "new-todo") - text (.-value input)] - (when (seq text) - (let [id (str "todo:" (random-uuid))] - (swap! todos-atom assoc id {:text text :done false}) - (set! (.-value input) ""))))))) - ;; Checkbox toggles - (doseq [cb (array-seq (.querySelectorAll js/document "input[type=checkbox]"))] - (.addEventListener cb "change" - (fn [e] - (let [id (.-id (.-dataset (.-target e)))] - (swap! todos-atom update-in [id :done] not))))) - ;; Delete buttons - (doseq [btn (array-seq (.querySelectorAll js/document ".del-btn"))] - (.addEventListener btn "click" - (fn [e] - (let [id (.-id (.-dataset (.-target e)))] - (swap! todos-atom dissoc id)))))) - -(defn ^:export init [] - (go - (let [conn (> @(!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- esc [s] + (-> (str s) + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """))) + +(defn- render-todo-item [[id doc]] + (let [editing? (= id @!editing) + classes (str (when (:completed doc) " completed") + (when editing? " editing"))] + (str "
  • " + "
    " + "" + "" + "" + "
    " + (when editing? + (str "")) + "
  • "))) + +(defn- render-footer [active-count total-count] + (let [current @!filter] + (str ""))) + +(defn- render-sync-status [] + (let [pending (when @!todos (pb/pending-count @!todos)) + online? (.-onLine js/navigator)] + (str "
    " + "" + "" + (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) + (str + "
    " + "

    todos

    " + "
    " + (when (pos? total) + (str "")) + "" + "
    " + "
    " + (when (pos? total) + (str "
    " + "" + "
    " + (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 [conn ( (HttpRequest/newBuilder) (.uri (URI. (url "/"))) (.GET) (.build)) resp (.send client req (HttpResponse$BodyHandlers/ofString))] (is (= 200 (.statusCode resp))) - (is (= "pocketbook ok" (.body resp))))) + (is (.contains (.body resp) "TodoMVC")))) (deftest push-and-pull (testing "Push new documents"