From 3d3b031332dbfd283f4e8ed134dbd9c04b9738bb Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 14:24:49 -0400 Subject: [PATCH] feat: add route state and merge patches --- scenes/tests.py | 12 ++- scenes/views.py | 12 ++- tl/shadow-cljs.edn | 1 + tl/src/tl/core.cljs | 18 ++-- tl/src/tl/events.cljs | 216 ++++++++++++++++++++++++++++++++++-------- tl/src/tl/routes.cljs | 75 +++++++++++++++ tl/src/tl/views.cljs | 35 ++++--- 7 files changed, 306 insertions(+), 63 deletions(-) create mode 100644 tl/src/tl/routes.cljs diff --git a/scenes/tests.py b/scenes/tests.py index b2b8d72..4a8ffdf 100644 --- a/scenes/tests.py +++ b/scenes/tests.py @@ -37,11 +37,19 @@ class DeltaMergeTests(SceneApiTestCase): self.put(self.b, changed={"a2": ann("beat")}) self.assertEqual(set(self.groups()), {"a1", "a2"}) - def test_same_annotation_is_last_write_wins(self): + def test_same_annotation_same_field_is_last_write_wins(self): self.put(self.a, changed={"a1": ann("x", "first")}) - self.put(self.b, changed={"a1": ann("x", "second")}) + self.put(self.b, changed={"a1": {"content": "second"}}) self.assertEqual(self.groups()["a1"]["content"], "second") + def test_same_annotation_different_fields_merge(self): + self.put(self.a, changed={"a1": ann("x", "first", marks=[{"id": "m0"}])}) + self.put(self.a, changed={"a1": {"marks": [{"id": "m1"}]}}) + self.put(self.b, changed={"a1": {"content": "second"}}) + g = self.groups()["a1"] + self.assertEqual(g["content"], "second") + self.assertEqual(g["marks"], [{"id": "m1"}]) + def test_stale_client_neither_resurrects_nor_wipes(self): # bob only ever knew a2; his delta must not erase alice's a1 self.put(self.a, changed={"a1": ann("x")}) diff --git a/scenes/views.py b/scenes/views.py index def00a1..09fbb15 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -135,16 +135,20 @@ def scene(request, pk): if (resp := _auth(request)): return resp p = _editable(request, pk) - # A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}. - # Merging per-id means two users editing different annotations both land - # (no clobber); same-annotation edits are last-write-wins by arrival. + # A delta, not the whole scene. `changed` values may be whole new + # annotations or field patches for existing annotations. Merging per + # field lets same-annotation edits to different fields both land. data = json.loads(request.body or "{}") changed = data.get("changed") or {} deleted = data.get("deleted") or [] summary = None if changed or deleted: groups = dict((p.scene or {}).get("groups", {})) - groups.update(changed) + for gid, patch in changed.items(): + if isinstance(patch, dict) and isinstance(groups.get(gid), dict): + groups[gid] = {**groups[gid], **patch} + else: + groups[gid] = patch for gid in deleted: groups.pop(gid, None) merged = dict(p.scene or {}, groups=groups) diff --git a/tl/shadow-cljs.edn b/tl/shadow-cljs.edn index 14a7c6c..bcabf3d 100644 --- a/tl/shadow-cljs.edn +++ b/tl/shadow-cljs.edn @@ -7,6 +7,7 @@ :dependencies [[reagent "1.2.0"] [re-frame "1.4.7"] + [metosin/reitit "0.9.1"] [day8.re-frame/http-fx "0.2.4"] [binaryage/devtools "1.0.7"]] diff --git a/tl/src/tl/core.cljs b/tl/src/tl/core.cljs index 5a51e8e..b7ae11d 100644 --- a/tl/src/tl/core.cljs +++ b/tl/src/tl/core.cljs @@ -3,6 +3,7 @@ [reagent.dom :as rdom] [re-frame.core :as re-frame] [tl.events :as events] + [tl.routes :as routes] [tl.views :as views] [tl.config :as config])) @@ -11,13 +12,13 @@ (when config/debug? (println "dev mode"))) -(defn route! - "Map the URL hash to a page: #/p/ → editor, #/new → create, else list." - [] - (let [hash (.. js/window -location -hash)] - (if-let [[_ id] (re-find #"^#/p/(\d+)" hash)] - (re-frame/dispatch [::events/open-project (js/parseInt id 10)]) - (re-frame/dispatch (if (= hash "#/new") [::events/nav-create] [::events/nav-list]))))) +(defn route! [match] + (case (get-in match [:data :name]) + :project/show (re-frame/dispatch [::events/open-project + (get-in match [:path-params :id]) + (routes/view-state match)]) + :project/new (re-frame/dispatch [::events/nav-create]) + (re-frame/dispatch [::events/nav-list]))) (defn ^:dev/after-load mount-root [] (re-frame/clear-subscription-cache!) @@ -28,7 +29,6 @@ (defn init [] (re-frame/dispatch-sync [::events/initialize-db]) (re-frame/dispatch [::events/fetch-me]) - (.addEventListener js/window "hashchange" route!) - (route!) + (routes/start! route!) (dev-setup) (mount-root)) diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index f99a7f9..2611b14 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -1,51 +1,163 @@ (ns tl.events (:require [re-frame.core :as rf] + [day8.re-frame.http-fx] ; registers the :http-xhrio effect [tl.api :as api] [tl.db :as db] [tl.otio :as otio] + [tl.routes :as routes] [tl.scene :as scene])) (rf/reg-event-db ::initialize-db (fn [_ _] db/default-db)) ;; --- effects: talk to the backend ----------------------------------------- +;; Most backend calls are :http-xhrio maps returned straight from the event +;; handlers below (each with an :on-failure that surfaces the error). The two +;; here are the non-fetch holdouts: the websocket, and a fan-in alias so the +;; several handlers that want the project list can keep saying :fetch-projects. -(rf/reg-fx :fetch-me (fn [_] (api/fetch-me!))) -(rf/reg-fx :fetch-projects (fn [_] (api/fetch-projects!))) -(rf/reg-fx :load-project (fn [id] (api/load-project! id))) -(rf/reg-fx :refresh-project-detail (fn [id] (api/fetch-project-detail! id))) -(rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted))) -(rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip))) +(rf/reg-fx :fetch-projects (fn [_] (rf/dispatch [::fetch-projects]))) (rf/reg-fx :connect-scene (fn [id] (api/connect-scene! id))) -(rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash))) -(rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password))) -(rf/reg-fx :logout (fn [_] (api/logout!))) +(rf/reg-fx :route (fn [[route params]] + (if params + (routes/navigate! route params) + (routes/navigate! route)))) +(rf/reg-fx :route/replace-project-state routes/replace-project-state!) (rf/reg-fx :poll-thumbnails (fn [id] (js/setTimeout #(rf/dispatch [::refresh-project-detail id]) 1500))) +;; --- shared error handling ------------------------------------------------ +;; Background calls (the thumbnail poll) that shouldn't disturb the UI on +;; failure point :on-failure here. User-facing failures get their own handler +;; that parks a message where the relevant view shows it. + +(rf/reg-event-fx ::ignore-error + (fn [_ [_ failure]] (js/console.warn "request failed" (clj->js failure)) {})) + +;; couldn't reach the server at all (fetch-me canary, logout) — shown on the +;; landing page so the user knows the backend, not their session, is the problem. +(rf/reg-event-db ::net-error + (fn [db [_ failure]] + (assoc db :net-error (api/error-message failure "Can't reach the server.")))) +(rf/reg-event-db ::projects-error + (fn [db [_ failure]] + (assoc db :projects-error (api/error-message failure "Couldn't load your projects.")))) + +(def ^:private attribution-keys + #{:createdBy :createdAt :editedBy :editedAt}) + +(defn- editable-group [g] + (apply dissoc g :draft :gid attribution-keys)) + +(defn- group-patch + "Fields changed since `orig`. nil orig means this is a new group, so send it + whole. Attribution stamps never come from the client." + [orig g] + (let [g (editable-group g)] + (if orig + (let [orig (editable-group orig)] + (into {} + (keep (fn [k] + (let [v (get g k ::missing)] + (when (and (not= v ::missing) (not= v (get orig k))) + [k v])))) + (into #{} (concat (keys orig) (keys g))))) + g))) + +(defn- valid-stack [scene stack] + (let [groups (:groups scene) + s (vec (cons :root (remove #{:root} stack)))] + (or (seq (take-while #(contains? groups %) s)) [:root]))) + +(defn- with-route-view [view scene {:keys [stack playhead]}] + (let [stack (vec (valid-stack scene stack)) + ctx (peek stack)] + (cond-> (assoc view :stack stack) + playhead (assoc-in [:playheads ctx] playhead)))) + +(defn- route-state [db] + (let [ctx (peek (get-in db [:view :stack]))] + {:id (get-in db [:project :id]) + :stack (get-in db [:view :stack]) + :playhead (scene/playhead (:view db) ctx)})) + +(defn- sync-route [fx db] + (if (get-in db [:project :id]) + (assoc fx :route/replace-project-state (route-state db)) + fx)) + ;; --- auth + routing ------------------------------------------------------- (rf/reg-event-fx ::set-auth (fn [{:keys [db]} [_ me]] - (cond-> {:db (assoc db :auth me :login-error nil :projects nil)} + (cond-> {:db (assoc db :auth me :login-error nil :net-error nil :projects nil)} (:authenticated me) (assoc :fetch-projects true)))) ; load my projects -(rf/reg-event-fx ::fetch-me (fn [_ _] {:fetch-me true})) +(rf/reg-event-fx ::fetch-me + (fn [_ _] + {:http-xhrio (api/GET "/api/me/" {:on-success [::set-auth] + :on-failure [::net-error]})})) -(rf/reg-event-fx ::login (fn [{:keys [db]} [_ u p]] - {:db (assoc db :login-error nil) :login {:username u :password p}})) -(rf/reg-event-db ::login-error (fn [db [_ msg]] (assoc db :login-error (or msg "login failed")))) -(rf/reg-event-fx ::logout (fn [_ _] {:logout true})) +(rf/reg-event-fx ::fetch-projects + (fn [_ _] + {:http-xhrio (api/GET "/api/projects/" {:on-success [::set-projects] + :on-failure [::projects-error]})})) + +(rf/reg-event-fx ::login + (fn [{:keys [db]} [_ u p]] + {:db (assoc db :login-error nil) + :http-xhrio (api/POST "/api/login/" {:username u :password p} + {:on-success [::login-ok] :on-failure [::login-error]})})) +(rf/reg-event-fx ::login-ok + (fn [{:keys [db]} [_ me]] + {:db (assoc db :auth me :login-error nil :net-error nil :projects nil) + :fetch-projects true + :route [:projects nil]})) +(rf/reg-event-db ::login-error + (fn [db [_ failure]] + (assoc db :login-error (api/error-message failure "Login failed.")))) +(rf/reg-event-fx ::logout + (fn [_ _] + {:http-xhrio (api/POST "/api/logout/" {} {:on-success [::set-auth] + :on-failure [::logout-error]})})) +;; logout couldn't reach the server: drop the session locally and say so. +(rf/reg-event-db ::logout-error + (fn [db _] + (assoc db :auth {:authenticated false} :projects nil + :net-error "Couldn't reach the server; logged out locally."))) (rf/reg-event-fx ::nav-list (fn [{:keys [db]} _] {:db (assoc db :page :list) :fetch-projects true})) -(rf/reg-event-db ::set-projects (fn [db [_ ps]] (assoc db :projects ps))) +(rf/reg-event-db ::set-projects (fn [db [_ ps]] (assoc db :projects ps :projects-error nil))) (rf/reg-event-db ::nav-create (fn [db _] (assoc db :page :create :create-error nil))) +;; loading a project is a three-hop chain: detail → OTIO file → scene, each +;; feeding the next, and any hop's failure lands the editor in :error. (rf/reg-event-fx ::open-project - (fn [{:keys [db]} [_ id]] - {:db (-> db (assoc :page :editor) (assoc-in [:load :status] :loading)) - :load-project id})) + (fn [{:keys [db]} [_ id view-state]] + {:db (-> db + (assoc :page :editor) + (assoc-in [:load :status] :loading) + (assoc-in [:load :error] nil) + (assoc-in [:view :route-state] view-state)) + :http-xhrio (api/GET (str "/api/projects/" id "/") + {:on-success [::project-detail-loaded] + :on-failure [::project-load-error]})})) +(rf/reg-event-fx ::project-detail-loaded + (fn [_ [_ detail]] + {:http-xhrio (api/GET-url (:otio detail) + {:on-success [::otio-loaded detail] + :on-failure [::project-load-error]})})) +(rf/reg-event-fx ::otio-loaded + (fn [_ [_ detail otio-raw]] + {:http-xhrio (api/GET (str "/api/projects/" (:id detail) "/scene/") + {:on-success [::project-ready detail otio-raw] + :on-failure [::project-load-error]})})) +(rf/reg-event-db ::project-load-error + (fn [db [_ failure]] + (-> db + (assoc-in [:load :status] :error) + (assoc-in [:load :error] (api/error-message failure "Failed to load project."))))) (defn- thumbnails-pending? [project] (contains? #{"pending" "running"} (:thumbnail_status project))) @@ -56,13 +168,15 @@ (let [parsed (otio/parse otio-raw) base (scene/from-otio parsed) anns (scene/restore-annotations (get-in scene-resp [:scene :groups] {})) + scene (update base :groups merge anns) + view (with-route-view (:view db/default-db) scene (get-in db [:view :route-state])) next-db (-> db (assoc :project detail :page :editor) (assoc :fps (or (:fps scene-resp) (:fps parsed))) - (assoc :scene (update base :groups merge anns)) - (assoc :view (:view db/default-db)) + (assoc :scene scene) + (assoc :view view) (assoc-in [:load :status] :ready))] - (cond-> {:db next-db :connect-scene (:id detail)} + (cond-> (sync-route {:db next-db :connect-scene (:id detail)} next-db) (thumbnails-pending? detail) (assoc :poll-thumbnails (:id detail)))))) ;; A peer saved: merge their attributed groups in (and drop deletions). We keep @@ -83,7 +197,9 @@ ::refresh-project-detail (fn [{:keys [db]} _] (if-let [id (get-in db [:project :id])] - {:refresh-project-detail id} + {:http-xhrio (api/GET (str "/api/projects/" id "/") + {:on-success [::project-detail-refreshed] + :on-failure [::ignore-error]})} ; background poll: stay quiet {}))) (rf/reg-event-fx @@ -101,9 +217,16 @@ (rf/reg-event-fx ::create-project (fn [{:keys [db]} [_ name otio clip]] - {:db (assoc db :create-error nil) :create-project {:name name :otio otio :clip clip}})) -(rf/reg-event-fx ::project-created (fn [_ [_ p]] {:goto (str "#/p/" (:id p))})) -(rf/reg-event-db ::project-create-error (fn [db [_ msg]] (assoc db :create-error (or msg "upload failed")))) + (let [fd (doto (js/FormData.) + (.append "name" name) (.append "otio" otio) (.append "clip" clip))] + {:db (assoc db :create-error nil) + :http-xhrio (api/upload "/api/projects/" fd + {:on-success [::project-created] + :on-failure [::project-create-error]})}))) +(rf/reg-event-fx ::project-created (fn [_ [_ p]] {:route [:project/show {:id (:id p)}]})) +(rf/reg-event-db ::project-create-error + (fn [db [_ failure]] + (assoc db :create-error (api/error-message failure "Upload failed.")))) ;; --- view ----------------------------------------------------------------- @@ -158,8 +281,10 @@ (assoc-in [:view :playheads ctx] playhead)) :player/seek (when sf (/ sf (:fps db)))}))) -(rf/reg-event-db ::set-playhead - (fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf))) +(rf/reg-event-fx ::set-playhead + (fn [{:keys [db]} [_ ctx lf]] + (let [next-db (assoc-in db [:view :playheads ctx] lf)] + (sync-route {:db next-db} next-db)))) (rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) (rf/reg-event-db ::set-zoom (fn [db [_ z]] (assoc-in db [:view :zoom] z))) (rf/reg-event-db ::set-row-h (fn [db [_ h]] (assoc-in db [:view :row-h] h))) @@ -172,7 +297,7 @@ ctx (peek (get-in db [:view :stack])) segs (scene/content-segments (:scene db) ctx) sf (scene/local->source segs (scene/playhead (:view db) ctx))] - {:db db :player/pause true :player/seek (when sf (/ sf (:fps db)))})) + (sync-route {:db db :player/pause true :player/seek (when sf (/ sf (:fps db)))} db))) (rf/reg-event-fx ::expand (fn [{:keys [db]} [_ gid]] (enter-ctx db #(conj % gid)))) (rf/reg-event-fx ::collapse (fn [{:keys [db]} _] (enter-ctx db #(if (> (count %) 1) (pop %) %)))) @@ -204,16 +329,33 @@ ;; cancelling a draft is local only; saving a real annotation / deleting one ;; pushes a delta to the backend (which merges + attributes it). (rf/reg-event-db ::drop-group (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) -(rf/reg-event-fx ::put-group - (fn [{:keys [db]} [_ gid g]] - (cond-> {:db (assoc-in db [:scene :groups gid] g)} - (and (= :annotation (:type g)) (not (:draft g))) - (assoc :put-scene {:id (get-in db [:project :id]) - :changed {gid (dissoc g :draft :gid)}})))) +(rf/reg-event-db ::restore-group + (fn [db [_ gid g]] (assoc-in db [:scene :groups gid] g))) +(rf/reg-event-db ::put-group + (fn [db [_ gid g]] (assoc-in db [:scene :groups gid] g))) +(rf/reg-event-fx + ::save-group + (fn [{:keys [db]} [_ gid g orig]] + (let [g (editable-group g) + patch (group-patch orig g) + id (get-in db [:project :id])] + (cond-> {:db (-> db (assoc-in [:scene :groups gid] g) (assoc :save-error nil))} + (and id (= :annotation (:type g)) (seq patch)) + (assoc :http-xhrio (api/put-scene id {:changed {gid patch}} + {:on-success [::scene-saved] + :on-failure [::save-error]})))))) (rf/reg-event-fx ::delete-annotation (fn [{:keys [db]} [_ gid]] - {:db (update-in db [:scene :groups] dissoc gid) - :put-scene {:id (get-in db [:project :id]) :deleted [gid]}})) + (let [id (get-in db [:project :id])] + (cond-> {:db (-> db (update-in [:scene :groups] dissoc gid) + (assoc :save-error nil))} + id (assoc :http-xhrio (api/put-scene id {:deleted [gid]} + {:on-success [::scene-saved] + :on-failure [::save-error]})))))) +(rf/reg-event-db ::scene-saved (fn [db _] (assoc db :save-error nil))) +(rf/reg-event-db ::save-error + (fn [db [_ failure]] + (assoc db :save-error (api/error-message failure "Couldn't save your changes — they're unsaved.")))) ;; click a clip while authoring: first click sets a pending start, the second ;; completes the span as a run of single-clip marks. `frame` (mark time) is diff --git a/tl/src/tl/routes.cljs b/tl/src/tl/routes.cljs new file mode 100644 index 0000000..4bda8aa --- /dev/null +++ b/tl/src/tl/routes.cljs @@ -0,0 +1,75 @@ +(ns tl.routes + (:require + [clojure.string :as str] + [reitit.frontend :as reitit] + [reitit.frontend.easy :as rfe])) + +(def routes + [["/" {:name :projects}] + ["/new" {:name :project/new}] + ["/p/:id" {:name :project/show + :parameters {:path {:id int?}}}]]) + +(def router + (reitit/router routes)) + +(defn href + ([route] (rfe/href route)) + ([route params] (rfe/href route params))) + +(defn start! [on-navigate] + (rfe/start! router on-navigate {:use-fragment true})) + +(defn navigate! + ([route] (rfe/push-state route)) + ([route params] (rfe/push-state route params))) + +(defn- encode [s] (js/encodeURIComponent (str s))) +(defn- decode [s] (js/decodeURIComponent (str s))) + +(defn- query-string [m] + (let [pairs (keep (fn [[k v]] + (when (and v (not= "" v)) + (str (encode (name k)) "=" (encode v)))) + m)] + (when (seq pairs) + (str "?" (str/join "&" pairs))))) + +(defn- current-hash-query [] + (let [hash (.. js/window -location -hash) + i (.indexOf hash "?")] + (when (not= -1 i) + (subs hash (inc i))))) + +(defn- parse-query [q] + (into {} + (keep (fn [pair] + (when (seq pair) + (let [[k v] (str/split pair #"=" 2)] + [(keyword (decode k)) (decode (or v ""))])))) + (str/split (or q "") #"&"))) + +(defn view-state [match] + (let [qp (merge (parse-query (current-hash-query)) + (into {} (map (fn [[k v]] [(keyword k) v]) + (:query-params match)))) + stack (some-> (:stack qp) (str/split #",")) + f (some-> (:f qp) js/parseFloat)] + (cond-> {} + (seq stack) (assoc :stack (mapv keyword stack)) + (and f (not (js/isNaN f))) (assoc :playhead f)))) + +(defn project-url [id stack playhead] + (let [stack-param (->> (rest stack) (map name) (str/join ",")) + query (query-string {:stack stack-param + :f (some-> playhead js/Math.round)})] + (str (href :project/show {:id id}) query))) + +(defonce ^:private last-replaced (atom nil)) + +(defn replace-project-state! [{:keys [id stack playhead]}] + (when id + (let [url (project-url id stack playhead)] + (when (not= url @last-replaced) + (reset! last-replaced url) + (.replaceState js/history nil "" url))))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 6d0be2f..4c0f55f 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -5,6 +5,7 @@ [re-frame.core :as rf] [tl.subs :as subs] [tl.scene :as scene] + [tl.routes :as routes] [tl.events :as events])) (defonce video-el (atom nil)) @@ -593,7 +594,9 @@ put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)])) rows (scene/marks->rows scene (:marks d)) valid? (and (not (str/blank? (:name d))) (seq (:marks d))) - save #(when valid? (put (dissoc d :draft)))] + save #(when valid? + (rf/dispatch [::events/save-group gid (dissoc d :draft :gid) + (when-not new? orig)]))] [:form.form {:on-submit (fn [e] (.preventDefault e) (save))} [:div.form-head (if new? "New annotation" "Edit annotation")] [:div.form-row @@ -648,7 +651,10 @@ [:div.form-actions [:button.save {:type "submit" :disabled (not valid?)} "Save"] [:button.cancel {:type "button" - :on-click #(if new? (rf/dispatch [::events/drop-group gid]) (put orig))} "Cancel"]]]))) + :on-click #(if new? + (rf/dispatch [::events/drop-group gid]) + (rf/dispatch [::events/restore-group gid orig]))} + "Cancel"]]]))) ;; --- chrome --------------------------------------------------------------- @@ -788,8 +794,10 @@ (defn annot-pane [] (let [pane @(rf/subscribe [::subs/pane]) - draft? (some? @(rf/subscribe [::subs/draft-group]))] + draft? (some? @(rf/subscribe [::subs/draft-group])) + save-err @(rf/subscribe [::subs/save-error])] [:div.pane-wrap + (when save-err [:div.err {:style {:padding "4px 8px"}} "⚠ " save-err]) [:div.pane-tabs [:button {:class (when (= pane :annotations) "active") :on-click #(rf/dispatch [::events/set-pane :annotations])} "Annotations"] @@ -804,7 +812,8 @@ (let [status @(rf/subscribe [::subs/status]) proj @(rf/subscribe [::subs/project]) authed? @(rf/subscribe [::subs/authed?]) - thumb-status @(rf/subscribe [::subs/thumbnail-status])] + thumb-status @(rf/subscribe [::subs/thumbnail-status]) + load-err @(rf/subscribe [::subs/load-error])] [:div.app [:div.top [:div.video-pane [video-monitor] [frame-readout]] @@ -812,7 +821,7 @@ [:div.divider-h {:on-pointer-down drag-top!}] [:div.timeline-pane [:div.project-bar - [:a.home-link {:href "#/"} "← projects"] + [:a.home-link {:href (routes/href :projects)} "← projects"] [:span.project-name (:name proj)] (when (#{"pending" "running"} thumb-status) [:span.muted "thumbnails processing"]) @@ -827,7 +836,7 @@ [:div.timeline-scroll (case status :ready [timeline] - :error [:div {:style {:color "#e66" :padding 12}} "Failed to load project"] + :error [:div {:style {:color "#e66" :padding 12}} (or load-err "Failed to load project")] [:div {:style {:padding 12}} "Loading…"])]]])) ;; --- project list / create (the landing pages) --------------------------- @@ -850,21 +859,25 @@ (defn list-page [] (let [projects @(rf/subscribe [::subs/projects]) authed? @(rf/subscribe [::subs/authed?]) - user @(rf/subscribe [::subs/username])] + user @(rf/subscribe [::subs/username]) + net-err @(rf/subscribe [::subs/net-error]) + proj-err @(rf/subscribe [::subs/projects-error])] [:div.home [:div.home-head [:h1 "lumet"] (when authed? [:span.muted "signed in as " user " · " [:a {:href "#" :on-click #(do (.preventDefault %) (rf/dispatch [::events/logout]))} "log out"]])] + (when net-err [:div.err "⚠ " net-err]) (if authed? [:<> - [:a.add-btn {:href "#/new"} "+ New project"] + [:a.add-btn {:href (routes/href :project/new)} "+ New project"] + (when proj-err [:div.err "⚠ " proj-err]) (if (seq projects) [:ul.project-list (for [p projects] ^{:key (:id p)} - [:li [:a {:href (str "#/p/" (:id p))} (:name p)] + [:li [:a {:href (routes/href :project/show {:id (:id p)})} (:name p)] [:span.muted " · " (:owner__username p)]])] [:p.muted "No projects yet — create one."])] [:<> @@ -876,7 +889,7 @@ (fn [] (let [err @(rf/subscribe [::subs/create-error])] [:div.home - [:div.home-head [:h1 "New project"] [:a {:href "#/"} "← back"]] + [:div.home-head [:h1 "New project"] [:a {:href (routes/href :projects)} "← back"]] [:div.form [:input.form-name {:placeholder "Project name" :value @pname :on-change #(reset! pname (.. % -target -value))}] @@ -890,7 +903,7 @@ [:div.form-actions [:button.save {:disabled (or (str/blank? @pname) (not @otio) (not @clip)) :on-click #(rf/dispatch [::events/create-project @pname @otio @clip])} "Create"] - [:a.cancel {:href "#/"} "Cancel"]]]])))) + [:a.cancel {:href (routes/href :projects)} "Cancel"]]]])))) (defn main-panel [] (case @(rf/subscribe [::subs/page])