feat: add route state and merge patches

This commit is contained in:
Your Name 2026-06-29 14:24:49 -04:00
parent 5b206635fb
commit 3d3b031332
7 changed files with 306 additions and 63 deletions

View file

@ -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")})

View file

@ -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)

View file

@ -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"]]

View file

@ -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))

View file

@ -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
View 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)))))

View file

@ -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])