diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1ddf..035111a8 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,5 +2,8 @@ + + + \ No newline at end of file diff --git a/src/sparkboard.css b/src/sparkboard.css index 9d92d9f1..9459f72b 100644 --- a/src/sparkboard.css +++ b/src/sparkboard.css @@ -256,6 +256,13 @@ @layer components { + .field-label { + @apply block font-bold text-base + } + .field-wrapper { + @apply gap-2 flex-v relative + } + /* https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */ .auto-size { @apply grid @@ -371,6 +378,7 @@ ring-offset-back bg-primary text-primary-txt hover:bg-primary/90 focus-visible:bg-primary/90 disabled:bg-primary active:ring-0 + px-6 py-3 } .btn-darken { diff --git a/src/sparkboard/app.cljc b/src/sparkboard/app.cljc index d6159fb2..7c5e8557 100644 --- a/src/sparkboard/app.cljc +++ b/src/sparkboard/app.cljc @@ -1,7 +1,7 @@ (ns sparkboard.app (:require [sparkboard.slack.schema] [sparkboard.app.account.ui] - [sparkboard.app.assets.ui] + [sparkboard.app.asset.ui] [sparkboard.app.board.ui] [sparkboard.app.chat.ui] [sparkboard.app.collection.ui] diff --git a/src/sparkboard/app/account/ui.cljc b/src/sparkboard/app/account/ui.cljc index 26371500..e37ab7db 100644 --- a/src/sparkboard/app/account/ui.cljc +++ b/src/sparkboard/app/account/ui.cljc @@ -1,20 +1,21 @@ (ns sparkboard.app.account.ui - (:require - #?(:cljs ["@radix-ui/react-dropdown-menu" :as dm]) - [inside-out.forms :as forms] - [promesa.core :as p] - [re-db.api :as db] - [sparkboard.i18n :refer [tr]] - [sparkboard.routing :as routes] - [sparkboard.ui :as ui] - [sparkboard.ui.header :as header] - [sparkboard.ui.icons :as icons] - [sparkboard.app.entity.ui :as entity.ui] - [sparkboard.ui.radix :as radix] - [sparkboard.util :as u] - [yawn.hooks :as h] - [yawn.view :as v] - [sparkboard.app.account.data :as data])) + (:require #?(:cljs ["@radix-ui/react-dropdown-menu" :as dm]) + [inside-out.forms :as forms] + [promesa.core :as p] + [re-db.api :as db] + [sparkboard.app.account.data :as data] + [sparkboard.app.entity.ui :as entity.ui] + [sparkboard.app.field-entry.ui :as entry.ui] + [sparkboard.app.form.ui :as form.ui] + [sparkboard.app.views.header :as header] + [sparkboard.app.views.radix :as radix] + [sparkboard.app.views.ui :as ui] + [sparkboard.i18n :refer [tr]] + [sparkboard.icons :as icons] + [sparkboard.routing :as routes] + [sparkboard.util :as u] + [yawn.hooks :as h] + [yawn.view :as v])) (ui/defview new-menu [params] (radix/dropdown-menu {:id :new-menu @@ -44,7 +45,7 @@ [{:on-select #(routes/nav! (routes/entity-route entity 'show) entity)} (:entity/title entity)]) recents)}) - (radix/dropdown-menu {:trigger [:div.btn-white.btn (tr :tr/new) down-arrow] + (radix/dropdown-menu {:trigger [:div.btn-white.btn (tr :tr/new) down-arrow] :children [[{:on-select #(routes/nav! 'sparkboard.app.board-data/new params)} (tr :tr/board)] [{:on-select #(routes/nav! 'sparkboard.app.org-data/new params)} (tr :tr/org)]]}) [header/chat] @@ -84,9 +85,9 @@ [:div.flex-v.gap-2 - [ui/text-field ?email] + [entry.ui/text-field ?email] (when (= :password @!step) - [ui/text-field ?password {:id "account-password"}]) + [entry.ui/text-field ?password {:id "account-password"}]) (str (forms/visible-messages !account)) [:button.btn.btn-primary.w-full.h-10.text-sm.p-3 (tr :tr/continue-with-email)]] @@ -131,7 +132,7 @@ (when-let [{:keys [org board project]} entities] [:div.p-body.flex-v.gap-8 (when (> (count all) 6) - [ui/filter-field ?filter]) + [form.ui/filter-field ?filter]) (let [limit (partial ui/truncate-items {:limit 10})] [:div.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-2.md:gap-8.-mx-2 (when (seq project) diff --git a/src/sparkboard/app/assets/data.cljc b/src/sparkboard/app/asset/data.cljc similarity index 97% rename from src/sparkboard/app/assets/data.cljc rename to src/sparkboard/app/asset/data.cljc index 94c7ebca..54fbb431 100644 --- a/src/sparkboard/app/assets/data.cljc +++ b/src/sparkboard/app/asset/data.cljc @@ -1,4 +1,4 @@ -(ns sparkboard.app.assets.data +(ns sparkboard.app.asset.data (:require #?(:clj [ring.util.response :as resp]) #?(:clj [sparkboard.server.assets :as assets]) [sparkboard.authorize :as az] diff --git a/src/sparkboard/app/asset/ui.cljc b/src/sparkboard/app/asset/ui.cljc new file mode 100644 index 00000000..1ed1a625 --- /dev/null +++ b/src/sparkboard/app/asset/ui.cljc @@ -0,0 +1,14 @@ +(ns sparkboard.app.asset.ui + (:require [sparkboard.query-params :as query-params] + [sparkboard.app.asset.data])) + +(def variants {:avatar {:op "bound" :width 200 :height 200} + :card {:op "bound" :width 600} + :page {:op "bound" :width 1200}}) + +(defn asset-src [asset variant] + (when-let [id (:entity/id asset)] + (str "/assets/" id + (some-> (variants variant) query-params/query-string)))) + +(defn css-url [s] (str "url(" s ")")) \ No newline at end of file diff --git a/src/sparkboard/app/assets/ui.cljc b/src/sparkboard/app/assets/ui.cljc deleted file mode 100644 index 08adadde..00000000 --- a/src/sparkboard/app/assets/ui.cljc +++ /dev/null @@ -1 +0,0 @@ -(ns sparkboard.app.assets.ui) \ No newline at end of file diff --git a/src/sparkboard/app/board/ui.cljc b/src/sparkboard/app/board/ui.cljc index 1f678a4b..75cea497 100644 --- a/src/sparkboard/app/board/ui.cljc +++ b/src/sparkboard/app/board/ui.cljc @@ -3,17 +3,21 @@ [promesa.core :as p] [re-db.api :as db] [sparkboard.app.account.data :as account.data] + [sparkboard.app.asset.ui :as asset.ui] [sparkboard.app.board.data :as data] [sparkboard.app.domain.ui :as domain.ui] [sparkboard.app.entity.ui :as entity.ui] + [sparkboard.app.field-entry.ui :as entry.ui] [sparkboard.app.field.ui :as field.ui] + [sparkboard.app.form.ui :as form.ui] [sparkboard.app.project.data :as project.data] [sparkboard.app.project.ui :as project.ui] + [sparkboard.app.form.ui :as form.ui] + [sparkboard.app.views.header :as header] + [sparkboard.app.views.radix :as radix] + [sparkboard.app.views.ui :as ui] [sparkboard.i18n :refer [tr]] [sparkboard.routing :as routing] - [sparkboard.ui :as ui] - [sparkboard.ui.header :as header] - [sparkboard.ui.radix :as radix] [sparkboard.util :as u] [yawn.hooks :as h] [yawn.view :as v])) @@ -36,18 +40,18 @@ :entity/id)))))]}) :required [?title ?domain]] [:form - {:class ui/form-classes + {:class form.ui/form-classes :on-submit (fn [^js e] (.preventDefault e) (ui/with-submission [result (data/new! {:board @!board}) :form !board] - (routing/nav! `show {:board-id (:entity/id result)}))) + (routing/nav! `show {:board-id (:entity/id result)}))) :ref (ui/use-autofocus-ref)} [:h2.text-2xl (tr :tr/new-board)] (when owners [:div.flex-v.gap-2 - [ui/input-label {} (tr :tr/owner)] + [:label.field-label {} (tr :tr/owner)] (radix/select-menu {:value @?owner :on-value-change (partial reset! ?owner) :options @@ -55,11 +59,11 @@ (map (fn [{:keys [entity/id entity/title image/avatar]}] {:value (str id) :text title - :icon [:img.w-5.h-5.rounded-sm {:src (ui/asset-src avatar :avatar)}]})))})]) + :icon [:img.w-5.h-5.rounded-sm {:src (asset.ui/asset-src avatar :avatar)}]})))})]) - [ui/text-field ?title {:label (tr :tr/title)}] + [entry.ui/text-field ?title {:label (tr :tr/title)}] (domain.ui/domain-field ?domain) - [ui/submit-form !board (tr :tr/create)]]))) + [form.ui/submit-form !board (tr :tr/create)]]))) (ui/defview register {:route "/b/:board-id/register"} @@ -67,8 +71,8 @@ (ui/with-form [!member {:member/name ?name :member/password ?pass}] [:div [:h3 (tr :tr/register)] - [ui/text-field ?name] - [ui/text-field ?pass] + [entry.ui/text-field ?name] + [entry.ui/text-field ?pass] [:button {:on-click #(p/let [res (routing/POST route @!member)] ;; TODO - how to determine POST success? #_(when (http-ok? res) @@ -105,7 +109,7 @@ [:div.p-body [:div.flex.gap-4.items-stretch - [ui/filter-field ?filter] + [form.ui/filter-field ?filter] [action-button {:on-click (fn [_] (p/let [{:as result @@ -148,11 +152,11 @@ (let [board (data/settings params)] [:<> (header/entity board) - [:div {:class ui/form-classes} - (entity.ui/use-persisted board :entity/title ui/text-field {:class "text-lg"}) - (entity.ui/use-persisted board :entity/description ui/prose-field {:class "bg-gray-100 px-3 py-3"}) + [:div {:class form.ui/form-classes} + (entity.ui/use-persisted board :entity/title entry.ui/text-field {:class "text-lg"}) + (entity.ui/use-persisted board :entity/description entry.ui/prose-field {:class "bg-gray-100 px-3 py-3"}) (entity.ui/use-persisted board :entity/domain domain.ui/domain-field) - (entity.ui/use-persisted board :image/avatar ui/image-field {:label (tr :tr/image.logo)}) + (entity.ui/use-persisted board :image/avatar entry.ui/image-field {:label (tr :tr/image.logo)}) (field.ui/fields-editor board :board/member-fields) (field.ui/fields-editor board :board/project-fields) diff --git a/src/sparkboard/app/chat/ui.cljc b/src/sparkboard/app/chat/ui.cljc index fc83689d..e4ae57e8 100644 --- a/src/sparkboard/app/chat/ui.cljc +++ b/src/sparkboard/app/chat/ui.cljc @@ -3,13 +3,14 @@ [clojure.string :as str] [promesa.core :as p] [sparkboard.app.chat.data :as data] + [sparkboard.app.field-entry.ui :as entry.ui] [sparkboard.app.member.data :as member.data] + [sparkboard.app.views.radix :as radix] + [sparkboard.app.views.ui :as ui] [sparkboard.i18n :refer [tr]] + [sparkboard.icons :as icons] [sparkboard.routing :as routes] [sparkboard.schema :as sch] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] - [sparkboard.ui.radix :as radix] [sparkboard.util :as u] [yawn.hooks :as h] [yawn.view :as v])) @@ -60,7 +61,7 @@ {:class (when (data/unread? account-id chat) "bg-blue-500")}]] [:div.text-gray-700.hidden.md:line-clamp-2.text-sm - (ui/show-prose + (entry.ui/show-prose (cond-> (:chat.message/content last-message) (sch/id= account-id (:entity/created-by last-message)) (update :prose/string (partial str (tr :tr/you) " "))))]]])) @@ -85,7 +86,7 @@ "bg-blue-500 text-white place-self-end" "bg-gray-100 text-gray-900 place-self-start")] :key id} - (ui/show-prose content)]) + (entry.ui/show-prose content)]) (ui/defview chat-header [{:keys [account-id chat]}] (let [close-icon [icons/close "w-6 h-6 hover:opacity-50"]] @@ -134,7 +135,7 @@ (sort-by :entity/created-at) (map (partial chat-message params)) doall)] - [ui/auto-size + [entry.ui/auto-size {:class [search-classes "m-1 whitespace-pre-wrap min-h-[38px] flex-none"] :type "text" diff --git a/src/sparkboard/app/domain/ui.cljc b/src/sparkboard/app/domain/ui.cljc index d907847b..e6cc3b03 100644 --- a/src/sparkboard/app/domain/ui.cljc +++ b/src/sparkboard/app/domain/ui.cljc @@ -3,8 +3,10 @@ [inside-out.forms :as forms] [promesa.core :as p] [sparkboard.app.domain.data :as data] + [sparkboard.app.field-entry.ui :as entry.ui] + [sparkboard.app.form.ui :as form.ui] [sparkboard.i18n :refer [tr]] - [sparkboard.ui :as ui])) + [sparkboard.app.views.ui :as ui])) #?(:cljs (defn availability-validator [] @@ -24,19 +26,19 @@ #?(:cljs (defn domain-field [?domain & [props]] - [ui/input-wrapper - [ui/show-label ?domain] + [:div.field-wrapper + [form.ui/show-label ?domain] [:div.flex.gap-2.items-stretch - (ui/text-field ?domain (merge {:wrap (fn [v] - (when-not (str/blank? v) - {:domain/name (data/qualify-domain (data/normalize-domain v))})) - :unwrap (fn [v] - (or (some-> v :domain/name data/unqualify-domain) "")) - :auto-complete "off" - :spell-check false - :wrapper-class "flex-auto"} - props - {:label false})) + (entry.ui/text-field ?domain (merge {:wrap (fn [v] + (when-not (str/blank? v) + {:domain/name (data/qualify-domain (data/normalize-domain v))})) + :unwrap (fn [v] + (or (some-> v :domain/name data/unqualify-domain) "")) + :auto-complete "off" + :spell-check false + :wrapper-class "flex-auto"} + props + {:label false})) [:div.flex.items-center.text-sm.text-gray-500.h-10 ".sparkboard.com"]]])) #?(:cljs diff --git a/src/sparkboard/app/entity/data.cljc b/src/sparkboard/app/entity/data.cljc index 52d3b9b4..81a49c3b 100644 --- a/src/sparkboard/app/entity/data.cljc +++ b/src/sparkboard/app/entity/data.cljc @@ -33,7 +33,7 @@ #_#_:db/fulltext true} :entity/field-entries {s- [:map-of :uuid :field-entry/as-map]} :entity/video {:doc "Primary video for project (distinct from fields)" - s- :video/entry} + s- :video/url} :entity/public? {:doc "Contents of this entity can be accessed without authentication (eg. and indexed by search engines)" s- :boolean} :entity/website {:doc "External website for entity" diff --git a/src/sparkboard/app/entity/ui.cljc b/src/sparkboard/app/entity/ui.cljc index 411cd728..c6c67f92 100644 --- a/src/sparkboard/app/entity/ui.cljc +++ b/src/sparkboard/app/entity/ui.cljc @@ -1,9 +1,10 @@ (ns sparkboard.app.entity.ui (:require [inside-out.forms :as forms] + [sparkboard.app.asset.ui :as asset.ui] [sparkboard.app.entity.data :as data] [sparkboard.routing :as routing] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] + [sparkboard.app.views.ui :as ui] + [sparkboard.icons :as icons] [sparkboard.validate :as validate] [yawn.hooks :as h] [yawn.view :as v])) @@ -43,7 +44,7 @@ (merge {:class ["w-12 sm:w-16" "bg-no-repeat sm:bg-secondary bg-center bg-contain"]} (when avatar - {:style {:background-image (ui/css-url (ui/asset-src avatar :avatar))}})))]) + {:style {:background-image (asset.ui/css-url (asset.ui/asset-src avatar :avatar))}})))]) [:div.flex.items-center.px-3.leading-snug [:div.line-clamp-2 title]]]) diff --git a/src/sparkboard/app/field/data.cljc b/src/sparkboard/app/field/data.cljc index 48ce4ecb..c30c46cb 100644 --- a/src/sparkboard/app/field/data.cljc +++ b/src/sparkboard/app/field/data.cljc @@ -6,8 +6,8 @@ [sparkboard.query :as q] [sparkboard.schema :as sch :refer [? s-]] [sparkboard.server.datalevin :as dl] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] + [sparkboard.app.views.ui :as ui] + [sparkboard.icons :as icons] [sparkboard.validate :as validate] [yawn.hooks :as h] [yawn.view :as v])) @@ -69,8 +69,6 @@ :field-option/label {s- :string}, :field-option/value {s- :string}, :video/url {s- :string} - :video/entry {s- [:map {:closed true} - :video/url]} :image-list/images {s- [:sequential :entity/id]} :link-list/links {s- [:sequential :link-list/link]} :select/value {s- :string} diff --git a/src/sparkboard/app/field/ui.cljc b/src/sparkboard/app/field/ui.cljc index b2128457..75f21994 100644 --- a/src/sparkboard/app/field/ui.cljc +++ b/src/sparkboard/app/field/ui.cljc @@ -7,12 +7,15 @@ [re-db.api :as db] [sparkboard.app.entity.data :as entity.data] [sparkboard.app.entity.ui :as entity.ui] + [sparkboard.app.field-entry.ui :as entry.ui] [sparkboard.app.field.data :as data] + [sparkboard.app.form.ui :as form.ui] + [sparkboard.app.views.radix :as radix] + [sparkboard.app.views.ui :as ui] + [sparkboard.color :as color] [sparkboard.i18n :refer [tr]] + [sparkboard.icons :as icons] [sparkboard.schema :as sch] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] - [sparkboard.ui.radix :as radix] [sparkboard.util :as u] [yawn.hooks :as h] [yawn.view :as v])) @@ -112,14 +115,14 @@ "opacity-0 group-hover:opacity-100" "cursor-drag"]}) [icons/drag-dots]]] - [ui/text-field ?label {:on-save save! - :wrapper-class "flex-auto" - :class "rounded-sm relative focus:z-2" - :style {:background-color @?color - :color (ui/contrasting-text-color @?color)}}] + [entry.ui/text-field ?label {:on-save save! + :wrapper-class "flex-auto" + :class "rounded-sm relative focus:z-2" + :style {:background-color @?color + :color (color/contrasting-text-color @?color)}}] [:div.relative.w-10.focus-within-ring.rounded.overflow-hidden.self-stretch - [ui/color-field ?color {:on-save save! - :style {:top -10 :left -10 :width 100 :height 100 :position "absolute"}}]] + [entry.ui/color-field ?color {:on-save save! + :style {:top -10 :left -10 :width 100 :height 100 :position "absolute"}}]] [radix/dropdown-menu {:id :field-option :trigger [:button.p-1.relative.icon-gray.cursor-default [icons/ellipsis-horizontal "w-4 h-4"]] @@ -146,7 +149,7 @@ (let [save! (fn [& _] (on-save (mapv u/prune @?options)))] [:div.col-span-2.flex-v.gap-3 - [ui/input-label (tr :tr/options)] + [:label.field-label (tr :tr/options)] (when (:loading? ?options) [:div.loading-bar.absolute.h-1.top-0.left-0.right-0]) (into [:div.flex-v] @@ -159,7 +162,7 @@ '?label @?new '?color "#ffffff"}) (forms/try-submit+ ?new (save!)))} - [ui/text-field ?new {:placeholder "Option label" :wrapper-class "flex-auto"}] + [entry.ui/text-field ?new {:placeholder "Option label" :wrapper-class "flex-auto"}] [:div.btn.bg-white.px-3.py-1.shadow "Add Option"]]) #_[ui/pprinted @?options]]))) @@ -167,22 +170,22 @@ [:div.bg-gray-100.gap-3.grid.grid-cols-2.pl-12.pr-7.pt-4.pb-6 [:div.col-span-2.flex-v.gap-3 - (entity.ui/use-persisted field :field/label ui/text-field {:class "bg-white text-sm" - :multi-line true}) - (entity.ui/use-persisted field :field/hint ui/text-field {:class "bg-white text-sm" - :multi-line true - :placeholder "Further instructions"})] + (entity.ui/use-persisted field :field/label entry.ui/text-field {:class "bg-white text-sm" + :multi-line true}) + (entity.ui/use-persisted field :field/hint entry.ui/text-field {:class "bg-white text-sm" + :multi-line true + :placeholder "Further instructions"})] (when (= :field.type/select (:field/type field)) (entity.ui/use-persisted field :field/options options-field)) #_[:div.flex.items-center.gap-2.col-span-2 [:span.font-semibold.text-xs.uppercase (:label (field-types (:field/type field)))]] [:div.contents.labels-normal - (entity.ui/use-persisted field :field/required? ui/checkbox-field) - (entity.ui/use-persisted field :field/show-as-filter? ui/checkbox-field) + (entity.ui/use-persisted field :field/required? entry.ui/checkbox-field) + (entity.ui/use-persisted field :field/show-as-filter? entry.ui/checkbox-field) (when (= attribute :board/member-fields) - (entity.ui/use-persisted field :field/show-at-registration? ui/checkbox-field)) - (entity.ui/use-persisted field :field/show-on-card? ui/checkbox-field) + (entity.ui/use-persisted field :field/show-at-registration? entry.ui/checkbox-field)) + (entity.ui/use-persisted field :field/show-on-card? entry.ui/checkbox-field) [:a.text-gray-500.hover:underline.cursor-pointer.flex.gap-2 {:on-click #(radix/simple-alert! {:message "Are you sure you want to remove this?" :confirm-text (tr :tr/remove) @@ -240,9 +243,9 @@ fields (->> (get entity attribute) (sort-by :field/order)) [expanded expand!] (h/use-state nil)] - [ui/input-wrapper {:class "labels-semibold"} + [:div.field-wrapper {:class "labels-semibold"} (when-let [label (or label (tr attribute))] - [ui/input-label {:class "flex items-center"} + [:label.field-label {:class "flex items-center"} label [:div.flex.ml-auto.items-center (when-not @!new-field @@ -286,10 +289,10 @@ {:on-click #(reset! !new-field nil)} [icons/close "w-5 h-5 "]] [:div.h-10.flex.items-center [(:icon (data/field-types @?type)) "icon-lg text-gray-700 mx-2"]] - [ui/text-field ?label {:label false - :ref !autofocus-ref - :placeholder (:label ?label) - :wrapper-class "flex-auto"}] + [entry.ui/text-field ?label {:label false + :ref !autofocus-ref + :placeholder (:label ?label) + :wrapper-class "flex-auto"}] [:button.btn.btn-white.h-10 {:type "submit"} (tr :tr/add)]] - [:div.pl-12.py-2 (ui/show-field-messages !form)]])])) \ No newline at end of file + [:div.pl-12.py-2 (form.ui/show-field-messages !form)]])])) \ No newline at end of file diff --git a/src/sparkboard/app/field_entry/ui.cljc b/src/sparkboard/app/field_entry/ui.cljc index 4cf9bacc..a9ffda55 100644 --- a/src/sparkboard/app/field_entry/ui.cljc +++ b/src/sparkboard/app/field_entry/ui.cljc @@ -1,13 +1,176 @@ (ns sparkboard.app.field-entry.ui - (:require [inside-out.forms :as forms] + (:require [applied-science.js-interop :as j] + [clojure.string :as str] + [clojure.set :as set] + [inside-out.forms :as forms] + [sparkboard.app.asset.ui :as asset.ui] + [sparkboard.app.asset.data :as asset.data] [sparkboard.app.field-entry.data :as data] - [sparkboard.ui :as ui] - [sparkboard.ui.radix :as radix] - [yawn.hooks :as h])) + [sparkboard.routing :as routing] + [sparkboard.app.views.ui :as ui] + [sparkboard.icons :as icons] + [sparkboard.app.views.radix :as radix] + [yawn.hooks :as h] + [yawn.view :as v] + [sparkboard.client.sanitize :as sanitize] + [sparkboard.app.form.ui :as form.ui] + [sparkboard.color :as color])) + +(defn parse-video-url [url] + (try + (or (when-let [id (some #(second (re-find % url)) [#"youtube\.com/watch\?v=([^&?\s]+)" + #"youtube\.com/embed/([^&?\s]+)" + #"youtube\.com/v/([^&?\s]+)" + #"youtu\.be/([^&?\s]+)"])] + {:type :youtube + :youtube/id id + :video/url url + :video/thumbnail (str "https://img.youtube.com/vi/" id "/hqdefault.jpg")}) + (when-let [id (some #(second (re-find % url)) [#"vimeo\.com/(?:video/)?(\d+)" + #"vimeo\.com/channels/.*/(\d+)"])] + {:type :vimeo + :vimeo/id id + :video/url url + ;; 3rd party service; may be unreliable + ;; https://stackoverflow.com/questions/1361149/get-img-thumbnails-from-vimeo + ;; https://github.com/ThatGuySam/vumbnail + :video/thumbnail (str "https://vumbnail.com/" id ".jpg")})) + (catch js/Error e nil))) + +(defn show-prose [{:as m :prose/keys [format string]}] + (when m + (case format + :prose.format/html [sanitize/safe-html string] + :prose.format/markdown [ui/show-markdown string]))) + +(v/defview auto-size [props] + (let [v! (h/use-state "") + props (merge props {:value (:value props @v!) + :on-change (:on-change props + #(reset! v! (j/get-in % [:target :value])))})] + [:div.auto-size + [:div.bg-black (select-keys props [:class :style]) + (str (:value props) " ")] + [:textarea (assoc props :rows 1)]])) + +(ui/defview checkbox-field + "A text-input element that reads metadata from a ?field to display appropriately" + [?field props] + ;; TODO handle on-save + (let [messages (forms/visible-messages ?field) + loading? (:loading? ?field) + props (-> (v/merge-props (form.ui/?field-props ?field + (j/get-in [:target :checked]) + props) + {:type "checkbox" + :on-blur (forms/blur-handler ?field) + :on-focus (forms/focus-handler ?field) + :on-change #(let [value (.. ^js % -target -checked)] + (form.ui/maybe-save-field ?field props value)) + :class [(when loading? "invisible") + (if (:invalid (forms/types messages)) + "outline-invalid" + "outline-default")]}) + (set/rename-keys {:value :checked}) + (update :checked boolean)) + ] + [:div.flex.flex-col.gap-1.relative + [:label.relative.flex.items-center + (when loading? + [:div.h-5.w-5.inline-flex.items-center.justify-center.absolute + [ui/loading:spinner "h-3 w-3"]]) + [:input.h-5.w-5.rounded.border-gray-300.text-primary + (form.ui/pass-props props)] + [:div.flex-v.gap-1.ml-2 + (when-let [label (:label ?field)] + [:div.flex.items-center.h-5 label]) + (when (seq messages) + (into [:div.text-gray-500] (map form.ui/view-message) messages))]]])) + +(defn field-props [?field & [props]] + (v/props (:props (meta ?field)) props)) + +(defn show-postfix [?field props] + (when-let [postfix (or (:postfix props) + (:postfix (meta ?field)) + (and (some-> (:persisted-value props) + (not= (:value props))) + [icons/pencil-outline "w-4 h-4 text-txt/40"]))] + [:div.pointer-events-none.absolute.inset-y-0.right-0.top-0.bottom-0.flex.items-center.p-2 postfix])) + + + +(defn text-field + "A text-input element that reads metadata from a ?field to display appropriately" + [?field & [props]] + (let [{:as props + :keys [inline? + multi-line + multi-paragraph + wrap + unwrap + wrapper-class + on-save + persisted-value] + :or {wrap identity + unwrap identity}} (merge props (:props (meta ?field))) + blur! (fn [e] (j/call-in e [:target :blur])) + cancel! (fn [e] + (reset! ?field persisted-value) + (blur! e)) + props (v/merge-props props + (form.ui/?field-props ?field + (j/get-in [:target :value]) + (merge {:wrap #(when-not (str/blank? %) %) + :unwrap #(or % "")} props)) + + {:class ["pr-8 rounded" + (if inline? + "form-inline" + "default-ring") + (when (:invalid (forms/types (forms/visible-messages ?field))) + "outline-invalid")] + :placeholder (or (:placeholder props) + (when inline? (or (:label props) (:label ?field)))) + :on-key-down + (ui/keydown-handler {(if multi-paragraph + :Meta-Enter + :Enter) #(when on-save + (j/call % :preventDefault) + (form.ui/maybe-save-field ?field props @?field)) + :Escape blur! + :Meta-. cancel!})})] + (v/x + [:div.field-wrapper + {:class wrapper-class} + (when-not inline? (form.ui/show-label ?field (:label props))) + [:div.flex-v.relative + (if multi-line + [auto-size (v/merge-props {:class "form-text w-full"} (form.ui/pass-props props))] + [:input.form-text (form.ui/pass-props props)]) + (show-postfix ?field props) + (when (:loading? ?field) + [:div.loading-bar.absolute.bottom-0.left-0.right-0 {:class "h-[3px]"}])] + (form.ui/show-field-messages ?field)]))) + +(defn wrap-prose [value] + (when-not (str/blank? value) + {:prose/format :prose.format/markdown + :prose/string value})) + +(def unwrap-prose :prose/string) + +(ui/defview prose-field [?field props] + ;; TODO + ;; multi-line markdown editor with formatting + (text-field ?field (merge {:wrap wrap-prose + :unwrap unwrap-prose + :multi-line true} + props))) (ui/defview show-select [?field {:field/keys [label options]} entry] [:div.flex-v.gap-2 - [ui/input-label label] + [:label.field-label label] [radix/select-menu {:value (:select/value @?field) :id (str (:entity/id entry)) :read-only? (:can-edit? ?field) @@ -17,6 +180,137 @@ :value value})) doall)}]]) +(comment + + (defn youtube-embed [video-id] + [:iframe#ytplayer {:type "text/html" :width 640 :height 360 + :frameborder 0 + :src (str "https://www.youtube.com/embed/" video-id)}]) + (video-field [:field.video/youtube-sdgurl "gMpYX2oev0M"]) + ) + +(ui/defview show-video [url] + [:a.bg-black.w-full.aspect-video.flex.items-center.justify-center.group.relative + {:href url + :target "_blank" + :style {:background-image (asset.ui/css-url (:video/thumbnail (parse-video-url url))) + :background-size "cover" + :background-position "center"}} + [icons/external-link "absolute text-white top-2 right-2 icon-sm drop-shadow"] + [icons/play-circle "icon-xl text-white drop-shadow-2xl transition-all group-hover:scale-110 "]]) + +(ui/defview video-field + {:key (fn [?field] #?(:cljs (goog/getUid ?field)))} + [?field {:as props :keys [can-edit?]}] + (let [!editing? (h/use-state (nil? @?field))] + [:div.field-wrapper + ;; preview shows persisted value? + [:div.flex.items-center + [:div.flex-auto (form.ui/show-label ?field (:label props))] + (when can-edit? + [:div.place-self-end [:a {:on-click #(swap! !editing? not)} + [(if @!editing? icons/dash icons/chevron-down) "icon-gray"]]])] + + (when (and can-edit? @!editing?) + (text-field ?field (merge props + {:label nil + :placeholder "YouTube or Vimeo url" + :wrap (partial hash-map :video/url) + :unwrap :video/url}))) + (when-let [url (:video/url @?field)] + [show-video url])])) + +(ui/defview select-field [?field {:as props :keys [label options]}] + [:div.field-wrapper + (form.ui/show-label ?field label) + [radix/select-menu (-> (form.ui/?field-props ?field identity (assoc props + :on-change #(form.ui/maybe-save-field ?field props @?field))) + (set/rename-keys {:on-change :on-value-change}) + (assoc :can-edit? (:can-edit? props) + :options (->> options + (map (fn [{:field-option/keys [label value color]}] + {:text label + :value value})) + doall)))] + (when (:loading? ?field) + [:div.loading-bar.absolute.bottom-0.left-0.right-0 {:class "h-[3px]"}])]) + +(ui/defview color-field [?field props] + (let [get-value (j/get-in [:target :value])] + [:input.default-ring.default-ring-hover.rounded + (-> (v/merge-props + (form.ui/pass-props props) + (form.ui/?field-props ?field get-value props) + {:on-blur (fn [e] + (reset! ?field (get-value e)) + (form.ui/maybe-save-field ?field props (get-value e))) + :type "color"}) + (update :value #(or % "#ffffff")))])) + +(ui/defview image-field [?field props] + (let [src (asset.ui/asset-src @?field :card) + loading? (:loading? ?field) + !selected-blob (h/use-state nil) + !dragging? (h/use-state false) + thumbnail (ui/use-loaded-image src @!selected-blob) + on-file (fn [file] + (forms/touch! ?field) + (reset! !selected-blob (js/URL.createObjectURL file)) + (ui/with-submission [asset (routing/POST `asset.data/upload! (doto (js/FormData.) + (.append "files" file))) + :form ?field] + (reset! ?field asset) + (form.ui/maybe-save-field ?field props asset))) + !input (h/use-ref)] + ;; TODO handle on-save + [:label.gap-2.flex-v.relative + {:for (form.ui/field-id ?field)} + (form.ui/show-label ?field (:label props)) + [:button.flex-v.items-center.justify-center.p-3.gap-3.relative.default-ring.default-ring-hover + {:on-click #(j/call @!input :click) + :class ["rounded-lg" + (if @!dragging? + "outline-2 outline-focus-accent")] + :on-drag-over (fn [^js e] + (.preventDefault e) + (reset! !dragging? true)) + :on-drag-leave (fn [^js e] + (reset! !dragging? false)) + :on-drop (fn [^js e] + (.preventDefault e) + (some-> (j/get-in e [:dataTransfer :files 0]) on-file))} + (when loading? + [icons/loading "w-4 h-4 absolute top-0 right-0 text-txt/40 mx-2 my-3"]) + [:div.block.relative.rounded.cursor-pointer.flex.items-center.justify-center.rounded-lg + (v/props {:class "text-muted-txt hover:text-txt w-32 h-32"} + (when thumbnail + {:class "bg-contain bg-no-repeat bg-center" + :style {:background-image (asset.ui/css-url thumbnail)}})) + + (when-not thumbnail + (ui/upload-icon "w-5 h-5 m-auto")) + + [:input.hidden + {:id (form.ui/field-id ?field) + :ref !input + :type "file" + :accept "image/webp, image/jpeg, image/gif, image/png, image/svg+xml" + :on-change #(some-> (j/get-in % [:target :files 0]) on-file)}]] + (form.ui/show-field-messages ?field)]])) + +(ui/defview images-field [?field {:as props :keys [label]}] + (let [images (->> (:images/order @?field) + (map (fn [id] + {:url (asset.ui/asset-src {:entity/id id} :card) + :entity/id id})))] + (for [{:keys [entity/id url]} images] + ;; TODO + ;; upload image, + ;; re-order images + [:div.relative {:key url} + [:div.inset-0.bg-black.absolute.opacity-10] + [:img {:src url}]]))) + (ui/defview show-entry {:key (comp :entity/id :field)} [{:keys [parent field entry can-edit?]}] @@ -26,13 +320,13 @@ :can-edit? can-edit? :on-save (partial data/save-entry! nil (:entity/id parent) (:entity/id field))}] (case (:field/type field) - :field.type/video [ui/video-field ?field props] - :field.type/select [ui/select-field ?field (merge props - {:wrap (fn [x] {:select/value x}) - :unwrap :select/value - :persisted-value value - :options (:field/options field)})] + :field.type/video [video-field ?field props] + :field.type/select [select-field ?field (merge props + {:wrap (fn [x] {:select/value x}) + :unwrap :select/value + :persisted-value value + :options (:field/options field)})] :field.type/link-list [ui/pprinted value props] - :field.type/image-list [ui/images-field ?field props] - :field.type/prose [ui/prose-field ?field props] + :field.type/image-list [images-field ?field props] + :field.type/prose [prose-field ?field props] (str "no match" field)))) \ No newline at end of file diff --git a/src/sparkboard/app/form/ui.cljc b/src/sparkboard/app/form/ui.cljc new file mode 100644 index 00000000..1b4bb06d --- /dev/null +++ b/src/sparkboard/app/form/ui.cljc @@ -0,0 +1,102 @@ +(ns sparkboard.app.form.ui + (:require [applied-science.js-interop :as j] + [sparkboard.app.views.ui :as ui] + [inside-out.forms :as io] + [yawn.view :as v] + [sparkboard.i18n :refer [tr]] + [sparkboard.util :as u] + [sparkboard.icons :as icons] + [sparkboard.color :as color])) + +(defn field-id [?field] + (str "field-" (goog/getUid ?field))) + +(defn maybe-save-field [?field props value] + (when-let [on-save (and (not= value (:persisted-value props)) + (:on-save props))] + (io/try-submit+ ?field + (on-save value)))) + +(defn pass-props [props] (dissoc props + :multi-line :postfix :wrapper-class + :persisted-value :on-save :on-change-value + :wrap :unwrap + :inline? + :can-edit? + :label)) + +(defn show-label [?field & [label]] + (when-let [label (u/some-or label (:label ?field))] + [:label.field-label {:for (field-id ?field)} label])) + +(defn ?field-props [?field + get-value + {:as props + :keys [wrap + unwrap + on-save + on-change-value + on-change + persisted-value] + :or {wrap identity unwrap identity}}] + (cond-> {:id (field-id ?field) + :value (unwrap @?field) + :on-change (fn [e] + (let [new-value (wrap (get-value e))] + (reset! ?field new-value) + (when on-change-value + (pass-props (on-change-value new-value))) + (when on-change + (on-change e)))) + :on-blur (fn [e] + (maybe-save-field ?field props @?field) + ((io/blur-handler ?field) e)) + :on-focus (io/focus-handler ?field)} + persisted-value + (assoc :persisted-value (unwrap persisted-value)))) + +(def email-validator (fn [v _] + (when v + (when-not (re-find #"^[^@]+@[^@]+$" v) + (tr :tr/invalid-email))))) + +(def form-classes "flex flex-col gap-4 p-6 max-w-lg mx-auto bg-back relative text-sm") + +(ui/defview view-message [{:keys [type content]}] + (case type + :in-progress (ui/loading:spinner " h-4 w-4 text-blue-600 ml-2") + [:div + {:style (case type + (:error :invalid) {:color color/invalid-text-color + :background-color color/invalid-bg-color} + nil)} + content])) + +(defn show-field-messages [?field] + (when-let [messages (seq (io/visible-messages ?field))] + (v/x (into [:div.gap-3.text-sm] (map view-message messages))))) + +(ui/defview submit-form [?form label] + [:<> + (show-field-messages ?form) + [:button.btn.btn-primary + {:type "submit" + :disabled (not (io/submittable? ?form))} + label]]) + +(defn filter-field [?field & [attrs]] + (let [loading? (or (:loading? ?field) (:loading? attrs))] + [:div.flex.relative.items-stretch.flex-auto + [:input.pr-9.border.border-gray-300.w-full.rounded-lg.p-3 + (v/props (?field-props ?field (j/get-in [:target :value]) {:unwrap #(or % "")}) + {:class ["outline-none focus-visible:outline-4 outline-offset-0 focus-visible:outline-gray-200"] + :placeholder "Search..." + :on-key-down #(when (= "Escape" (.-key ^js %)) + (reset! ?field nil))})] + [:div.absolute.top-0.right-0.bottom-0.flex.items-center.pr-3 + {:class "text-txt/40"} + (cond loading? (icons/loading "w-4 h-4 rotate-3s") + (seq @?field) [:div.contents.cursor-pointer + {:on-click #(reset! ?field nil)} + (icons/close "w-5 h-5")] + :else (icons/search "w-6 h-6"))]])) \ No newline at end of file diff --git a/src/sparkboard/app/member/ui.cljc b/src/sparkboard/app/member/ui.cljc index d5ba7515..4da36b9e 100644 --- a/src/sparkboard/app/member/ui.cljc +++ b/src/sparkboard/app/member/ui.cljc @@ -1,7 +1,8 @@ (ns sparkboard.app.member.ui - (:require [sparkboard.app.member.data :as data] + (:require [sparkboard.app.asset.ui :as asset.ui] + [sparkboard.app.member.data :as data] [sparkboard.i18n :refer [tr]] - [sparkboard.ui :as ui])) + [sparkboard.app.views.ui :as ui])) (ui/defview show {:route "/m/:member-id" @@ -23,4 +24,4 @@ (map (fn [{:tag/keys [label background-color]}] [:li {:style (when background-color {:background-color background-color})} label])) tags)]) - (when avatar [:img {:src (ui/asset-src avatar :card)}])])) \ No newline at end of file + (when avatar [:img {:src (asset.ui/asset-src avatar :card)}])])) \ No newline at end of file diff --git a/src/sparkboard/app/org/data.cljc b/src/sparkboard/app/org/data.cljc index 03b84c70..af719a9a 100644 --- a/src/sparkboard/app/org/data.cljc +++ b/src/sparkboard/app/org/data.cljc @@ -11,9 +11,9 @@ [sparkboard.routing :as routes] [sparkboard.schema :as sch :refer [? s-]] [sparkboard.server.datalevin :as dl] - [sparkboard.ui :as ui] - [sparkboard.ui.header :as header] - [sparkboard.ui.icons :as icons] + [sparkboard.app.views.ui :as ui] + [sparkboard.app.views.header :as header] + [sparkboard.icons :as icons] [sparkboard.util :as u] [sparkboard.validate :as validate] [yawn.hooks :as h] diff --git a/src/sparkboard/app/org/ui.cljc b/src/sparkboard/app/org/ui.cljc index 069db468..24caf52c 100644 --- a/src/sparkboard/app/org/ui.cljc +++ b/src/sparkboard/app/org/ui.cljc @@ -4,11 +4,13 @@ [sparkboard.app.domain.ui :as domain.ui] [sparkboard.app.entity.data :as entity.data] [sparkboard.app.entity.ui :as entity.ui] + [sparkboard.app.field-entry.ui :as entry.ui] + [sparkboard.app.form.ui :as form.ui] [sparkboard.app.org.data :as data] + [sparkboard.app.views.header :as header] + [sparkboard.app.views.ui :as ui] [sparkboard.i18n :refer [tr]] [sparkboard.routing :as routes] - [sparkboard.ui :as ui] - [sparkboard.ui.header :as header] [sparkboard.util :as u] [yawn.hooks :as h] [yawn.view :as v])) @@ -28,17 +30,17 @@ (let [q q] (set-result! {:loading? true}) (p/let [result (data/search-once {:org-id (:org-id params) - :q q})] + :q q})] (when (= q @?filter) (set-result! {:value result :q q})))))) [q]) [:div (header/entity org) - [:div.p-body (ui/show-prose description)] + [:div.p-body (entry.ui/show-prose description)] [:div.p-body [:div.flex.gap-4.items-stretch - [ui/filter-field ?filter {:loading? (:loading? result)}] + [form.ui/filter-field ?filter {:loading? (:loading? result)}] [:a.btn.btn-white.flex.items-center.px-3 {:href (routes/path-for ['sparkboard.app.board-data/new {:query-params {:org-id (:entity/id org)}}])} @@ -62,12 +64,12 @@ (let [org (data/settings params)] [:<> (header/entity org) - [:div {:class ui/form-classes} - (entity.ui/use-persisted org :entity/title ui/text-field) - (entity.ui/use-persisted org :entity/description ui/prose-field) + [:div {:class form.ui/form-classes} + (entity.ui/use-persisted org :entity/title entry.ui/text-field) + (entity.ui/use-persisted org :entity/description entry.ui/prose-field) (entity.ui/use-persisted org :entity/domain domain.ui/domain-field) ;; TODO - uploading an image does not work - (entity.ui/use-persisted org :image/avatar ui/image-field {:label (tr :tr/image.logo)}) + (entity.ui/use-persisted org :image/avatar entry.ui/image-field {:label (tr :tr/image.logo)}) ]])) @@ -80,13 +82,13 @@ :entity/domain ?domain}) :required [?title ?domain]] [:form - {:class ui/form-classes + {:class form.ui/form-classes :on-submit (fn [e] (.preventDefault e) (ui/with-submission [result (data/new! {:org @!org}) :form !org] - (routes/nav! [`show {:org-id (:entity/id result)}])))} + (routes/nav! [`show {:org-id (:entity/id result)}])))} [:h2.text-2xl (tr :tr/new-org)] - [ui/text-field ?title {:label (tr :tr/title)}] + [entry.ui/text-field ?title {:label (tr :tr/title)}] (domain.ui/domain-field ?domain) - [ui/submit-form !org (tr :tr/create)]])) \ No newline at end of file + [form.ui/submit-form !org (tr :tr/create)]])) \ No newline at end of file diff --git a/src/sparkboard/app/project/new_flow.cljc b/src/sparkboard/app/project/new_flow.cljc index e6b71d52..e108a150 100644 --- a/src/sparkboard/app/project/new_flow.cljc +++ b/src/sparkboard/app/project/new_flow.cljc @@ -7,11 +7,11 @@ [sparkboard.query :as q] [sparkboard.routing :as routes] [sparkboard.server.datalevin :as dl] - [sparkboard.ui :as ui] + [sparkboard.app.views.ui :as ui] - [sparkboard.ui.header :as header] - [sparkboard.ui.icons :as icons] - [sparkboard.ui.radix :as radix] + [sparkboard.app.views.header :as header] + [sparkboard.icons :as icons] + [sparkboard.app.views.radix :as radix] [sparkboard.validate :as validate] [yawn.hooks :as h] [yawn.view :as v])) diff --git a/src/sparkboard/app/project/ui.cljc b/src/sparkboard/app/project/ui.cljc index 83d52ba0..fa924dad 100644 --- a/src/sparkboard/app/project/ui.cljc +++ b/src/sparkboard/app/project/ui.cljc @@ -6,29 +6,13 @@ [sparkboard.app.project.data :as data] [sparkboard.i18n :refer [tr]] [sparkboard.routing :as routing] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] - [sparkboard.ui.radix :as radix] + [sparkboard.app.views.ui :as ui] + [sparkboard.icons :as icons] + [sparkboard.app.views.radix :as radix] [sparkboard.validate :as validate] [yawn.hooks :as h] [yawn.view :as v])) -(defn youtube-embed [video-id] - [:iframe#ytplayer {:type "text/html" :width 640 :height 360 - :frameborder 0 - :src (str "https://www.youtube.com/embed/" video-id)}]) - -(defn video-field [[kind v]] - (case kind - :video/youtube-id (youtube-embed v) - :video/youtube-url [:a {:href v} "youtube video"] - :video/vimeo-url [:a {:href v} "vimeo video"] - {kind v})) - -(comment - (video-field [:field.video/youtube-sdgurl "gMpYX2oev0M"]) - ) - (def btn (v/from-element :div.btn.btn-transp.border-2.py-2.px-3)) (def hint (v/from-element :div.flex.items-center.text-sm {:class "text-primary/70"})) (def chiclet (v/from-element :div.rounded.px-2.py-1 {:class "bg-primary/5 text-primary/90"})) @@ -161,7 +145,7 @@ modal-close]] [:div.px-body.flex-v.gap-6 - (ui/show-prose description) + (entry.ui/show-prose description) (when badges [:section (into [:ul] @@ -177,9 +161,9 @@ :entry entry})) [:section.flex-v.gap-2.items-start [manage-community-actions project (:project/community-actions project)]] - (when-let [vid video] + (when video [:section [:h3 (tr :tr/video)] - [video-field vid]])]]])) + [entry.ui/show-video video]])]]])) (ui/defview new {:route "/new/p/:board-id" diff --git a/src/sparkboard/ui/header.cljc b/src/sparkboard/app/views/header.cljc similarity index 88% rename from src/sparkboard/ui/header.cljc rename to src/sparkboard/app/views/header.cljc index 6d19d80c..966b8552 100644 --- a/src/sparkboard/ui/header.cljc +++ b/src/sparkboard/app/views/header.cljc @@ -1,18 +1,19 @@ -(ns sparkboard.ui.header +(ns sparkboard.app.views.header (:require #?(:cljs ["@radix-ui/react-popover" :as Popover]) [promesa.core :as p] [re-db.api :as db] + [sparkboard.app.asset.ui :as asset.ui] [sparkboard.app.chat.data :as chat.data] [sparkboard.app.chat.ui :as chat.ui] [sparkboard.app.entity.ui :as entity.ui] [sparkboard.i18n :as i :refer [tr]] [sparkboard.routing :as routes] - [sparkboard.ui :as ui] - [sparkboard.ui.icons :as icons] - [sparkboard.ui.radix :as radix] + [sparkboard.app.views.ui :as ui] + [sparkboard.icons :as icons] + [sparkboard.app.views.radix :as radix] + [sparkboard.util :as u] [yawn.hooks :as h] - [yawn.util :as yu] - [sparkboard.util :as u])) + [yawn.util :as yu])) (defn btn [{:keys [icon href]}] [(if href :a :div) @@ -89,7 +90,7 @@ [:<> (radix/dropdown-menu {:trigger [:button.flex.items-center.focus-ring.rounded.px-1 {:tab-index 0} - [:img.rounded-full.h-6.w-6 {:src (ui/asset-src (:image/avatar account) :avatar)}]] + [:img.rounded-full.h-6.w-6 {:src (asset.ui/asset-src (:image/avatar account) :avatar)}]] :children [[{:on-click #(routes/nav! 'sparkboard.app.account-ui/show)} (tr :tr/home)] [{:on-click #(routes/nav! 'sparkboard.app.account-ui/logout!)} (tr :tr/logout)] [{:sub? true @@ -106,7 +107,7 @@ (when avatar [:a.contents {:href entity-href} [:img.h-10 - {:src (ui/asset-src avatar :avatar)}]]) + {:src (asset.ui/asset-src avatar :avatar)}]]) [:a.contents {:href entity-href} [:h3.hover:underline title]] diff --git a/src/sparkboard/ui/layouts.cljc b/src/sparkboard/app/views/layouts.cljc similarity index 82% rename from src/sparkboard/ui/layouts.cljc rename to src/sparkboard/app/views/layouts.cljc index c48fa847..06707f3a 100644 --- a/src/sparkboard/ui/layouts.cljc +++ b/src/sparkboard/app/views/layouts.cljc @@ -1,4 +1,4 @@ -(ns sparkboard.ui.layouts) +(ns sparkboard.app.views.layouts) (defn two-col [left & right] [:div.grid.grid-cols-1.md:grid-cols-2.h-screen diff --git a/src/sparkboard/ui/radix.cljc b/src/sparkboard/app/views/radix.cljc similarity index 99% rename from src/sparkboard/ui/radix.cljc rename to src/sparkboard/app/views/radix.cljc index addf87ed..003a4b2c 100644 --- a/src/sparkboard/ui/radix.cljc +++ b/src/sparkboard/app/views/radix.cljc @@ -1,11 +1,11 @@ -(ns sparkboard.ui.radix +(ns sparkboard.app.views.radix (:require #?(:cljs ["@radix-ui/react-alert-dialog" :as alert]) #?(:cljs ["@radix-ui/react-dialog" :as dialog]) #?(:cljs ["@radix-ui/react-dropdown-menu" :as dm]) #?(:cljs ["@radix-ui/react-select" :as sel]) #?(:cljs ["@radix-ui/react-tabs" :as tabs]) #?(:cljs ["@radix-ui/react-tooltip" :as tooltip]) - [sparkboard.ui.icons :as icons] + [sparkboard.icons :as icons] [yawn.view :as v] [yawn.util] [sparkboard.i18n :refer [tr]] diff --git a/src/sparkboard/ui.clj b/src/sparkboard/app/views/ui.clj similarity index 94% rename from src/sparkboard/ui.clj rename to src/sparkboard/app/views/ui.clj index d9a93b0f..8f04dc5e 100644 --- a/src/sparkboard/ui.clj +++ b/src/sparkboard/app/views/ui.clj @@ -1,4 +1,4 @@ -(ns sparkboard.ui +(ns sparkboard.app.views.ui (:require [sparkboard.util :as u] [yawn.view :as v] [clojure.walk :as walk] @@ -50,14 +50,14 @@ (defmacro boundary [{:keys [on-error]} & body] `(let [on-error# ~on-error] (~'try - (~'sparkboard.ui/error-boundary + (~'sparkboard.app.views.ui/error-boundary on-error# ~@body) (~'catch ~'js/Error e# (on-error# e#))))) (defmacro transition [expr] - `(~'sparkboard.ui/startTransition (fn [] ~expr))) + `(~'sparkboard.app.views.ui/startTransition (fn [] ~expr))) (defmacro with-let "Within a reaction, evaluates bindings once, memoizing the results." diff --git a/src/sparkboard/app/views/ui.cljs b/src/sparkboard/app/views/ui.cljs new file mode 100644 index 00000000..c2e004ff --- /dev/null +++ b/src/sparkboard/app/views/ui.cljs @@ -0,0 +1,286 @@ +(ns sparkboard.app.views.ui + (:require ["@radix-ui/react-dropdown-menu" :as dm] + ["prosemirror-keymap" :refer [keydownHandler]] + ["markdown-it" :as md] + ["linkify-element" :as linkify-element] + ["react" :as react] + [applied-science.js-interop :as j] + [cljs.reader :as edn] + [clojure.pprint] + [clojure.set :as set] + [clojure.string :as str] + [inside-out.forms :as forms] + [inside-out.macros] + [promesa.core :as p] + [re-db.api :as db] + [re-db.react] + [shadow.cljs.modern :refer [defclass]] + [sparkboard.app.asset.ui :as asset.ui] + [sparkboard.client.sanitize :as sanitize] + [sparkboard.i18n] + [sparkboard.i18n :refer [tr]] + [sparkboard.routing :as routing] + [sparkboard.icons :as icons] + [sparkboard.util :as u] + [yawn.hooks :as h] + [yawn.view :as v]) + (:require-macros [sparkboard.app.views.ui :as ui])) + +(defn dev? [] (= "dev" (db/get :env/config :env))) + +(defn loading-bar [& [class]] + [:div.relative + {:class class} + [:div.loading-bar]]) + +(defonce ^js Markdown (md)) + +(ui/defview show-markdown + [source] + (let [!ref (h/use-ref)] + (h/use-effect (fn [] + (when-let [el @!ref] + (-> el + (j/!set :innerHTML (.render Markdown (or source ""))) + (linkify-element)))) + [@!ref source]) + (v/x [:div {:class "prose contents" + :ref !ref + :dangerouslySetInnerHTML #js{:__html ""}}]))) + +(defn filtered [match-text] + (comp + (remove :entity/archived?) + (filter (if match-text + #(re-find (re-pattern (str "(?i)" match-text)) (:entity/title %)) + identity)))) + +(defn pprinted [x & _] + [:pre.whitespace-pre-wrap (with-out-str (clojure.pprint/pprint x))]) + +(def safe-html sanitize/safe-html) + +(def logo-url "/images/logo-2023.png") + +(defn logo [classes] + [:svg {:class classes + :viewBox "0 0 551 552" + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlns-xlink "http://www.w3.org/1999/xlink" + :xml-space "preserve" + :fill "currentColor" + :style {:fill-rule "evenodd" + :clip-rule "evenodd" + :stroke-linejoin "round" + :stroke-miterlimit 2}} + [:path {:d "M282,0.5L550.5,0.5L550.5,273.5L462.5,273.5L462.5,313L539.5,313L539.5,551.5L308,551.5L308,507.5L252,507.5L252,548.5L0.5,548.5L0.5,313L105,313L105,279L6.5,279L6.5,6.5L234.5,6.5L234.5,77L282,77L282,0.5ZM283,1.5L283,78L233.5,78L233.5,7.5L7.5,7.5L7.5,278L106,278L106,314L1.5,314L1.5,547.5L251,547.5L251,506.5L309,506.5L309,550.5L538.5,550.5L538.5,314L461.5,314L461.5,272.5L549.5,272.5L549.5,1.5L283,1.5ZM305,24L527,24L527,249L461.5,249L461.5,202.5L439,202.5L439,249L380,249L380,176.5L305,176.5L305,100L353,100L353,78L305,78L305,24ZM306,25L306,77L354,77L354,101L306,101L306,175.5L381,175.5L381,248L438,248L438,201.5L462.5,201.5L462.5,248L526,248L526,25L306,25ZM30.5,30L210.5,30L210.5,78L173,78L173,100L210.5,100L210.5,144.5L233.5,144.5L233.5,100L283,100L283,176.5L188.5,176.5L188.5,278L218,278L218,206.5L283,206.5L283,272.5L349,272.5L349,302.5L380,302.5L380,272.5L439,272.5L439,314L309,314L309,484L251,484L251,314L136,314L136,278L159,278L159,255.5L136,255.5L136,221.5L106,221.5L106,255.5L30.5,255.5L30.5,30ZM31.5,31L31.5,254.5L105,254.5L105,220.5L137,220.5L137,254.5L160,254.5L160,279L137,279L137,313L252,313L252,483L308,483L308,313L438,313L438,273.5L381,273.5L381,303.5L348,303.5L348,273.5L282,273.5L282,207.5L219,207.5L219,279L187.5,279L187.5,175.5L282,175.5L282,101L234.5,101L234.5,145.5L209.5,145.5L209.5,101L172,101L172,77L209.5,77L209.5,31L31.5,31ZM305,206.5L349,206.5L349,249L305,249L305,206.5ZM306,207.5L306,248L348,248L348,207.5L306,207.5ZM328.5,333.5L439,333.5L439,400.5L461.5,400.5L461.5,333.5L519.5,333.5L519.5,532L328.5,532L328.5,506.5L361.5,506.5L361.5,484L328.5,484L328.5,333.5ZM329.5,334.5L329.5,483L362.5,483L362.5,507.5L329.5,507.5L329.5,531L518.5,531L518.5,334.5L462.5,334.5L462.5,401.5L438,401.5L438,334.5L329.5,334.5ZM32.5,345L106,345L106,374L136,374L136,345L220.5,345L220.5,484L181,484L181,506.5L220.5,506.5L220.5,517.5L32.5,517.5L32.5,345ZM33.5,346L33.5,516.5L219.5,516.5L219.5,507.5L180,507.5L180,483L219.5,483L219.5,346L137,346L137,375L105,375L105,346L33.5,346Z"}] + [:path {:d "M283,1.5L549.5,1.5L549.5,272.5L461.5,272.5L461.5,314L538.5,314L538.5,550.5L309,550.5L309,506.5L251,506.5L251,547.5L1.5,547.5L1.5,314L106,314L106,278L7.5,278L7.5,7.5L233.5,7.5L233.5,78L283,78L283,1.5ZM32.5,345L32.5,517.5L220.5,517.5L220.5,506.5L181,506.5L181,484L220.5,484L220.5,345L136,345L136,374L106,374L106,345L32.5,345ZM30.5,30L30.5,255.5L106,255.5L106,221.5L136,221.5L136,255.5L159,255.5L159,278L136,278L136,314L251,314L251,484L309,484L309,314L439,314L439,272.5L380,272.5L380,302.5L349,302.5L349,272.5L283,272.5L283,206.5L218,206.5L218,278L188.5,278L188.5,176.5L283,176.5L283,100L233.5,100L233.5,144.5L210.5,144.5L210.5,100L173,100L173,78L210.5,78L210.5,30L30.5,30ZM305,206.5L305,249L349,249L349,206.5L305,206.5ZM305,24L305,78L353,78L353,100L305,100L305,176.5L380,176.5L380,249L439,249L439,202.5L461.5,202.5L461.5,249L527,249L527,24L305,24ZM328.5,333.5L328.5,484L361.5,484L361.5,506.5L328.5,506.5L328.5,532L519.5,532L519.5,333.5L461.5,333.5L461.5,400.5L439,400.5L439,333.5L328.5,333.5Z"}]]) + +(defn loading:spinner [& [class]] + (let [class (or class "h-4 w-4 text-blue-600 ml-2")] + (v/x + [:div.flex.items-center.justify-left + [:svg.animate-spin + {:xmlns "http://www.w3.org/2000/svg" + :fill "none" + :viewBox "0 0 24 24" + :class class} + [:circle.opacity-25 {:cx "12" :cy "12" :r "10" :stroke "currentColor" :stroke-width "4"}] + [:path.opacity-75 {:fill "currentColor" :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]]]))) + +(defn keydown-handler [bindings] + (let [handler (keydownHandler (reduce-kv (fn [out k f] + (j/!set out (name k) (fn [_ _ _] (f js/window.event)))) #js{} bindings))] + (fn [e] (handler #js{} e)))) + +(defn error-view [{:keys [error]}] + (when error + [:div.px-body.my-4 + [:div.text-destructive.border-2.border-destructive.rounded.shadow.p-4 + (str error)]])) + +(defn upload-icon [class] + [:svg {:class class :width "15" :height "15" :viewBox "0 0 15 15" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M7.81825 1.18188C7.64251 1.00615 7.35759 1.00615 7.18185 1.18188L4.18185 4.18188C4.00611 4.35762 4.00611 4.64254 4.18185 4.81828C4.35759 4.99401 4.64251 4.99401 4.81825 4.81828L7.05005 2.58648V9.49996C7.05005 9.74849 7.25152 9.94996 7.50005 9.94996C7.74858 9.94996 7.95005 9.74849 7.95005 9.49996V2.58648L10.1819 4.81828C10.3576 4.99401 10.6425 4.99401 10.8182 4.81828C10.994 4.64254 10.994 4.35762 10.8182 4.18188L7.81825 1.18188ZM2.5 9.99997C2.77614 9.99997 3 10.2238 3 10.5V12C3 12.5538 3.44565 13 3.99635 13H11.0012C11.5529 13 12 12.5528 12 12V10.5C12 10.2238 12.2239 9.99997 12.5 9.99997C12.7761 9.99997 13 10.2238 13 10.5V12C13 13.104 12.1062 14 11.0012 14H3.99635C2.89019 14 2 13.103 2 12V10.5C2 10.2238 2.22386 9.99997 2.5 9.99997Z" :fill "currentColor" :fill-rule "evenodd" :clip-rule "evenodd"}]]) + +(defn use-loaded-image [url fallback] + (let [!loaded (h/use-state #{})] + (h/use-effect (fn [] + (when url + (let [^js img (doto (js/document.createElement "img") + (j/assoc-in! [:style :display] "none") + (js/document.body.appendChild))] + (j/assoc! img + :onload #(do (swap! !loaded conj url) + (.remove img)) + :src url)))) [url]) + (if (contains? @!loaded url) + url + fallback))) + +(def email-schema [:re #"^[^@]+@[^@]+$"]) + +(comment + (defn malli-validator [schema] + (fn [v _] + (vd/humanized schema v)))) + +(defn merge-async + "Accepts a collection of {:loading?, :error, :value} maps, returns a single map: + - :loading? if any result is loading + - :error is first error found + - :value is a vector of values" + [results] + (if (map? results) + results + {:error (first (keep :error results)) + :loading? (boolean (seq (filter :loading? results))) + :value (mapv :value results)})) + +(ui/defview show-async-status + "Given a map of {:loading?, :error}, shows a loading bar and/or error message" + [result] + [:<> + (when (:loading? result) [loading-bar "bg-blue-100 h-1"]) + (when (:error result) [error-view result])]) + +(defn use-promise + "Returns a {:loading?, :error, :value} map for a promise (which should be memoized)" + [promise] + (let [!result (h/use-state {:loading? true}) + !unmounted? (h/use-ref false) + !latest-promise (h/use-ref promise)] + (h/use-effect (constantly #(reset! !unmounted? true))) + (h/use-effect (fn [] + (when-not @!unmounted? + (reset! !latest-promise promise) + (-> (p/let [value promise] + (when (identical? promise @!latest-promise) + (reset! !result {:value value}))) + (p/catch (fn [e] + (when (identical? promise @!latest-promise) + (reset! !result {:error (ex-message e)}))))))) + [promise]) + @!result)) + +(ui/defview show-match + "Given a match, shows the view, loading bar, and/or error message. + - adds :data to params when a :query is provided" + [{:as match :match/keys [endpoints params]}] + (if-let [view (-> endpoints :view :endpoint/sym (@routing/!views))] + (when view + [view (assoc params :account-id (db/get :env/config :account-id))]) + [pprinted match])) + +(defn use-debounced-value + "Caches value for `wait` milliseconds after last change." + [value wait] + (let [!state (h/use-state value) + !mounted (h/use-ref false) + !timeout (h/use-ref nil) + !cooldown (h/use-ref false) + cancel #(some-> @!timeout js/clearTimeout)] + (h/use-effect + (fn [] + (when @!mounted + (if @!cooldown + (do (cancel) + (reset! !timeout + (js/setTimeout + #(do (reset! !cooldown false) + (reset! !state value)) + wait))) + (do + (reset! !cooldown true) + (reset! !state value))))) + [value wait]) + (h/use-effect + (fn [] + (reset! !mounted true) + cancel)) + @!state)) + +(ui/defview redirect [to] + (h/use-effect #(routing/nav! to))) + +(defn initials [display-name] + (let [words (str/split display-name #"\s+")] + (str/upper-case + (str/join "" (map first + (if (> (count words) 2) + [(first words) (last words)] + words)))))) + +(defn avatar [{:as props :keys [size] :or {size 6}} + {:keys [account/display-name + entity/title + image/avatar]}] + (let [class (v/classes [(str "w-" size) + (str "h-" size) + "flex-none rounded-full"]) + props (dissoc props :size)] + (or + (when-let [src (asset.ui/asset-src avatar :avatar)] + [:div.bg-no-repeat.bg-center.bg-contain + (v/merge-props {:style {:background-image (asset.ui/css-url src)} + :class class} + props)]) + (when-let [txt (or display-name title)] + [:div.bg-gray-200.text-gray-600.inline-flex.items-center.justify-center + (v/merge-props {:class class} props) + (initials txt)])))) + +(defclass ErrorBoundary + (extends react/Component) + (constructor [this props] (super props)) + Object + (componentDidCatch [this error info] + (js/console.error error) + (js/console.log (j/get info :componentStack))) + (render [this] + (if-let [e (j/get-in this [:state :error])] + ((j/get-in this [:props :fallback]) e) + (j/get-in this [:props :children])))) + +(j/!set ErrorBoundary "getDerivedStateFromError" + (fn [error] + (js/console.error error) + #js {:error error})) + +(v/defview error-boundary [fallback child] + [:el ErrorBoundary {:fallback fallback} child]) + +(def startTransition react/startTransition) + +(ui/defview truncate-items [{:keys [limit expander unexpander] + :or {expander (fn [n] + [:div.flex-v.items-center.text-center.py-1.cursor-pointer.bg-gray-50.hover:bg-gray-100.rounded-lg.text-gray-600 + #_[icons/chevron-down "w-6 h-6"] + #_[icons/ellipsis-horizontal "w-8"] + (str "+ " n)]) + unexpander [:div.flex-v.items-center.text-center.py-1.bg-gray-50.hover:bg-gray-100.rounded-lg.text-gray-600 [icons/chevron-up "w-6 h-6"]]}} items] + (let [item-count (count items) + !expanded? (h/use-state false) + expandable? (> item-count limit)] + (cond (not expandable?) items + @!expanded? [:<> items [:div.contents {:on-click #(reset! !expanded? false)} unexpander]] + :else [:<> (take limit items) [:div.contents {:on-click #(reset! !expanded? true)} (expander (- item-count limit))]]))) + +(def hero (v/from-element :div.rounded-lg.bg-gray-100.p-6.width-full)) + +(defn use-autofocus-ref [] + (h/use-callback (fn [^js x] + (when x + (if (.matches x "input, textarea") + (.focus x) + (some-> (.querySelector x "input, textarea") .focus)))))) + +(def read-string (partial edn/read-string {:readers {'uuid uuid}})) + +(defn prevent-default [f] + (fn [^js e] + (.preventDefault e) + (f e))) + +(defn pprint [x] (clojure.pprint/pprint x)) + diff --git a/src/sparkboard/client/auth.cljs b/src/sparkboard/client/auth.cljs index 4f81391b..99ee4c4e 100644 --- a/src/sparkboard/client/auth.cljs +++ b/src/sparkboard/client/auth.cljs @@ -3,7 +3,7 @@ [applied-science.js-interop :as j] [sparkboard.slack.firebase :as firebase] [promesa.core :as p] - [sparkboard.ui :as ui] + [sparkboard.app.views.ui :as ui] [yawn.hooks :refer [use-deref]])) ;; TODO diff --git a/src/sparkboard/client/core.cljs b/src/sparkboard/client/core.cljs index 84cb07b5..998ecb60 100644 --- a/src/sparkboard/client/core.cljs +++ b/src/sparkboard/client/core.cljs @@ -7,14 +7,16 @@ [re-db.integrations.reagent] [sparkboard.app :as app] [sparkboard.app.domain.ui :as domain.ui] + [sparkboard.app.field-entry.ui :as entry.ui] + [sparkboard.app.form.ui :as form.ui] [sparkboard.client.scratch] [sparkboard.i18n :refer [tr]] [sparkboard.routing :as routing] [sparkboard.schema :as sch] [sparkboard.slack.firebase :as firebase] [sparkboard.transit :as transit] - [sparkboard.ui :as ui] - [sparkboard.ui.radix :as radix] + [sparkboard.app.views.ui :as ui] + [sparkboard.app.views.radix :as radix] [yawn.root :as root] [yawn.view :as v])) @@ -68,11 +70,11 @@ validator (update :validators conj validator)))) (forms/set-global-meta! - {:account/email {:el ui/text-field + {:account/email {:el entry.ui/text-field :props {:type "email" :placeholder (tr :tr/email)} - :validators [ui/email-validator]} - :account/password {:el ui/text-field + :validators [form.ui/email-validator]} + :account/password {:el entry.ui/text-field :props {:type "password" :placeholder (tr :tr/password)} :validators [(forms/min-length 8)]} diff --git a/src/sparkboard/client/slack.cljs b/src/sparkboard/client/slack.cljs index 4e182065..92d73341 100644 --- a/src/sparkboard/client/slack.cljs +++ b/src/sparkboard/client/slack.cljs @@ -7,7 +7,7 @@ [sparkboard.slack.firebase :as firebase] [yawn.hooks :as hooks :refer [use-deref]] [yawn.view :as v] - [sparkboard.ui :as ui])) + [sparkboard.app.views.ui :as ui])) (def db (delay (.database firebase/app))) diff --git a/src/sparkboard/color.cljc b/src/sparkboard/color.cljc new file mode 100644 index 00000000..6446fe21 --- /dev/null +++ b/src/sparkboard/color.cljc @@ -0,0 +1,25 @@ +(ns sparkboard.color) + +(def invalid-border-color "red") +(def invalid-text-color "red") +(def invalid-bg-color "light-pink") + +(defn contrasting-text-color [bg-color] + (if bg-color + (try (let [[r g b] (if (= \# (first bg-color)) + (let [bg-color (if (= 4 (count bg-color)) + (str bg-color (subs bg-color 1)) + bg-color)] + [(js/parseInt (subs bg-color 1 3) 16) + (js/parseInt (subs bg-color 3 5) 16) + (js/parseInt (subs bg-color 5 7) 16)]) + (re-seq #"\d+" bg-color)) + luminance (/ (+ (* r 0.299) + (* g 0.587) + (* b 0.114)) + 255)] + (if (> luminance 0.5) + "#000000" + "#ffffff")) + (catch js/Error e "#000000")) + "#000000")) \ No newline at end of file diff --git a/src/sparkboard/ui/icons.cljc b/src/sparkboard/icons.cljc similarity index 99% rename from src/sparkboard/ui/icons.cljc rename to src/sparkboard/icons.cljc index 5f920a5d..7d3adaac 100644 --- a/src/sparkboard/ui/icons.cljc +++ b/src/sparkboard/icons.cljc @@ -1,4 +1,4 @@ -(ns sparkboard.ui.icons +(ns sparkboard.icons (:require [yawn.view :as v])) (defn arrow-left [& [classes]] diff --git a/src/sparkboard/routing.cljc b/src/sparkboard/routing.cljc index ec572122..50a79cf7 100644 --- a/src/sparkboard/routing.cljc +++ b/src/sparkboard/routing.cljc @@ -165,8 +165,8 @@ (def path-by-name (comp :match/path match-by-tag)) (comment - (match-by-tag 'sparkboard.app.assets.data/upload! {:query-params {:a 1}}) - (path-by-name 'sparkboard.app.assets.data/upload! {:query-params {:a 1}}) + (match-by-tag 'sparkboard.app.asset.data/upload! {:query-params {:a 1}}) + (path-by-name 'sparkboard.app.asset.data/upload! {:query-params {:a 1}}) (aux:match-by-path "/upload?a=1") (aux:parse-path "/upload?a=1")) @@ -232,9 +232,9 @@ (defonce !history (atom nil)) (comment - (reit/match-by-name @!router 'sparkboard.app.assets.data/upload! {}) + (reit/match-by-name @!router 'sparkboard.app.asset.data/upload! {}) (reit/match-by-name @!router 'sparkboard.app.board.data/show {:board-id (random-uuid)}) - (match-by-tag 'sparkboard.app.assets-data/upload! {})) + (match-by-tag 'sparkboard.app.asset-data/upload! {})) (defn path-for "Given a route vector like `[:route/id {:param1 val1}]`, returns the path (string)" diff --git a/src/sparkboard/server/html.clj b/src/sparkboard/server/html.clj index 318924fd..b5b99b37 100644 --- a/src/sparkboard/server/html.clj +++ b/src/sparkboard/server/html.clj @@ -5,7 +5,7 @@ [mhuebert.cljs-static.assets :as assets] [ring.util.response :as ring.response] [sparkboard.transit :as transit] - [sparkboard.ui.layouts :as layouts] + [sparkboard.app.views.layouts :as layouts] [clojure.java.io :as io] [sparkboard.server.env :as env]) (:import (java.time Instant))) diff --git a/src/sparkboard/ui.cljs b/src/sparkboard/ui.cljs deleted file mode 100644 index 75bf575e..00000000 --- a/src/sparkboard/ui.cljs +++ /dev/null @@ -1,692 +0,0 @@ -(ns sparkboard.ui - (:require ["@radix-ui/react-dropdown-menu" :as dm] - ["prosemirror-keymap" :refer [keydownHandler]] - ["markdown-it" :as md] - ["linkify-element" :as linkify-element] - ["react" :as react] - [applied-science.js-interop :as j] - [cljs.reader :as edn] - [clojure.pprint] - [clojure.set :as set] - [clojure.string :as str] - [inside-out.forms :as forms] - [inside-out.macros] - [promesa.core :as p] - [re-db.api :as db] - [re-db.react] - [sparkboard.client.sanitize :as sanitize] - [sparkboard.i18n :as i] - [sparkboard.ui.radix :as radix] - [sparkboard.util :as u] - [yawn.hooks :as h] - [yawn.view :as v] - [sparkboard.routing :as routing] - [sparkboard.query-params :as query-params] - [sparkboard.i18n :refer [tr]] - [sparkboard.ui.icons :as icons] - [sparkboard.app.assets.data :as assets.data] - [shadow.cljs.modern :refer [defclass]]) - (:require-macros [sparkboard.ui :refer [defview with-submission]])) - -(defn dev? [] (= "dev" (db/get :env/config :env))) - -(defn loading-bar [& [class]] - [:div.relative - {:class class} - [:div.loading-bar]]) - -(defonce ^js Markdown (md)) - -(defview show-markdown - [source] - (let [!ref (h/use-ref)] - (h/use-effect (fn [] - (when-let [el @!ref] - (-> el - (j/!set :innerHTML (.render Markdown (or source ""))) - (linkify-element)))) - [@!ref source]) - (v/x [:div {:class "prose contents" - :ref !ref - :dangerouslySetInnerHTML #js{:__html ""}}]))) - -(def variants {:avatar {:op "bound" :width 200 :height 200} - :card {:op "bound" :width 600} - :page {:op "bound" :width 1200}}) - -(defn asset-src [asset variant] - (when-let [id (:entity/id asset)] - (str "/assets/" id - (some-> (variants variant) query-params/query-string)))) - -(defn filtered [match-text] - (comp - (remove :entity/archived?) - (filter (if match-text - #(re-find (re-pattern (str "(?i)" match-text)) (:entity/title %)) - identity)))) - -(defn pprinted [x & _] - [:pre.whitespace-pre-wrap (with-out-str (clojure.pprint/pprint x))]) - -(def safe-html sanitize/safe-html) - -(defn show-prose [{:as m :prose/keys [format string]}] - (when m - (case format - :prose.format/html [sanitize/safe-html string] - :prose.format/markdown [show-markdown string]))) - -(defn css-url [s] (str "url(" s ")")) - -(def logo-url "/images/logo-2023.png") - -(defn logo [classes] - [:svg {:class classes - :viewBox "0 0 551 552" - :version "1.1" - :xmlns "http://www.w3.org/2000/svg" - :xmlns-xlink "http://www.w3.org/1999/xlink" - :xml-space "preserve" - :fill "currentColor" - :style {:fill-rule "evenodd" - :clip-rule "evenodd" - :stroke-linejoin "round" - :stroke-miterlimit 2}} - [:path {:d "M282,0.5L550.5,0.5L550.5,273.5L462.5,273.5L462.5,313L539.5,313L539.5,551.5L308,551.5L308,507.5L252,507.5L252,548.5L0.5,548.5L0.5,313L105,313L105,279L6.5,279L6.5,6.5L234.5,6.5L234.5,77L282,77L282,0.5ZM283,1.5L283,78L233.5,78L233.5,7.5L7.5,7.5L7.5,278L106,278L106,314L1.5,314L1.5,547.5L251,547.5L251,506.5L309,506.5L309,550.5L538.5,550.5L538.5,314L461.5,314L461.5,272.5L549.5,272.5L549.5,1.5L283,1.5ZM305,24L527,24L527,249L461.5,249L461.5,202.5L439,202.5L439,249L380,249L380,176.5L305,176.5L305,100L353,100L353,78L305,78L305,24ZM306,25L306,77L354,77L354,101L306,101L306,175.5L381,175.5L381,248L438,248L438,201.5L462.5,201.5L462.5,248L526,248L526,25L306,25ZM30.5,30L210.5,30L210.5,78L173,78L173,100L210.5,100L210.5,144.5L233.5,144.5L233.5,100L283,100L283,176.5L188.5,176.5L188.5,278L218,278L218,206.5L283,206.5L283,272.5L349,272.5L349,302.5L380,302.5L380,272.5L439,272.5L439,314L309,314L309,484L251,484L251,314L136,314L136,278L159,278L159,255.5L136,255.5L136,221.5L106,221.5L106,255.5L30.5,255.5L30.5,30ZM31.5,31L31.5,254.5L105,254.5L105,220.5L137,220.5L137,254.5L160,254.5L160,279L137,279L137,313L252,313L252,483L308,483L308,313L438,313L438,273.5L381,273.5L381,303.5L348,303.5L348,273.5L282,273.5L282,207.5L219,207.5L219,279L187.5,279L187.5,175.5L282,175.5L282,101L234.5,101L234.5,145.5L209.5,145.5L209.5,101L172,101L172,77L209.5,77L209.5,31L31.5,31ZM305,206.5L349,206.5L349,249L305,249L305,206.5ZM306,207.5L306,248L348,248L348,207.5L306,207.5ZM328.5,333.5L439,333.5L439,400.5L461.5,400.5L461.5,333.5L519.5,333.5L519.5,532L328.5,532L328.5,506.5L361.5,506.5L361.5,484L328.5,484L328.5,333.5ZM329.5,334.5L329.5,483L362.5,483L362.5,507.5L329.5,507.5L329.5,531L518.5,531L518.5,334.5L462.5,334.5L462.5,401.5L438,401.5L438,334.5L329.5,334.5ZM32.5,345L106,345L106,374L136,374L136,345L220.5,345L220.5,484L181,484L181,506.5L220.5,506.5L220.5,517.5L32.5,517.5L32.5,345ZM33.5,346L33.5,516.5L219.5,516.5L219.5,507.5L180,507.5L180,483L219.5,483L219.5,346L137,346L137,375L105,375L105,346L33.5,346Z"}] - [:path {:d "M283,1.5L549.5,1.5L549.5,272.5L461.5,272.5L461.5,314L538.5,314L538.5,550.5L309,550.5L309,506.5L251,506.5L251,547.5L1.5,547.5L1.5,314L106,314L106,278L7.5,278L7.5,7.5L233.5,7.5L233.5,78L283,78L283,1.5ZM32.5,345L32.5,517.5L220.5,517.5L220.5,506.5L181,506.5L181,484L220.5,484L220.5,345L136,345L136,374L106,374L106,345L32.5,345ZM30.5,30L30.5,255.5L106,255.5L106,221.5L136,221.5L136,255.5L159,255.5L159,278L136,278L136,314L251,314L251,484L309,484L309,314L439,314L439,272.5L380,272.5L380,302.5L349,302.5L349,272.5L283,272.5L283,206.5L218,206.5L218,278L188.5,278L188.5,176.5L283,176.5L283,100L233.5,100L233.5,144.5L210.5,144.5L210.5,100L173,100L173,78L210.5,78L210.5,30L30.5,30ZM305,206.5L305,249L349,249L349,206.5L305,206.5ZM305,24L305,78L353,78L353,100L305,100L305,176.5L380,176.5L380,249L439,249L439,202.5L461.5,202.5L461.5,249L527,249L527,24L305,24ZM328.5,333.5L328.5,484L361.5,484L361.5,506.5L328.5,506.5L328.5,532L519.5,532L519.5,333.5L461.5,333.5L461.5,400.5L439,400.5L439,333.5L328.5,333.5Z"}]]) - -(def invalid-border-color "red") -(def invalid-text-color "red") -(def invalid-bg-color "light-pink") - -(defn loading:spinner [& [class]] - (let [class (or class "h-4 w-4 text-blue-600 ml-2")] - (v/x - [:div.flex.items-center.justify-left - [:svg.animate-spin - {:xmlns "http://www.w3.org/2000/svg" - :fill "none" - :viewBox "0 0 24 24" - :class class} - [:circle.opacity-25 {:cx "12" :cy "12" :r "10" :stroke "currentColor" :stroke-width "4"}] - [:path.opacity-75 {:fill "currentColor" :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]]]))) - -(defview view-message [{:keys [type content]}] - (case type - :in-progress (loading:spinner " h-4 w-4 text-blue-600 ml-2") - [:div - {:style (case type - (:error :invalid) {:color invalid-text-color - :background-color invalid-bg-color} - nil)} - content])) - -(def input-label (v/from-element :label.block.font-bold.text-base)) -(def input-wrapper (v/from-element :div.gap-2.flex-v.relative)) - -(defn field-id [?field] - (str "field-" (goog/getUid ?field))) - -(v/defview auto-size [props] - (let [v! (h/use-state "") - props (merge props {:value (:value props @v!) - :on-change (:on-change props - #(reset! v! (j/get-in % [:target :value])))})] - [:div.auto-size - [:div.bg-black (select-keys props [:class :style]) - (str (:value props) " ")] - [:textarea (assoc props :rows 1)]])) - -(defn pass-props [props] (dissoc props - :multi-line :postfix :wrapper-class - :persisted-value :on-save :on-change-value - :wrap :unwrap - :inline? - :can-edit? - :label)) - -(defn compseq [& fs] - (fn [& args] - (doseq [f fs] (apply f args)))) - -(defn maybe-save-field [?field props value] - (when-let [on-save (and (not= value (:persisted-value props)) - (:on-save props))] - (forms/try-submit+ ?field - (on-save value)))) - -(defn show-label [?field & [label]] - (when-let [label (u/some-or label (:label ?field))] - [input-label {:class "text-label" - :for (field-id ?field)} label])) - -(defn common-props [?field - get-value - {:as props - :keys [wrap - unwrap - on-save - on-change-value - on-change - persisted-value] - :or {wrap identity unwrap identity}}] - (cond-> {:id (field-id ?field) - :value (unwrap @?field) - :on-change (fn [e] - (let [new-value (wrap (get-value e))] - (reset! ?field new-value) - (when on-change-value - (pass-props (on-change-value new-value))) - (when on-change - (on-change e)))) - :on-blur (fn [e] - (maybe-save-field ?field props @?field) - ((forms/blur-handler ?field) e)) - :on-focus (forms/focus-handler ?field)} - persisted-value - (assoc :persisted-value (unwrap persisted-value)))) - -(defn color-field [?field props] - (let [get-value (j/get-in [:target :value])] - [:input.default-ring.default-ring-hover.rounded - (-> (v/merge-props - (pass-props props) - (common-props ?field get-value props) - {:on-blur (fn [e] - (reset! ?field (get-value e)) - (maybe-save-field ?field props (get-value e))) - :type "color"}) - (update :value #(or % "#ffffff")))])) - -(defview select-field [?field {:as props :keys [label options]}] - [input-wrapper - (show-label ?field label) - [radix/select-menu (-> (common-props ?field identity (assoc props - :on-change #(maybe-save-field ?field props @?field))) - (set/rename-keys {:on-change :on-value-change}) - (assoc :can-edit? (:can-edit? props) - :options (->> options - (map (fn [{:field-option/keys [label value color]}] - {:text label - :value value})) - doall)))] - (when (:loading? ?field) - [:div.loading-bar.absolute.bottom-0.left-0.right-0 {:class "h-[3px]"}])]) - -(defn show-field-messages [?field] - (when-let [messages (seq (forms/visible-messages ?field))] - (v/x (into [:div.gap-3.text-sm] (map view-message messages))))) - -(defn show-postfix [?field props] - (when-let [postfix (or (:postfix props) - (:postfix (meta ?field)) - (and (some-> (:persisted-value props) - (not= (:value props))) - [icons/pencil-outline "w-4 h-4 text-txt/40"]))] - [:div.pointer-events-none.absolute.inset-y-0.right-0.top-0.bottom-0.flex.items-center.p-2 postfix])) - -(defn keydown-handler [bindings] - (let [handler (keydownHandler (reduce-kv (fn [out k f] - (j/!set out (name k) (fn [_ _ _] (f js/window.event)))) #js{} bindings))] - (fn [e] (handler #js{} e)))) - -(defn text-field - "A text-input element that reads metadata from a ?field to display appropriately" - [?field & [props]] - (let [{:as props - :keys [inline? - multi-line - multi-paragraph - wrap - unwrap - wrapper-class - on-save - persisted-value] - :or {wrap identity - unwrap identity}} (merge props (:props (meta ?field))) - blur! (fn [e] (j/call-in e [:target :blur])) - cancel! (fn [e] - (reset! ?field persisted-value) - (blur! e)) - props (v/merge-props props - (common-props ?field - (j/get-in [:target :value]) - (merge {:wrap #(when-not (str/blank? %) %) - :unwrap #(or % "")} props)) - - {:class ["pr-8 rounded" - (if inline? - "form-inline" - "default-ring") - (when (:invalid (forms/types (forms/visible-messages ?field))) - "outline-invalid")] - :placeholder (or (:placeholder props) - (when inline? (or (:label props) (:label ?field)))) - :on-key-down - (keydown-handler {(if multi-paragraph - :Meta-Enter - :Enter) #(when on-save - (j/call % :preventDefault) - (maybe-save-field ?field props @?field)) - :Escape blur! - :Meta-. cancel!})})] - (v/x - [input-wrapper - {:class wrapper-class} - (when-not inline? (show-label ?field (:label props))) - [:div.flex-v.relative - (if multi-line - [auto-size (v/merge-props {:class "form-text w-full"} (pass-props props))] - [:input.form-text (pass-props props)]) - (show-postfix ?field props) - (when (:loading? ?field) - [:div.loading-bar.absolute.bottom-0.left-0.right-0 {:class "h-[3px]"}])] - (show-field-messages ?field)]))) - -(defn wrap-prose [value] - (when-not (str/blank? value) - {:prose/format :prose.format/markdown - :prose/string value})) - -(def unwrap-prose :prose/string) - -(defview prose-field [?field props] - ;; TODO - ;; multi-line markdown editor with formatting - (text-field ?field (merge {:wrap wrap-prose - :unwrap unwrap-prose - :multi-line true} - props))) -(defn parse-video-url [url] - (try - (or (when-let [id (some #(second (re-find % url)) [#"youtube\.com/watch\?v=([^&?\s]+)" - #"youtube\.com/embed/([^&?\s]+)" - #"youtube\.com/v/([^&?\s]+)" - #"youtu\.be/([^&?\s]+)"])] - {:type :youtube - :youtube/id id - :video/url url - :video/thumbnail (str "https://img.youtube.com/vi/" id "/hqdefault.jpg")}) - (when-let [id (some #(second (re-find % url)) [#"vimeo\.com/(?:video/)?(\d+)" - #"vimeo\.com/channels/.*/(\d+)"])] - {:type :vimeo - :vimeo/id id - :video/url url - ;; 3rd party service; may be unreliable - ;; https://stackoverflow.com/questions/1361149/get-img-thumbnails-from-vimeo - ;; https://github.com/ThatGuySam/vumbnail - :video/thumbnail (str "https://vumbnail.com/" id ".jpg")})) - (catch js/Error e nil))) - -(defview video-field - {:key goog/getUid} - [?field {:as props :keys [can-edit?]}] - (let [!editing? (h/use-state (nil? @?field))] - [input-wrapper - ;; preview shows persisted value? - [:div.flex.items-center - [:div.flex-auto (show-label ?field (:label props))] - (when can-edit? - [:div.place-self-end [:a {:on-click #(swap! !editing? not)} - [(if @!editing? icons/dash icons/chevron-down) "icon-gray"]]])] - - (when (and can-edit? @!editing?) - (text-field ?field (merge props - {:label nil - :placeholder "YouTube or Vimeo url" - :wrap (partial hash-map :video/url) - :unwrap :video/url}))) - (when-let [url (:video/url @?field)] - [:a.bg-black.w-full.aspect-video.flex.items-center.justify-center.group.relative - {:href url - :target "_blank" - :style {:background-image (css-url (:video/thumbnail (parse-video-url url))) - :background-size "cover" - :background-position "center"}} - [icons/external-link "absolute text-white top-2 right-2 icon-sm drop-shadow"] - [icons/play-circle "icon-xl text-white drop-shadow-2xl transition-all group-hover:scale-110 "]])])) - -(defview checkbox-field - "A text-input element that reads metadata from a ?field to display appropriately" - [?field props] - ;; TODO handle on-save - (let [messages (forms/visible-messages ?field) - loading? (:loading? ?field) - props (-> (v/merge-props (common-props ?field - (j/get-in [:target :checked]) - props) - {:type "checkbox" - :on-blur (forms/blur-handler ?field) - :on-focus (forms/focus-handler ?field) - :on-change #(let [value (.. ^js % -target -checked)] - (maybe-save-field ?field props value)) - :class [(when loading? "invisible") - (if (:invalid (forms/types messages)) - "outline-invalid" - "outline-default")]}) - (set/rename-keys {:value :checked}) - (update :checked boolean)) - ] - [:div.flex.flex-col.gap-1.relative - [:label.relative.flex.items-center - (when loading? - [:div.h-5.w-5.inline-flex.items-center.justify-center.absolute - [loading:spinner "h-3 w-3"]]) - [:input.h-5.w-5.rounded.border-gray-300.text-primary - (pass-props props)] - [:div.flex-v.gap-1.ml-2 - (when-let [label (:label ?field)] - [:div.flex.items-center.h-5 label]) - (when (seq messages) - (into [:div.text-gray-500] (map view-message) messages))]]])) - -(defn field-props [?field & [props]] - (v/props (:props (meta ?field)) props)) - -(defn filter-field [?field & [attrs]] - (let [loading? (or (:loading? ?field) (:loading? attrs))] - [:div.flex.relative.items-stretch.flex-auto - [:input.pr-9.border.border-gray-300.w-full.rounded-lg.p-3 - (v/props (common-props ?field (j/get-in [:target :value]) {:unwrap #(or % "")}) - {:class ["outline-none focus-visible:outline-4 outline-offset-0 focus-visible:outline-gray-200"] - :placeholder "Search..." - :on-key-down #(when (= "Escape" (.-key ^js %)) - (reset! ?field nil))})] - [:div.absolute.top-0.right-0.bottom-0.flex.items-center.pr-3 - {:class "text-txt/40"} - (cond loading? (icons/loading "w-4 h-4 rotate-3s") - (seq @?field) [:div.contents.cursor-pointer - {:on-click #(reset! ?field nil)} - (icons/close "w-5 h-5")] - :else (icons/search "w-6 h-6"))]])) - -(defn error-view [{:keys [error]}] - (when error - [:div.px-body.my-4 - [:div.text-destructive.border-2.border-destructive.rounded.shadow.p-4 - (str error)]])) - -(defn upload-icon [class] - [:svg {:class class :width "15" :height "15" :viewBox "0 0 15 15" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M7.81825 1.18188C7.64251 1.00615 7.35759 1.00615 7.18185 1.18188L4.18185 4.18188C4.00611 4.35762 4.00611 4.64254 4.18185 4.81828C4.35759 4.99401 4.64251 4.99401 4.81825 4.81828L7.05005 2.58648V9.49996C7.05005 9.74849 7.25152 9.94996 7.50005 9.94996C7.74858 9.94996 7.95005 9.74849 7.95005 9.49996V2.58648L10.1819 4.81828C10.3576 4.99401 10.6425 4.99401 10.8182 4.81828C10.994 4.64254 10.994 4.35762 10.8182 4.18188L7.81825 1.18188ZM2.5 9.99997C2.77614 9.99997 3 10.2238 3 10.5V12C3 12.5538 3.44565 13 3.99635 13H11.0012C11.5529 13 12 12.5528 12 12V10.5C12 10.2238 12.2239 9.99997 12.5 9.99997C12.7761 9.99997 13 10.2238 13 10.5V12C13 13.104 12.1062 14 11.0012 14H3.99635C2.89019 14 2 13.103 2 12V10.5C2 10.2238 2.22386 9.99997 2.5 9.99997Z" :fill "currentColor" :fill-rule "evenodd" :clip-rule "evenodd"}]]) - -(defn use-loaded-image [url fallback] - (let [!loaded (h/use-state #{})] - (h/use-effect (fn [] - (when url - (let [^js img (doto (js/document.createElement "img") - (j/assoc-in! [:style :display] "none") - (js/document.body.appendChild))] - (j/assoc! img - :onload #(do (swap! !loaded conj url) - (.remove img)) - :src url)))) [url]) - (if (contains? @!loaded url) - url - fallback))) -(routing/path-for `assets.data/upload!) - -(defview image-field [?field props] - (let [src (asset-src @?field :card) - loading? (:loading? ?field) - !selected-blob (h/use-state nil) - !dragging? (h/use-state false) - thumbnail (use-loaded-image src @!selected-blob) - on-file (fn [file] - (forms/touch! ?field) - (reset! !selected-blob (js/URL.createObjectURL file)) - (with-submission [asset (routing/POST `assets.data/upload! (doto (js/FormData.) - (.append "files" file))) - :form ?field] - (reset! ?field asset) - (maybe-save-field ?field props asset))) - !input (h/use-ref)] - ;; TODO handle on-save - [:label.gap-2.flex-v.relative - {:for (field-id ?field)} - (show-label ?field (:label props)) - [:button.flex-v.items-center.justify-center.p-3.gap-3.relative.default-ring.default-ring-hover - {:on-click #(j/call @!input :click) - :class ["rounded-lg" - (if @!dragging? - "outline-2 outline-focus-accent")] - :on-drag-over (fn [^js e] - (.preventDefault e) - (reset! !dragging? true)) - :on-drag-leave (fn [^js e] - (reset! !dragging? false)) - :on-drop (fn [^js e] - (.preventDefault e) - (some-> (j/get-in e [:dataTransfer :files 0]) on-file))} - (when loading? - [icons/loading "w-4 h-4 absolute top-0 right-0 text-txt/40 mx-2 my-3"]) - [:div.block.relative.rounded.cursor-pointer.flex.items-center.justify-center.rounded-lg - (v/props {:class "text-muted-txt hover:text-txt w-32 h-32"} - (when thumbnail - {:class "bg-contain bg-no-repeat bg-center" - :style {:background-image (css-url thumbnail)}})) - - (when-not thumbnail - (upload-icon "w-5 h-5 m-auto")) - - [:input.hidden - {:id (field-id ?field) - :ref !input - :type "file" - :accept "image/webp, image/jpeg, image/gif, image/png, image/svg+xml" - :on-change #(some-> (j/get-in % [:target :files 0]) on-file)}]] - (show-field-messages ?field)]])) - -(defview images-field [?field {:as props :keys [label]}] - (let [images (->> (:images/order @?field) - (map (fn [id] - {:url (asset-src (db/entity [:entity/id id]) :card) - :entity/id id})))] - (for [{:keys [entity/id url]} images] - ;; TODO - ;; upload image, - ;; re-order images - [:div.relative {:key url} - [:div.inset-0.bg-black.absolute.opacity-10] - [:img {:src url}]]))) - -(def email-schema [:re #"^[^@]+@[^@]+$"]) - -(comment - (defn malli-validator [schema] - (fn [v _] - (vd/humanized schema v)))) - -(defn merge-async - "Accepts a collection of {:loading?, :error, :value} maps, returns a single map: - - :loading? if any result is loading - - :error is first error found - - :value is a vector of values" - [results] - (if (map? results) - results - {:error (first (keep :error results)) - :loading? (boolean (seq (filter :loading? results))) - :value (mapv :value results)})) - -(defview show-async-status - "Given a map of {:loading?, :error}, shows a loading bar and/or error message" - [result] - [:<> - (when (:loading? result) [loading-bar "bg-blue-100 h-1"]) - (when (:error result) [error-view result])]) - -(defn use-promise - "Returns a {:loading?, :error, :value} map for a promise (which should be memoized)" - [promise] - (let [!result (h/use-state {:loading? true}) - !unmounted? (h/use-ref false) - !latest-promise (h/use-ref promise)] - (h/use-effect (constantly #(reset! !unmounted? true))) - (h/use-effect (fn [] - (when-not @!unmounted? - (reset! !latest-promise promise) - (-> (p/let [value promise] - (when (identical? promise @!latest-promise) - (reset! !result {:value value}))) - (p/catch (fn [e] - (when (identical? promise @!latest-promise) - (reset! !result {:error (ex-message e)}))))))) - [promise]) - @!result)) - -(defview show-match - "Given a match, shows the view, loading bar, and/or error message. - - adds :data to params when a :query is provided" - [{:as match :match/keys [endpoints params]}] - (if-let [view (-> endpoints :view :endpoint/sym (@routing/!views))] - (when view - [view (assoc params :account-id (db/get :env/config :account-id))]) - [pprinted match])) - -(defn use-debounced-value - "Caches value for `wait` milliseconds after last change." - [value wait] - (let [!state (h/use-state value) - !mounted (h/use-ref false) - !timeout (h/use-ref nil) - !cooldown (h/use-ref false) - cancel #(some-> @!timeout js/clearTimeout)] - (h/use-effect - (fn [] - (when @!mounted - (if @!cooldown - (do (cancel) - (reset! !timeout - (js/setTimeout - #(do (reset! !cooldown false) - (reset! !state value)) - wait))) - (do - (reset! !cooldown true) - (reset! !state value))))) - [value wait]) - (h/use-effect - (fn [] - (reset! !mounted true) - cancel)) - @!state)) - -(def email-validator (fn [v _] - (when v - (when-not (re-find #"^[^@]+@[^@]+$" v) - (tr :tr/invalid-email))))) - -(def form-classes "flex flex-col gap-4 p-6 max-w-lg mx-auto bg-back relative text-sm") -(def btn-primary :button.btn.btn-primary.px-6.py-3.self-start) - -(v/defview submit-form [!form label] - [:<> - (show-field-messages !form) - [btn-primary {:type "submit" - :disabled (not (forms/submittable? !form))} - label]]) - -(defview redirect [to] - (h/use-effect #(routing/nav! to))) - -(defn initials [display-name] - (let [words (str/split display-name #"\s+")] - (str/upper-case - (str/join "" (map first - (if (> (count words) 2) - [(first words) (last words)] - words)))))) - -(defn avatar [{:as props :keys [size] :or {size 6}} - {:keys [account/display-name - entity/title - image/avatar]}] - (let [class (v/classes [(str "w-" size) - (str "h-" size) - "flex-none rounded-full"]) - props (dissoc props :size)] - (or - (when-let [src (asset-src avatar :avatar)] - [:div.bg-no-repeat.bg-center.bg-contain - (v/merge-props {:style {:background-image (css-url src)} - :class class} - props)]) - (when-let [txt (or display-name title)] - [:div.bg-gray-200.text-gray-600.inline-flex.items-center.justify-center - (v/merge-props {:class class} props) - (initials txt)])))) - -(defclass ErrorBoundary - (extends react/Component) - (constructor [this props] (super props)) - Object - (componentDidCatch [this error info] - (js/console.error error) - (js/console.log (j/get info :componentStack))) - (render [this] - (if-let [e (j/get-in this [:state :error])] - ((j/get-in this [:props :fallback]) e) - (j/get-in this [:props :children])))) - -(j/!set ErrorBoundary "getDerivedStateFromError" - (fn [error] - (js/console.error error) - #js {:error error})) - -(v/defview error-boundary [fallback child] - [:el ErrorBoundary {:fallback fallback} child]) - -(def startTransition react/startTransition) - -(defview truncate-items [{:keys [limit expander unexpander] - :or {expander (fn [n] - [:div.flex-v.items-center.text-center.py-1.cursor-pointer.bg-gray-50.hover:bg-gray-100.rounded-lg.text-gray-600 - #_[icons/chevron-down "w-6 h-6"] - #_[icons/ellipsis-horizontal "w-8"] - (str "+ " n)]) - unexpander [:div.flex-v.items-center.text-center.py-1.bg-gray-50.hover:bg-gray-100.rounded-lg.text-gray-600 [icons/chevron-up "w-6 h-6"]]}} items] - (let [item-count (count items) - !expanded? (h/use-state false) - expandable? (> item-count limit)] - (cond (not expandable?) items - @!expanded? [:<> items [:div.contents {:on-click #(reset! !expanded? false)} unexpander]] - :else [:<> (take limit items) [:div.contents {:on-click #(reset! !expanded? true)} (expander (- item-count limit))]]))) - -(def hero (v/from-element :div.rounded-lg.bg-gray-100.p-6.width-full)) - -(defn use-autofocus-ref [] - (h/use-callback (fn [^js x] - (when x - (if (.matches x "input, textarea") - (.focus x) - (some-> (.querySelector x "input, textarea") .focus)))))) - -(def read-string (partial edn/read-string {:readers {'uuid uuid}})) - -(defn prevent-default [f] - (fn [^js e] - (.preventDefault e) - (f e))) - -(defn pprint [x] (clojure.pprint/pprint x)) - -(defn contrasting-text-color [bg-color] - (if bg-color - (try (let [[r g b] (if (= \# (first bg-color)) - (let [bg-color (if (= 4 (count bg-color)) - (str bg-color (subs bg-color 1)) - bg-color)] - [(js/parseInt (subs bg-color 1 3) 16) - (js/parseInt (subs bg-color 3 5) 16) - (js/parseInt (subs bg-color 5 7) 16)]) - (re-seq #"\d+" bg-color)) - luminance (/ (+ (* r 0.299) - (* g 0.587) - (* b 0.114)) - 255)] - (if (> luminance 0.5) - "#000000" - "#ffffff")) - (catch js/Error e "#000000")) - "#000000")) \ No newline at end of file