Files
atomsync/example/todomvc/pocketbook/todomvc.cljs
Florian Schroedl 570a087f53 refactor: move TodoMVC example into example/ directory
- example/todomvc/pocketbook/todomvc.cljs — app source
- example/todomvc/todomvc.html — page
- example/todomvc/js/ — compiled output (gitignored)

Server static file serving is now filesystem-based via --static-dir
flag instead of classpath resources. No static dir by default (API
only); bb server passes --static-dir example/todomvc.

Removed resources/public/ — library has no bundled static assets.
2026-04-04 17:05:12 +02:00

265 lines
9.4 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]
[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- esc [s]
(-> (str s)
(str/replace "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(str/replace "\"" "&quot;")))
(defn- render-todo-item [[id doc]]
(let [editing? (= id @!editing)
classes (str (when (:completed doc) " completed")
(when editing? " editing"))]
(str "<li class=\"todo-item" classes "\" data-id=\"" (esc id) "\">"
"<div class=\"view\">"
"<button class=\"toggle\" data-action=\"toggle\" data-id=\"" (esc id) "\">"
(if (:completed doc) "◉" "○")
"</button>"
"<label class=\"todo-label\" data-action=\"edit-start\" data-id=\"" (esc id) "\">"
(esc (:text doc))
"</label>"
"<button class=\"destroy\" data-action=\"destroy\" data-id=\"" (esc id) "\">&times;</button>"
"</div>"
(when editing?
(str "<input class=\"edit-input\" data-action=\"edit-input\" data-id=\"" (esc id) "\""
" value=\"" (esc (:text doc)) "\" />"))
"</li>")))
(defn- render-footer [active-count total-count]
(let [current @!filter]
(str "<footer class=\"app-footer\">"
"<span class=\"todo-count\">"
"<strong>" active-count "</strong> "
(if (= 1 active-count) "item" "items") " left"
"</span>"
"<nav class=\"filters\">"
"<button class=\"filter-btn" (when (= :all current) " selected") "\" data-action=\"filter\" data-filter=\"all\">All</button>"
"<button class=\"filter-btn" (when (= :active current) " selected") "\" data-action=\"filter\" data-filter=\"active\">Active</button>"
"<button class=\"filter-btn" (when (= :completed current) " selected") "\" data-action=\"filter\" data-filter=\"completed\">Completed</button>"
"</nav>"
(when (pos? (- total-count active-count))
"<button class=\"clear-completed\" data-action=\"clear-completed\">Clear completed</button>")
"</footer>")))
(defn- render-sync-status []
(let [pending (when @!todos (pb/pending-count @!todos))
online? (.-onLine js/navigator)]
(str "<div class=\"sync-bar\">"
"<span class=\"sync-dot " (if online? "online" "offline") "\"></span>"
"<span class=\"sync-text\">"
(cond
(not online?) "Offline — changes saved locally"
(and pending (pos? pending)) (str "Syncing " pending " change" (when (> pending 1) "s") "…")
:else "Synced")
"</span>"
"</div>")))
(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
"<header class=\"app-header\">"
"<h1>todos</h1>"
"<div class=\"input-row\">"
(when (pos? total)
(str "<button class=\"toggle-all" (when (all-completed?) " checked") "\" data-action=\"toggle-all\"></button>"))
"<input id=\"new-todo\" class=\"new-todo\" placeholder=\"What needs to be done?\" autofocus />"
"</div>"
"</header>"
(when (pos? total)
(str "<section class=\"main\">"
"<ul class=\"todo-list\">"
(apply str (map render-todo-item todos))
"</ul>"
"</section>"
(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 (<! (pb/open "pocketbook-todomvc"))
todos (pb/synced-atom conn "todo"
{:server "http://localhost:8090/sync"
:interval 15000})]
(reset! !conn conn)
(reset! !todos todos)
;; Wait for IDB load
(<! (pb/ready? todos))
;; Initial render
(render!)
(bind-events!)
;; Re-render on any data change
(add-watch todos :render (fn [_ _ _ _] (render!)))
;; Re-render on filter change
(add-watch !filter :render (fn [_ _ _ _] (render!)))
;; Online/offline status updates
(.addEventListener js/window "online" (fn [_] (render!)))
(.addEventListener js/window "offline" (fn [_] (render!)))
(js/console.log "🔶 Pocketbook TodoMVC loaded —" (count @todos) "todos"))))
(init)