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
+ ""
+ (when (pos? total)
+ (str ""
+ ""
+ (apply str (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 [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"