(ns pocketbook.todomvc "TodoMVC built on Pocketbook — offline-first, synced, Clojure-native." (:require [pocketbook.core :as pb] [cljs.core.async :refer [go > @@!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 (