feat: upload progress on new-project form

Swap the create-project upload from :http-xhrio to a raw XHR effect with
upload.onprogress, surface it as a 1-bit progress bar; disable Create while
uploading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-30 01:03:42 -04:00
parent 533d14ce49
commit 7e47ec6a2d
5 changed files with 47 additions and 11 deletions

View file

@ -376,6 +376,12 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px;
.err { color: var(--ink); font-weight: bold; font-size: 12px; margin: 8px 0; .err { color: var(--ink); font-weight: bold; font-size: 12px; margin: 8px 0;
border: 1px solid var(--ink); padding: 4px 8px; background: var(--shade); } border: 1px solid var(--ink); padding: 4px 8px; background: var(--shade); }
/* 1-bit upload progress: black fill in a bordered white track */
.upload-progress { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
.upload-track { flex: 1; height: 14px; border: 1px solid var(--ink); background: var(--paper); }
.upload-fill { height: 100%; background: var(--ink); transition: width .15s linear; }
.upload-pct { font-family: var(--mono); font-size: 11px; min-width: 36px; text-align: right; }
/* editor: project bar above the timeline — a Mac window title bar (selected) */ /* editor: project bar above the timeline — a Mac window title bar (selected) */
.project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px; .project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px;
background: var(--ink); color: var(--paper); font-family: var(--chicago); background: var(--ink); color: var(--paper); font-family: var(--chicago);

View file

@ -7,6 +7,7 @@
:projects nil ; project list (when signed in) :projects nil ; project list (when signed in)
:project nil ; current project detail {:id :name :clip :otio} :project nil ; current project detail {:id :name :clip :otio}
:create-error nil ; new-project upload failure :create-error nil ; new-project upload failure
:create-progress nil ; new-project upload progress (0..1) while uploading
:login-error nil ; sign-in failure :login-error nil ; sign-in failure
:net-error nil ; backend unreachable (fetch-me / logout) :net-error nil ; backend unreachable (fetch-me / logout)
:projects-error nil ; project-list fetch failure :projects-error nil ; project-list fetch failure

View file

@ -215,18 +215,39 @@
;; --- create project ------------------------------------------------------- ;; --- create project -------------------------------------------------------
;; Raw XHR (not :http-xhrio) so we can report upload progress for the big clip.
(rf/reg-fx :upload-project
(fn [{:keys [name otio clip]}]
(let [fd (doto (js/FormData.)
(.append "name" name) (.append "otio" otio) (.append "clip" clip))
xhr (js/XMLHttpRequest.)]
(.open xhr "POST" (str api/base "/api/projects/"))
(set! (.-withCredentials xhr) true)
(set! (.. xhr -upload -onprogress)
(fn [e] (when (.-lengthComputable e)
(rf/dispatch [::create-progress (/ (.-loaded e) (.-total e))]))))
(set! (.-onload xhr)
(fn [_]
(let [body (try (js->clj (js/JSON.parse (.-responseText xhr)) :keywordize-keys true)
(catch :default _ {}))]
(if (<= 200 (.-status xhr) 299)
(rf/dispatch [::project-created body])
(rf/dispatch [::project-create-error {:status (.-status xhr) :response body}])))))
(set! (.-onerror xhr) (fn [_] (rf/dispatch [::project-create-error {:status 0}])))
(.send xhr fd))))
(rf/reg-event-fx ::create-project (rf/reg-event-fx ::create-project
(fn [{:keys [db]} [_ name otio clip]] (fn [{:keys [db]} [_ name otio clip]]
(let [fd (doto (js/FormData.) {:db (assoc db :create-error nil :create-progress 0)
(.append "name" name) (.append "otio" otio) (.append "clip" clip))] :upload-project {:name name :otio otio :clip clip}}))
{:db (assoc db :create-error nil) (rf/reg-event-db ::create-progress (fn [db [_ p]] (assoc db :create-progress p)))
:http-xhrio (api/upload "/api/projects/" fd (rf/reg-event-fx ::project-created
{:on-success [::project-created] (fn [{:keys [db]} [_ p]]
:on-failure [::project-create-error]})}))) {:db (assoc db :create-progress nil) :route [:project/show {:id (:id p)}]}))
(rf/reg-event-fx ::project-created (fn [_ [_ p]] {:route [:project/show {:id (:id p)}]}))
(rf/reg-event-db ::project-create-error (rf/reg-event-db ::project-create-error
(fn [db [_ failure]] (fn [db [_ failure]]
(assoc db :create-error (api/error-message failure "Upload failed.")))) (assoc db :create-error (api/error-message failure "Upload failed.")
:create-progress nil)))
;; --- view ----------------------------------------------------------------- ;; --- view -----------------------------------------------------------------

View file

@ -18,6 +18,7 @@
(rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails]))) (rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status]))) (rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
(rf/reg-sub ::create-error (fn [db] (:create-error db))) (rf/reg-sub ::create-error (fn [db] (:create-error db)))
(rf/reg-sub ::create-progress (fn [db] (:create-progress db)))
(rf/reg-sub ::login-error (fn [db] (:login-error db))) (rf/reg-sub ::login-error (fn [db] (:login-error db)))
(rf/reg-sub ::net-error (fn [db] (:net-error db))) (rf/reg-sub ::net-error (fn [db] (:net-error db)))
(rf/reg-sub ::projects-error (fn [db] (:projects-error db))) (rf/reg-sub ::projects-error (fn [db] (:projects-error db)))

View file

@ -986,7 +986,9 @@
(defn create-page [] (defn create-page []
(let [pname (r/atom "") otio (atom nil) clip (atom nil)] (let [pname (r/atom "") otio (atom nil) clip (atom nil)]
(fn [] (fn []
(let [err @(rf/subscribe [::subs/create-error])] (let [err @(rf/subscribe [::subs/create-error])
prog @(rf/subscribe [::subs/create-progress])
uploading? (some? prog)]
[:div.home [:div.home
[:div.home-head [:h1 "New project"] [:a {:href (routes/href :projects)} "← back"]] [:div.home-head [:h1 "New project"] [:a {:href (routes/href :projects)} "← back"]]
[:div.form [:div.form
@ -999,9 +1001,14 @@
[:input {:type "file" :accept "video/*" [:input {:type "file" :accept "video/*"
:on-change #(reset! clip (-> % .-target .-files (aget 0)))}]] :on-change #(reset! clip (-> % .-target .-files (aget 0)))}]]
(when err [:div.err err]) (when err [:div.err err])
(when uploading?
[:div.upload-progress
[:div.upload-track [:div.upload-fill {:style {:width (str (* 100 prog) "%")}}]]
[:span.upload-pct (str (js/Math.round (* 100 prog)) "%")]])
[:div.form-actions [:div.form-actions
[:button.save {:disabled (or (str/blank? @pname) (not @otio) (not @clip)) [:button.save {:disabled (or uploading? (str/blank? @pname) (not @otio) (not @clip))
:on-click #(rf/dispatch [::events/create-project @pname @otio @clip])} "Create"] :on-click #(rf/dispatch [::events/create-project @pname @otio @clip])}
(if uploading? "Uploading…" "Create")]
[:a.cancel {:href (routes/href :projects)} "Cancel"]]]])))) [:a.cancel {:href (routes/href :projects)} "Cancel"]]]]))))
(defn main-panel [] (defn main-panel []