Rename the project from the top bar; export from a dialog

The title is a button that becomes a field, saved on Enter or blur. Export's
target and scale move out of the bar into a dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-30 01:12:08 -04:00
parent 0f9ce826ef
commit c139e4d747
3 changed files with 85 additions and 47 deletions

View file

@ -42,6 +42,7 @@
[arthur.flow.take :as take]
[arthur.fx.http :as http]
[arthur.synth :as synth]
[clojure.string :as str]
[re-frame.core :as rf]))
(defn- analysis-payload [analysis]
@ -577,9 +578,16 @@
::save! {:id (when-not (false? can-edit?) pid)
:base (get-in db [:project :seq])
:cid cid
:label (or (:label clip) (name id))
:label (or (not-empty (get-in db [:project :name]))
(:label clip) (name id))
:clip clip}}))))
(rf/reg-event-fx
::rename
(fn [{:keys [db]} [_ value]]
{:db (assoc-in db [:project :name] (not-empty (str/trim value)))
:dispatch [::save {:auto? true :force? true}]}))
(rf/reg-event-fx
::project-setting
(fn [{:keys [db]} [_ key value]]

View file

@ -13,55 +13,68 @@
[arthur.ui.share :as share]
[arthur.ui.snapshots :as snapshots]
[arthur.ui.undo :as undo]
[re-frame.core :as rf]))
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- exporter []
(defn- exporter [open?]
(let [{:keys [target zoom busy? done total]} @(rf/subscribe [::export/state])
targets @(rf/subscribe [::export/targets])]
[:<>
;; ONE SELECT over everything exportable: each symbol, and each instance
;; in the open one. They are one list because they
;; are one kind of request — render this, alone.
;;
;; The option VALUE goes through `export/target-value`, never `name`: a
;; symbol id is `:sym/face-8625` and an instance is a uuid, and writing
;; either through `name` loses what identifies it.
[:select {:value (export/target-value target)
:disabled busy?
:title "what to render"
:on-change #(rf/dispatch [::export/set-target
(export/target-id (.. % -target -value))])}
(doall
(for [{:keys [label] :as target} targets]
^{:key (export/target-value target)}
[:option {:value (export/target-value target)}
(str (when (:isolate target) "· ") label)]))]
[:select {:value zoom :disabled busy?
:title "integer zoom"
:on-change #(rf/dispatch [::export/set-zoom
(js/parseInt (.. % -target -value) 10)])}
(doall (for [z export/zooms] ^{:key z} [:option {:value z} (str z "x")]))]
[:button {:disabled busy? :on-click #(rf/dispatch [::export/start])
:title "a lossless PNG sequence and the mixed audio, in one zip"}
(if busy? (str "rendering " done "/" total) "export")]]))
[:button {:disabled busy? :on-click #(reset! open? true)} "export…"]
(when @open?
[:<> [:div.export-scrim {:on-click #(when-not busy? (reset! open? false))}]
[:section.export-dialog {:role "dialog" :aria-modal true :aria-label "Export"}
[:h2 "Export"]
[:label.export-field "what to render"
[:select {:value (export/target-value target) :disabled busy?
:on-change #(rf/dispatch [::export/set-target
(export/target-id (.. % -target -value))])}
(doall (for [{:keys [label] :as t} targets]
^{:key (export/target-value t)}
[:option {:value (export/target-value t)}
(str (when (:isolate t) "· ") label)]))]]
[:label.export-field "scale"
[:select {:value zoom :disabled busy?
:on-change #(rf/dispatch [::export/set-zoom
(js/parseInt (.. % -target -value) 10)])}
(doall (for [z export/zooms] ^{:key z} [:option {:value z} (str z "×")]))]]
[:p.dim "PNG sequence and audio in a ZIP archive"]
[:div.row
[:button {:disabled busy? :on-click #(rf/dispatch [::export/start])}
(if busy? (str "rendering " done "/" total) "start export")]
[:button {:disabled busy? :on-click #(reset! open? false)} "close"]]]])]))
(defn view []
(let [{project-name :name :keys [busy? seq] project-status :status}
@(rf/subscribe [::playback/project])
{footage-status :status} @(rf/subscribe [::playback/footage])
{export-status :status} @(rf/subscribe [::export/state])]
[:header.top
[:a.brand {:href "/" :title "your projects"
:on-click (fn [e] (.preventDefault e) (collab/navigate! "/"))} "arthur"]
[:span.status
(str (or project-name "untitled") (when seq (str " r" seq))
;; One line, and the most recent thing to have happened wins it. A
;; strip with three status slots is three empty boxes most of the time.
(when-let [said (or export-status project-status footage-status)]
(str " · " said)))]
[exporter]
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
[openmenu/view]
[undo/view]
[snapshots/view]
[share/view]]))
(r/with-let [renaming? (r/atom false)
draft (r/atom "")
export-open? (r/atom false)]
(let [{project-name :name :keys [busy? seq status]} @(rf/subscribe [::playback/project])
{footage-status :status} @(rf/subscribe [::playback/footage])
{export-status :status} @(rf/subscribe [::export/state])
title (or project-name "untitled")
commit! (fn []
(rf/dispatch [::project/rename @draft])
(reset! renaming? false))]
[:header.top
[:a.brand {:href "/" :title "your projects"
:on-click (fn [e] (.preventDefault e) (collab/navigate! "/"))} "arthur"]
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
[openmenu/view]
[undo/view]
[snapshots/view]
(if @renaming?
[:input.project-name {:auto-focus true :value @draft
:on-change #(reset! draft (.. % -target -value))
:on-blur (fn [_] (commit!))
:on-key-down (fn [e]
(case (.-key e)
"Enter" (do (.preventDefault e) (commit!))
"Escape" (reset! renaming? false)
nil))}]
[:button.project-title {:disabled busy? :title "click to rename project"
:on-click (fn [] (reset! draft title)
(reset! renaming? true))}
title (when seq (str " r" seq))])
[:span.status (or export-status status footage-status)]
[exporter export-open?]
[share/view]])))

View file

@ -194,6 +194,19 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
color: var(--dim);
}
.project-title { max-width: 22vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-name { width: 180px; }
.export-scrim { position: fixed; inset: 0; z-index: 50; background: rgba(0, 0, 0, .45); }
.export-dialog {
position: fixed; z-index: 51; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(360px, calc(100vw - 32px)); padding: 14px;
background: var(--pane); border: 1px solid var(--line); border-radius: 3px;
box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
}
.export-dialog h2 { margin: 0 0 12px; font: inherit; font-weight: 600; }
.export-field { display: grid; gap: 4px; margin: 8px 0; color: var(--dim); }
.export-field select { color: var(--fg); }
/* --------------------------------------------------------------------------
the open menu */
@ -520,6 +533,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
letter-spacing: .07em;
}
.inspector-form { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 7px; }
.inspector-field { display: grid; gap: 2px; color: var(--dim); }
.inspector-field input { width: 100%; color: var(--fg); }
/* Label / value, once, for every read-only fact in the pane. */
.facts { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0; }
.facts dt { color: var(--dim); }