feat: add route state and merge patches
This commit is contained in:
parent
5b206635fb
commit
3d3b031332
7 changed files with 306 additions and 63 deletions
|
|
@ -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")})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<id> → 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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
tl/src/tl/routes.cljs
Normal file
75
tl/src/tl/routes.cljs
Normal file
|
|
@ -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)))))
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue