From e1b0e7c7c8b6cd8649e7723085de7e2298397feb Mon Sep 17 00:00:00 2001 From: Matthew Huebert Date: Wed, 3 Jan 2024 14:37:23 +0100 Subject: [PATCH] images: remove via context menu, re-order --- src/sb/app/entity/data.cljc | 14 ++--- src/sb/app/field/admin_ui.cljc | 91 ++--------------------------- src/sb/app/field/ui.cljc | 52 +++++++++++------ src/sb/app/views/radix.cljc | 3 +- src/sb/app/views/ui.cljs | 102 +++++++++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 116 deletions(-) diff --git a/src/sb/app/entity/data.cljc b/src/sb/app/entity/data.cljc index 2e8b4735..d7eae1d3 100644 --- a/src/sb/app/entity/data.cljc +++ b/src/sb/app/entity/data.cljc @@ -119,18 +119,12 @@ [ctx e a v] (save-attributes! ctx e {a v})) -(defn save-field [?field] - (when-let [{:as ?persisted-field :keys [db/id attribute]} (io/ancestor-by ?field :field/persisted?)] - (io/try-submit+ ?persisted-field - (save-attribute! nil id attribute @?persisted-field)))) - (defn maybe-save-field [?field] - (let [value @?field] - (when (and (io/closest ?field :field/persisted?) - (not= value (persisted-value ?field))) - (io/try-submit+ ?field - (save-field ?field))))) + (when-let [{:as ?persisted-field :keys [db/id attribute]} (io/ancestor-by ?field :field/persisted?)] + (when (not= @?field (persisted-value ?field)) + (io/try-submit+ ?persisted-field + (save-attribute! nil id attribute @?persisted-field))))) (defn reverse-attr [a] (keyword (namespace a) (str "_" (name a)))) diff --git a/src/sb/app/field/admin_ui.cljc b/src/sb/app/field/admin_ui.cljc index 1853bb34..0c8952ad 100644 --- a/src/sb/app/field/admin_ui.cljc +++ b/src/sb/app/field/admin_ui.cljc @@ -18,87 +18,8 @@ [yawn.hooks :as h] [yawn.view :as v])) -(defn element-center-y [el] - #?(:cljs - (j/let [^js {:keys [y height]} (j/call el :getBoundingClientRect)] - (+ y (/ height 2))))) - -(defn re-order [xs source side destination] - {:post [(= (count %) (count xs))]} - (let [out (reduce (fn [out x] - (if (= x destination) - (into out (case side :before [source destination] - :after [destination source])) - (conj out x))) - [] - (remove #{source} xs))] - (when-not (= (count out) (count xs)) - (throw (ex-info "re-order failed, destination not found" {:source source :destination destination}))) - out)) - -(defn orderable-props - [?child] - #?(:cljs - (let [?parent (io/parent ?child) - group (goog/getUid ?parent) - id (:sym ?child) - on-move (fn [{:keys [source side destination]}] - (io/swap-many! ?parent re-order - (get ?parent source) - side - (get ?parent destination)) - (entity.data/save-field ?child)) - transfer-data (fn [e data] - (j/call-in e [:dataTransfer :setData] (str group) - (pr-str data))) - - receive-data (fn [e] - (try - (ui/read-string (j/call-in e [:dataTransfer :getData] (str group))) - (catch js/Error e nil))) - data-matches? (fn [e] - (some #{(str group)} (j/get-in e [:dataTransfer :types]))) - [active-drag set-drag!] (h/use-state nil) - [active-drop set-drop!] (h/use-state nil) - !should-drag? (h/use-ref false)] - {:drag-handle-props {:on-mouse-down #(reset! !should-drag? true) - :on-mouse-up #(reset! !should-drag? false)} - :drag-subject-props {:draggable true - :data-dragging active-drag - :data-dropping active-drop - :on-drag-over (j/fn [^js {:as e :keys [clientY currentTarget]}] - (j/call e :preventDefault) - (when (data-matches? e) - (set-drop! (if (< clientY (element-center-y currentTarget)) - :before - :after)))) - :on-drag-leave (fn [^js e] - (j/call e :preventDefault) - (set-drop! nil)) - :on-drop (fn [^js e] - (.preventDefault e) - (set-drop! nil) - (when-let [source (receive-data e)] - (on-move {:destination id - :source source - :side active-drop}))) - :on-drag-end (fn [^js e] - (set-drag! nil)) - :on-drag-start (fn [^js e] - (if @!should-drag? - (do - (set-drag! true) - (transfer-data e id)) - (.preventDefault e)))} - :drop-indicator (when active-drop - (v/x [:div.absolute.bg-focus-accent - {:class ["h-[4px] z-[99] inset-x-0 rounded" - (case active-drop - :before "top-[-2px]" - :after "bottom-[-2px]" nil)]}]))}))) - (ui/defview show-option [{:as ?option :syms [?label ?value ?color]}] - (let [{:keys [drag-handle-props drag-subject-props drop-indicator]} (orderable-props ?option)] + (let [{:keys [drag-handle-props drag-subject-props drop-indicator]} (ui/orderable-props ?option {:axis :y})] [:div.flex.gap-2.items-center.group.relative.-ml-6.py-1 (merge {:key @?value} drag-subject-props) @@ -129,7 +50,7 @@ :confirm-text (t :tr/remove) :confirm-fn (fn [] (io/remove-many! ?option) - (p/do (entity.data/save-field ?option) + (p/do (entity.data/maybe-save-field ?option) (radix/close-alert!)))}))} (t :tr/remove)]]}]])) @@ -147,7 +68,7 @@ '?label @?new '?color "#ffffff"}) (io/try-submit+ ?new - (p/let [result (entity.data/save-field ?options)] + (p/let [result (entity.data/maybe-save-field ?options)] (reset! ?new (:init ?new)) result)))} [field.ui/text-field ?new {:placeholder "Option label" :field/wrapper-class "flex-auto"}] @@ -186,7 +107,7 @@ :confirm-text (t :tr/remove) :confirm-fn (fn [] (io/remove-many! ?field) - (entity.data/save-field ?field))})} + (entity.data/maybe-save-field ?field))})} [:div.w-5.h-5.rounded.flex.items-center.justify-center.text-destructive [icons/trash "w-4 h-4"]] (t :tr/remove)]]]])) @@ -198,7 +119,7 @@ {:keys [icon]} (data/field-types @?type) {:keys [drag-handle-props drag-subject-props - drop-indicator]} (orderable-props ?field)] + drop-indicator]} (ui/orderable-props ?field {:axis :y})] [:div.flex-v.relative.border-b ;; label row [:div.flex.gap-3.p-3.items-stretch.relative.cursor-default.relative.group @@ -278,7 +199,7 @@ (prn [:client2 (last @?fields)]) (expand! (:field/id @?new-field)) (reset! !new-field nil) - (entity.data/save-field ?fields)))} + (entity.data/maybe-save-field ?fields)))} [:div.h-10.flex.items-center [(:icon (data/field-types @?type)) "icon-lg text-gray-700 mx-2"]] [field.ui/text-field ?label {:field/label false :ref !autofocus-ref diff --git a/src/sb/app/field/ui.cljc b/src/sb/app/field/ui.cljc index 8bc35544..5cd8414b 100644 --- a/src/sb/app/field/ui.cljc +++ b/src/sb/app/field/ui.cljc @@ -337,11 +337,40 @@ ;; put messages in a popover (form.ui/show-field-messages ?image-list)])) -(ui/defview images-field [?images {:field/keys [label can-edit?]}] - (let [?current (h/use-state (first ?images))] +(ui/defview image-thumbnail + {:key (fn [_ ?image] (:entity/id @?image))} + [{:keys [!?current ?images field/can-edit?]} {:as ?image :syms [?id]}] + (let [url (asset.ui/asset-src @?id :card) + {:keys [drag-handle-props + drag-subject-props + drop-indicator + dragging + dropping]} (ui/orderable-props ?image {:axis :x}) + current? (= @!?current ?image)] + [radix/context-menu + {:key url + :trigger [:div.relative.w-16.h-16.rounded.overflow-hidden.bg-gray-50.transition-all + (v/merge-props {:class [(when current? "outline outline-2 outline-black") + (when dragging "opacity-20") + (case dropping + :before "ml-1" + :after "-ml-1" + nil)] + :on-click #(reset! !?current ?image)} + drag-handle-props + drag-subject-props) + [:div.absolute.inset-0.bg-black.opacity-10.z-1] + [:div.absolute.inset-0.z-2.bg-contain {:style {:background-image (asset.ui/css-url url)}}]] + :items [[radix/context-menu-item {:on-select (fn [] + (io/remove-many! ?image) + (entity.data/maybe-save-field ?images))} + "Delete"]]}])) + +(ui/defview images-field [?images {:as props :field/keys [label can-edit?]}] + (let [!?current (h/use-state (first ?images))] [:div.field-wrapper (form.ui/show-label ?images label) - (when-let [{:syms [?id]} @?current] + (when-let [{:syms [?id]} @!?current] (let [[url loading?] (ui/use-last-loaded (asset.ui/asset-src @?id :avatar))] [:div.relative {:key url} (when loading? [icons/loading "w-4 h-4 text-txt/60 absolute top-2 right-2"]) @@ -350,19 +379,10 @@ ;; thumbnails [:div.flex.gap-2.flex-wrap (when can-edit? [:div.relative.h-16.w-16.flex-none [add-image-button ?images]]) - (for [{:as ?image :syms [?id]} ?images - :let [url (asset.ui/asset-src @?id :card) - current? (= ?image @?current)]] - [radix/context-menu [:div.relative.w-16.h-16.rounded.overflow-hidden.bg-gray-50 - {:class (when current? "outline outline-2 outline-black") - :on-click #(reset! ?current ?image) - :key url} - [:div.absolute.inset-0.bg-black.opacity-10.z-1] - [:div.absolute.inset-0.z-2.bg-contain {:style {:background-image (asset.ui/css-url url)}}]] - {:items [[radix/context-menu-item {:on-select (fn [] - (io/remove-many! ?image) - (entity.data/maybe-save-field ?images))} - "Delete"]]}])]])) + (->> ?images + (map (partial image-thumbnail + (merge props {:!?current !?current + :?images ?images}))))]])) (ui/defview link-list-field [?links {:field/keys [label]}] [:div.field-wrapper diff --git a/src/sb/app/views/radix.cljc b/src/sb/app/views/radix.cljc index 9308750a..ba2a80c9 100644 --- a/src/sb/app/views/radix.cljc +++ b/src/sb/app/views/radix.cljc @@ -217,7 +217,8 @@ (def context-menu-item (v/from-element :el.text-sm.flex.items-center.outline-none.user-select-none.rounded.px-2.py-1 ContextMenu/Item {:class "data-[highlighted]:bg-gray-100"})) -(v/defview context-menu [trigger {:keys [id items] :or {id (str ::context-menu)}}] +(v/defview context-menu [{:keys [trigger + items]}] [:el ContextMenu/Root [:el ContextMenu/Trigger (v/x trigger)] [:el.bg-white.rounded.overflow-hidden.p-1.shadow-md.min-w-32 ContextMenu/Content {:style {:z-index 20}} diff --git a/src/sb/app/views/ui.cljs b/src/sb/app/views/ui.cljs index 8ef12350..bc62eae4 100644 --- a/src/sb/app/views/ui.cljs +++ b/src/sb/app/views/ui.cljs @@ -9,20 +9,20 @@ [cljs.reader :as edn] [clojure.pprint] [clojure.string :as str] + [inside-out.forms :as io] [inside-out.macros] [promesa.core :as p] [re-db.api :as db] [re-db.react] [sb.app.asset.ui :as asset.ui] + [sb.app.entity.data :as entity.data] [sb.client.sanitize :as sanitize] [sb.i18n] [sb.icons :as icons] [sb.routing :as routing] [shadow.cljs.modern :refer [defclass]] - [taoensso.tempura :as tempura] [yawn.hooks :as h] - [yawn.view :as v] - [sb.i18n :as i18n])) + [yawn.view :as v])) (defn dev? [] (= "dev" (db/get :env/config :env))) @@ -301,4 +301,98 @@ (.preventDefault e) (f e))) -(defn pprint [x] (clojure.pprint/pprint x)) \ No newline at end of file +(defn pprint [x] (clojure.pprint/pprint x)) + +(defn element-center-y [el] + (j/let [^js {:keys [y height]} (j/call el :getBoundingClientRect)] + (+ y (/ height 2)))) +(defn element-center-x [el] + (j/let [^js {:keys [x width]} (j/call el :getBoundingClientRect)] + (+ x (/ width 2)))) + +(defn re-order [xs source side destination] + {:post [(= (count %) (count xs))]} + (let [out (reduce (fn [out x] + (if (= x destination) + (into out (case side :before [source destination] + :after [destination source])) + (conj out x))) + [] + (remove #{source} xs))] + (when-not (= (count out) (count xs)) + (throw (ex-info "re-order failed, destination not found" {:source source :destination destination}))) + out)) + +(defn orderable-props + [?child {:keys [axis] :or {axis :y}}] + (let [?parent (io/parent ?child) + group (goog/getUid ?parent) + id (:sym ?child) + on-move (fn [{:keys [source side destination]}] + (io/swap-many-children! ?parent re-order + (get ?parent source) + side + (get ?parent destination)) + (entity.data/maybe-save-field ?child)) + transfer-data (fn [e data] + (j/call-in e [:dataTransfer :setData] (str group) + (pr-str data))) + + receive-data (fn [e] + (try + (read-string (j/call-in e [:dataTransfer :getData] (str group))) + (catch js/Error e nil))) + data-matches? (fn [e] + (some #{(str group)} (j/get-in e [:dataTransfer :types]))) + [active-drag set-drag!] (h/use-state nil) + [active-drop set-drop!] (h/use-state nil) + !should-drag? (h/use-ref false)] + {:drag-handle-props {:on-mouse-down #(reset! !should-drag? true) + :on-mouse-up #(reset! !should-drag? false)} + :drag-subject-props {:draggable true + :data-dragging active-drag + :data-dropping active-drop + :on-drag-over (j/fn [^js {:as e :keys [clientX + clientY + currentTarget]}] + (j/call e :preventDefault) + (when (data-matches? e) + (set-drop! (if (= ?child (last ?parent)) + (if (case axis + :y (< clientY (element-center-y currentTarget)) + :x (< clientX (element-center-x currentTarget))) + :before + :after) + :before)))) + :on-drag-leave (fn [^js e] + (j/call e :preventDefault) + (set-drop! nil)) + :on-drop (fn [^js e] + (.preventDefault e) + (set-drop! nil) + (when-let [source (receive-data e)] + (on-move {:destination id + :source source + :side active-drop}))) + :on-drag-end (fn [^js e] + (set-drag! nil)) + :on-drag-start (fn [^js e] + (if @!should-drag? + (do + (set-drag! true) + (transfer-data e id)) + (.preventDefault e)))} + :dragging active-drag + :dropping active-drop + :drop-indicator (when active-drop + (case axis + :y (v/x [:div.absolute.bg-focus-accent + {:class ["h-[4px] z-[99] inset-x-0 rounded" + (case active-drop + :before "top-[-2px]" + :after "bottom-[-2px]" nil)]}]) + :x (v/x [:div.absolute.bg-focus-accent + {:class ["w-[4px] z-[99] inset-y-0 rounded" + (case active-drop + :before "left-[-2px]" + :after "right-[-2px]" nil)]}])))})) \ No newline at end of file