diff --git a/package.json b/package.json index 0ecc8414..4b0e71ea 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@lezer/markdown": "^1.0.0", "@nextjournal/lang-clojure": "1.0.0", "@nextjournal/lezer-clojure": "1.0.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/src/sb/app.cljc b/src/sb/app.cljc index 889cc798..7d64d08f 100644 --- a/src/sb/app.cljc +++ b/src/sb/app.cljc @@ -2,14 +2,16 @@ (:require [sb.app.account.ui] [sb.app.asset.ui] [sb.app.board.ui] + [sb.app.board.admin-ui] [sb.app.chat.ui] [sb.app.collection.ui] [sb.app.content.ui] [sb.app.discussion.ui] - [sb.app.domain.ui] + [sb.app.domain-name.ui :as domain.ui] [sb.app.entity.ui] - [sb.app.field.admin-ui] - [sb.app.field.ui] + [sb.app.field.admin-ui :as field.admin-ui] + [sb.app.field.ui :as field.ui] + [sb.app.form.ui :as form.ui] [sb.app.member.ui] [sb.app.notification.ui] [sb.app.org.ui] @@ -17,7 +19,27 @@ [sb.app.social-feed.ui] [sb.app.vote.ui] [org.sparkboard.slack.schema] - [sb.transit :as t])) + [sb.transit :as t] + [inside-out.forms :as io] + [sb.i18n :refer [tr]])) #?(:cljs - (def client-endpoints (t/read (shadow.resource/inline "public/js/sparkboard.views.transit.json")))) \ No newline at end of file + (def client-endpoints (t/read (shadow.resource/inline "public/js/sparkboard.views.transit.json")))) + +(def global-field-meta + {:account/email {:view field.ui/text-field + :props {:type "email" + :placeholder (tr :tr/email)} + :validators [form.ui/email-validator]} + :account/password {:view field.ui/text-field + :props {:type "password" + :placeholder (tr :tr/password)} + :validators [(io/min-length 8)]} + :entity/title {:validators [(io/min-length 3)]} + :board/project-fields {:view field.admin-ui/fields-editor} + :board/member-fields {:view field.admin-ui/fields-editor} + :field/label {:view field.ui/text-field} + :field/hint {:view field.ui/text-field} + :entity/domain-name {:view domain.ui/domain-field + :validators (domain.ui/validators)} + :image/avatar {:view field.ui/image-field}}) \ No newline at end of file diff --git a/src/sb/app/board/admin_ui.cljc b/src/sb/app/board/admin_ui.cljc new file mode 100644 index 00000000..15421206 --- /dev/null +++ b/src/sb/app/board/admin_ui.cljc @@ -0,0 +1,71 @@ +(ns sb.app.board.admin-ui + (:require [sb.app.views.ui :as ui] + [sb.app.board.data :as data] + [sb.app.views.header :as header] + [sb.app.form.ui :as form.ui] + [sb.app.entity.ui :as entity.ui :refer [use-persisted]] + [sb.app.field.ui :as field.ui] + [sb.app.domain-name.ui :as domain.ui] + [sb.app.field.admin-ui :as field.admin-ui] + [sb.app.views.radix :as radix] + [sb.i18n :refer [tr]])) + +(ui/defview settings + {:route "/b/:board-id/settings"} + [{:as params :keys [board-id]}] + (let [board (data/settings params)] + [:<> + (header/entity board) + [radix/accordion {:class "max-w-[600px] mx-auto my-6 flex-v gap-6" + :multiple true} + + [:div.field-label (tr :tr/basic-settings)] + [:div.flex-v.gap-4 + (use-persisted board :entity/title) + (use-persisted board :entity/description) + (use-persisted board :entity/domain-name) + (use-persisted board :image/avatar {:label (tr :tr/image.logo)})] + + + [:div.field-label (tr :tr/projects-and-members)] + [:div.flex-v.gap-4 + (field.admin-ui/fields-editor board :board/member-fields) + (field.admin-ui/fields-editor board :board/project-fields)] + + + [:div.field-label (tr :tr/registration)] + [:div.flex-v.gap-4 + (use-persisted board :board/registration-open?) + (use-persisted board :board/registration-url-override) + (use-persisted board :board/registration-page-message) + (use-persisted board :board/invite-email-text)]] + + + + ;; TODO + ;; - :board/project-sharing-buttons + ;; - :board/member-tags + + ;; Registration + ;; - :board/registration-invitation-email-text + ;; - :board/registration-newsletter-field? + ;; - :board/registration-open? + ;; - :board/registration-message + ;; - :board/registration-url-override + ;; - :board/registration-codes + + ;; Theming + ;; - border radius + ;; - headline font + ;; - accent color + + ;; Sponsors + ;; - logo area with tiered sizes/visibility + + ;; Sticky Notes + ;; - schema: a new entity type (not a special kind of project) + ;; - modify migration based on ^new schema + ;; - color is picked per sticky note + ;; - sticky notes can include images/videos + + ])) \ No newline at end of file diff --git a/src/sb/app/board/data.cljc b/src/sb/app/board/data.cljc index 31ee4412..f6f21f62 100644 --- a/src/sb/app/board/data.cljc +++ b/src/sb/app/board/data.cljc @@ -10,97 +10,91 @@ [sb.validate :as validate])) (sch/register! - {:board/show-project-numbers? {s- :boolean - :doc "Show 'project numbers' for this board"} - :board/max-members-per-project {:doc "Set a maximum number of members a project may have" - s- :int} - :board/project-sharing-buttons {:doc "Which social sharing buttons to display on project detail pages", - s- [:map-of :social/sharing-button :boolean]} - :board/is-template? {:doc "Board is only used as a template for creating other boards", - s- :boolean}, - :board/labels {:unsure "How can this be handled w.r.t. locale?" - s- [:map-of [:enum - :label/member.one - :label/member.many - :label/project.one - :label/project.many] :string]}, - :board/instructions {:doc "Secondary instructions for a board, displayed above projects" - s- :prose/as-map}, - :board/max-projects-per-member {:doc "Set a maximum number of projects a member may join" - s- :int} - :board/sticky-color {:doc "Border color for sticky projects" - s- :html/color} - :board/member-tags (sch/ref :many :tag/as-map) - :board/project-fields (merge (sch/ref :many :field/as-map) - sch/component) - :board/member-fields (merge (sch/ref :many :field/as-map) - sch/component) - :board/registration-invitation-email-text {:doc "Body of email sent when inviting a user to a board." - s- :string}, - :board/registration-newsletter-field? {:doc "During registration, request permission to send the user an email newsletter" - s- :boolean}, - :board/registration-open? {:doc "Allows new registrations via the registration page. Does not affect invitations.", - s- :boolean}, - :board/registration-message {:doc "Content displayed on registration screen (before user chooses provider / enters email)" - s- :prose/as-map}, - :board/registration-url-override {:doc "URL to redirect user for registration (replaces the Sparkboard registration page, admins are expected to invite users)", - s- :http/url}, - :board/registration-codes {s- [:map-of :string [:map {:closed true} [:registration-code/active? :boolean]]]} - :board/new-projects-require-approval? {s- :boolean} - :board/custom-css {:doc "Custom CSS for this board" - s- :string} - :board/custom-js {:doc "Custom JS for this board" - s- :string} - :board/as-map {s- [:map {:closed true} - :entity/id - :entity/title - :entity/created-at - :entity/public? - :entity/kind - :entity/parent - - :board/registration-open? - - (? :image/avatar) - (? :image/logo-large) - (? :image/footer) - (? :image/background) - (? :image/sub-header) - - (? :entity/website) - (? :entity/meta-description) - (? :entity/description) - (? :entity/domain) - (? :entity/locale-default) - (? :entity/locale-dicts) - (? :entity/locale-suggestions) - (? :entity/social-feed) - (? :entity/deleted-at) - (? :entity/created-by) - - (? :board/custom-css) - (? :board/custom-js) - (? :board/instructions) - (? :board/is-template?) - (? :board/labels) - (? :board/max-members-per-project) - (? :board/max-projects-per-member) - (? :board/member-fields) - (? :board/member-tags) - (? :board/new-projects-require-approval?) - (? :board/project-fields) - (? :board/project-sharing-buttons) - (? :board/registration-codes) - (? :board/registration-invitation-email-text) - (? :board/registration-message) - (? :board/registration-newsletter-field?) - (? :board/registration-url-override) - (? :board/show-project-numbers?) - (? :board/slack.team) - (? :board/sticky-color) - - (? :member-vote/open?) - (? :webhook/subscriptions)]}}) + {:board/project-numbers? {s- :boolean + :hint "Assign numbers to this board's projects."} + :board/max-members-per-project {s- :int} + :board/project-sharing-buttons {:hint "Social sharing buttons to be displayed on project detail pages" + s- [:map-of :social/sharing-button :boolean]} + :board/is-template? {:doc "Board is only used as a template for creating other boards" + s- :boolean}, + :board/labels {:unsure "How can this be handled w.r.t. locale?" + s- [:map-of [:enum + :label/member.one + :label/member.many + :label/project.one + :label/project.many] :string]}, + :board/home-page-message {:hint "Additional instructions for a board, displayed when a member has signed in." + s- :prose/as-map}, + :board/max-projects-per-member {s- :int} + :board/sticky-color {:doc "Deprecate - sticky notes can pick their own colors" + s- :html/color} + :board/member-tags {s- [:sequential :tag/as-map]} + :board/project-fields {s- [:sequential :field/as-map]} + :board/member-fields {s- [:sequential :field/as-map]} + :board/invite-email-text {:hint "Text of email sent when inviting a user to a board." + s- :string}, + :board/registration-newsletter-field? {:hint "During registration, request permission to send the user an email newsletter" + s- :boolean}, + :board/registration-open? {:hint "Allows new registrations via the registration page. Does not affect invitations." + s- :boolean}, + :board/registration-page-message {:hint "Content displayed on registration screen (before user chooses provider / enters email)" + s- :prose/as-map}, + :board/registration-url-override {:hint "URL to redirect user for registration (replaces the Sparkboard registration page, admins are expected to invite users)", + s- :http/url}, + :board/registration-codes {s- [:map-of :string [:map {:closed true} [:registration-code/active? :boolean]]]} + :board/new-projects-require-approval? {s- :boolean} + :board/custom-css {s- :string} + :board/custom-js {s- :string} + :board/as-map {s- [:map {:closed true} + :entity/id + :entity/title + :entity/created-at + :entity/public? + :entity/kind + :entity/parent + + :board/registration-open? + + (? :image/avatar) + (? :image/logo-large) + (? :image/footer) + (? :image/background) + (? :image/sub-header) + + (? :entity/website) + (? :entity/meta-description) + (? :entity/description) + (? :entity/domain-name) + (? :entity/locale-default) + (? :entity/locale-dicts) + (? :entity/locale-suggestions) + (? :entity/social-feed) + (? :entity/deleted-at) + (? :entity/created-by) + + (? :board/custom-css) + (? :board/custom-js) + (? :board/home-page-message) + (? :board/is-template?) + (? :board/labels) + (? :board/max-members-per-project) + (? :board/max-projects-per-member) + (? :board/member-fields) + (? :board/member-tags) + (? :board/new-projects-require-approval?) + (? :board/project-fields) + (? :board/project-sharing-buttons) + (? :board/registration-codes) + (? :board/invite-email-text) + (? :board/registration-page-message) + (? :board/registration-newsletter-field?) + (? :board/registration-url-override) + (? :board/project-numbers?) + (? :board/slack.team) + (? :board/sticky-color) + + (? :member-vote/open?) + (? :webhook/subscriptions)]}}) (q/defx board:register! diff --git a/src/sb/app/board/ui.cljc b/src/sb/app/board/ui.cljc index 36ecc02a..a9249ef7 100644 --- a/src/sb/app/board/ui.cljc +++ b/src/sb/app/board/ui.cljc @@ -5,18 +5,16 @@ [sb.app.account.data :as account.data] [sb.app.asset.ui :as asset.ui] [sb.app.board.data :as data] - [sb.app.domain.ui :as domain.ui] + [sb.app.domain-name.ui :as domain.ui] [sb.app.entity.ui :as entity.ui] [sb.app.field.ui :as field.ui] - [sb.app.field.admin-ui :as field-admin.ui] + [sb.app.form.ui :as form.ui] [sb.app.form.ui :as form.ui] [sb.app.project.data :as project.data] [sb.app.project.ui :as project.ui] - [sb.app.form.ui :as form.ui] [sb.app.views.header :as header] [sb.app.views.radix :as radix] - [sb.app.views.ui :as ui] - [sb.i18n :refer [tr]] + [sb.app.views.ui :as ui :refer [tr]] [sb.routing :as routing] [sb.util :as u] [yawn.hooks :as h] @@ -31,13 +29,13 @@ seq (cons (account.data/account-as-entity account)))] (forms/with-form [!board (u/prune - {:entity/title ?title - :entity/domain ?domain - :entity/parent [:entity/id (uuid (?owner - :init - (or (-> params :query-params :org) - (str (-> (db/get :env/config :account) - :entity/id)))))]}) + {:entity/title ?title + :entity/domain-name ?domain + :entity/parent [:entity/id (uuid (?owner + :init + (or (-> params :query-params :org) + (str (-> (db/get :env/config :account) + :entity/id)))))]}) :required [?title ?domain]] [:form {:class form.ui/form-classes @@ -62,7 +60,7 @@ :icon [:img.w-5.h-5.rounded-sm {:src (asset.ui/asset-src avatar :avatar)}]})))})]) [field.ui/text-field ?title {:label (tr :tr/title)}] - (domain.ui/domain-field ?domain) + (domain.ui/domain-field ?domain nil) [form.ui/submit-form !board (tr :tr/create)]]))) (ui/defview register @@ -146,49 +144,6 @@ (data/members {:board-id board-id})) ]]]])) -(ui/defview settings - {:route "/b/:board-id/settings"} - [{:as params :keys [board-id]}] - (let [board (data/settings params)] - [:<> - (header/entity board) - [:div {:class form.ui/form-classes} - (entity.ui/use-persisted board :entity/title field.ui/text-field {:class "text-lg"}) - (entity.ui/use-persisted board :entity/description field.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 field.ui/image-field {:label (tr :tr/image.logo)}) - - (field-admin.ui/fields-editor board :board/member-fields) - (field-admin.ui/fields-editor board :board/project-fields) - - ;; TODO - ;; - :board/project-sharing-buttons - ;; - :board/member-tags - - ;; Registration - ;; - :board/registration-invitation-email-text - ;; - :board/registration-newsletter-field? - ;; - :board/registration-open? - ;; - :board/registration-message - ;; - :board/registration-url-override - ;; - :board/registration-codes - - ;; Theming - ;; - border radius - ;; - headline font - ;; - accent color - - ;; Sponsors - ;; - logo area with tiered sizes/visibility - - ;; Sticky Notes - ;; - schema: a new entity type (not a special kind of project) - ;; - modify migration based on ^new schema - ;; - color is picked per sticky note - ;; - sticky notes can include images/videos - - ]])) - (comment [:ul ;; i18n stuff [:li "suggested locales:" (str (:entity/locale-suggestions board))] diff --git a/src/sb/app/collection/data.cljc b/src/sb/app/collection/data.cljc index 5aff93d3..b9094142 100644 --- a/src/sb/app/collection/data.cljc +++ b/src/sb/app/collection/data.cljc @@ -8,6 +8,6 @@ :entity/kind :collection/boards :entity/title - (? :entity/domain) + (? :entity/domain-name) (? :image/avatar) (? :image/background)]}}) \ No newline at end of file diff --git a/src/sb/app/domain/ui.cljc b/src/sb/app/domain/ui.cljc deleted file mode 100644 index bb01af70..00000000 --- a/src/sb/app/domain/ui.cljc +++ /dev/null @@ -1,47 +0,0 @@ -(ns sb.app.domain.ui - (:require [clojure.string :as str] - [inside-out.forms :as forms] - [promesa.core :as p] - [sb.app.domain.data :as data] - [sb.app.field.ui :as field.ui] - [sb.app.form.ui :as form.ui] - [sb.i18n :refer [tr]] - [sb.app.views.ui :as ui])) - -#?(:cljs - (defn availability-validator [] - (-> (fn [v {:keys [field]}] - (when (not= (:domain/name (:init field)) - (:domain/name v)) - (when-let [v (:domain/name v)] - (when (>= (count v) 3) - (p/let [res (data/check-availability {:domain v})] - (if (:available? res) - (forms/message :info - [:span.text-green-500.font-bold (tr :tr/available)]) - (forms/message :invalid - (tr :tr/not-available) - {:visibility :always}))))))) - (forms/debounce 300)))) - -#?(:cljs - (defn domain-field [?domain & [props]] - [:div.field-wrapper - [form.ui/show-label ?domain] - [:div.flex.gap-2.items-stretch - (field.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 ".sb.com"]]])) - -#?(:cljs - (defn validators [] - [data/domain-valid-string - (availability-validator)])) \ No newline at end of file diff --git a/src/sb/app/domain/data.cljc b/src/sb/app/domain_name/data.cljc similarity index 52% rename from src/sb/app/domain/data.cljc rename to src/sb/app/domain_name/data.cljc index 1f8d06d3..6e03139b 100644 --- a/src/sb/app/domain/data.cljc +++ b/src/sb/app/domain_name/data.cljc @@ -1,4 +1,4 @@ -(ns sb.app.domain.data +(ns sb.app.domain-name.data (:require [clojure.string :as str] [inside-out.forms :as forms] [re-db.api :as db] @@ -8,19 +8,19 @@ [sb.schema :as sch :refer [? s-]])) (sch/register! - {:domain/url {s- :http/url} - :domain/name (merge {:doc "A complete domain name, eg a.b.com"} - sch/unique-id-str) - :domain/owner (sch/ref :one) - :entity/domain (merge (sch/ref :one :domain/as-map) - sch/unique-value) - :entity/_domain {s- [:map {:closed true} :entity/id]} - :domain/as-map (merge (sch/ref :one) - {s- [:map {:closed true} - :domain/name - (? :entity/_domain) - (? :domain/url) - (? :domain/owner)]})}) + {:domain-name/redirect-url {s- :http/url} + :domain-name/name (merge {:doc "A complete domain name, eg a.b.com"} + sch/unique-id-str) + :domain/owner (sch/ref :one) + :entity/domain-name (merge (sch/ref :one :domain-name/as-map) + sch/unique-value) + :entity/_domain-name {s- [:map {:closed true} :entity/id]} + :domain-name/as-map (merge (sch/ref :one) + {s- [:map {:closed true} + :domain-name/name + (? :entity/_domain-name) + (? :domain-name/redirect-url) + (? :domain/owner)]})}) (defn normalize-domain [domain] (-> domain @@ -45,11 +45,11 @@ {:available? (and (re-matches #"^[a-z0-9-.]+$" domain) (empty? - (:entity/_domain (db/entity [:domain/name domain])))) + (:entity/_domain-name (db/entity [:domain-name/name domain])))) :domain domain})) (defn domain-valid-string [v _] - (when-let [v (unqualify-domain (:domain/name v))] + (when-let [v (unqualify-domain (:domain-name/name v))] (when (< (count v) 3) (tr :tr/too-short)) (when-not (re-matches #"^[a-z0-9-]+$" v) diff --git a/src/sb/app/domain_name/ui.cljc b/src/sb/app/domain_name/ui.cljc new file mode 100644 index 00000000..1c1d79b1 --- /dev/null +++ b/src/sb/app/domain_name/ui.cljc @@ -0,0 +1,46 @@ +(ns sb.app.domain-name.ui + (:require [clojure.string :as str] + [inside-out.forms :as forms] + [promesa.core :as p] + [sb.app.domain-name.data :as data] + [sb.app.field.ui :as field.ui] + [sb.app.form.ui :as form.ui] + [sb.app.views.ui :as ui] + [sb.i18n :refer [tr]])) + +#?(:cljs + (defn availability-validator [] + (-> (fn [v {:keys [field]}] + (when (not= (:domain-name/name (:init field)) + (:domain-name/name v)) + (when-let [v (:domain-name/name v)] + (when (>= (count v) 3) + (p/let [res (data/check-availability {:domain v})] + (if (:available? res) + (forms/message :info + [:span.text-green-500.font-bold (tr :tr/available)]) + (forms/message :invalid + (tr :tr/not-available) + {:visibility :always}))))))) + (forms/debounce 300)))) + + +(ui/defview domain-field [?domain props] + [:div.field-wrapper + [form.ui/show-label ?domain] + [:div.flex.gap-2.items-stretch + (field.ui/text-field ?domain (merge {:wrap (fn [v] + (when-not (str/blank? v) + {:domain-name/name (data/qualify-domain (data/normalize-domain v))})) + :unwrap (fn [v] + (or (some-> v :domain-name/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"]]]) + +(defn validators [] + [data/domain-valid-string + #?(:cljs (availability-validator))]) \ No newline at end of file diff --git a/src/sb/app/entity/data.cljc b/src/sb/app/entity/data.cljc index 2a305ffd..851ba555 100644 --- a/src/sb/app/entity/data.cljc +++ b/src/sb/app/entity/data.cljc @@ -69,7 +69,7 @@ :entity/deleted-at {:image/avatar [:entity/id]} {:image/background [:entity/id]} - {:entity/domain [:domain/name]}]) + {:entity/domain-name [:domain-name/name]}]) (q/defx save-attributes! {:prepare [az/with-account-id!]} diff --git a/src/sb/app/entity/ui.cljc b/src/sb/app/entity/ui.cljc index 2924bbfc..52fcdd3b 100644 --- a/src/sb/app/entity/ui.cljc +++ b/src/sb/app/entity/ui.cljc @@ -1,26 +1,41 @@ (ns sb.app.entity.ui - (:require [inside-out.forms :as forms] + (:require [clojure.string :as str] + [inside-out.forms :as forms] [sb.app.asset.ui :as asset.ui] [sb.app.entity.data :as data] + [sb.app.field.ui :as field.ui] [sb.routing :as routing] [sb.app.views.ui :as ui] [sb.icons :as icons] [sb.validate :as validate] [yawn.hooks :as h] - [yawn.view :as v])) + [yawn.view :as v] + [sb.schema :as sch])) -#?(:cljs - (defn use-persisted [entity attribute field-view & [props]] +(defn infer-view [attribute] + (let [{:keys [malli/schema]} (get @sch/!schema attribute)] + (case schema + :string field.ui/text-field + :http/url field.ui/text-field + :boolean field.ui/checkbox-field + :prose/as-map field.ui/prose-field + nil))) + +(defn use-persisted [entity attribute & {:as props :keys [view]}] + #?(:cljs (let [persisted-value (get entity attribute) ?field (h/use-memo #(forms/field :init persisted-value :attribute attribute props) ;; create a new field when the persisted value changes - (h/use-deps persisted-value))] - [field-view ?field (merge {:persisted-value persisted-value - :on-save #(forms/try-submit+ ?field - (data/save-attribute! nil (:entity/id entity) attribute %))} - props)]))) + (h/use-deps persisted-value)) + view (or view + (:view ?field) + (infer-view attribute) (throw (ex-info (str "No view declared for attribute: " attribute) {:attribute attribute})))] + [view ?field (merge {:persisted-value persisted-value + :on-save #(forms/try-submit+ ?field + (data/save-attribute! nil (:entity/id entity) attribute %))} + (dissoc props :view))]))) #?(:cljs (defn href [{:as e :entity/keys [kind id]} key] @@ -51,7 +66,7 @@ (ui/defview settings-button [entity] (when-let [path (and (validate/editing-role? (:member/roles entity)) (some-> (routing/entity-route entity :settings) routing/path-for))] - [:a.icon-light-gray.flex.items-center.justify-center.focus-visible:bg-gray-200.self-stretch.rounded + [:a.button {:href path} [icons/gear "icon-lg"]])) diff --git a/src/sb/app/field/admin_ui.cljc b/src/sb/app/field/admin_ui.cljc index fe286fe4..f7d37c65 100644 --- a/src/sb/app/field/admin_ui.cljc +++ b/src/sb/app/field/admin_ui.cljc @@ -170,22 +170,21 @@ [: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 field.ui/text-field {:class "bg-white text-sm" - :multi-line true}) - (entity.ui/use-persisted field :field/hint field.ui/text-field {:class "bg-white text-sm" - :multi-line true - :placeholder "Further instructions"})] + (entity.ui/use-persisted field :field/label {:class "bg-white text-sm" + :multi-line true}) + (entity.ui/use-persisted field :field/hint {: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)))]] + (entity.ui/use-persisted field :field/options {:view options-field})) + [:div.contents.labels-normal - (entity.ui/use-persisted field :field/required? field.ui/checkbox-field) - (entity.ui/use-persisted field :field/show-as-filter? field.ui/checkbox-field) + (entity.ui/use-persisted field :field/required?) + (entity.ui/use-persisted field :field/show-as-filter?) (when (= attribute :board/member-fields) - (entity.ui/use-persisted field :field/show-at-registration? field.ui/checkbox-field)) - (entity.ui/use-persisted field :field/show-on-card? field.ui/checkbox-field) + (entity.ui/use-persisted field :field/show-at-registration?)) + (entity.ui/use-persisted field :field/show-on-card?) [: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) @@ -193,15 +192,15 @@ (data/remove-field nil (:entity/id parent) attribute - (:entity/id field)))})} + (:field/id field)))})} (tr :tr/remove)]]]) (ui/defview field-editor - {:key (comp :entity/id :field)} + {:key (comp :field/id :field)} [{:keys [parent attribute order-by expanded? toggle-expand! field]}] (let [field-type (data/field-types (:field/type field)) [handle-props drag-props indicator] (orderable-props {:group-id attribute - :id (sch/wrap-id (:entity/id field)) + :id (:field/id field) :on-move (fn [{:as args :keys [source destination side]}] (p/-> (entity.data/order-ref! {:attribute attribute @@ -240,11 +239,10 @@ (let [label (:label (forms/global-meta attribute)) !new-field (h/use-state nil) !autofocus-ref (ui/use-autofocus-ref) - fields (->> (get entity attribute) - (sort-by :field/order)) + fields (get entity attribute) [expanded expand!] (h/use-state nil)] [:div.field-wrapper {:class "labels-semibold"} - (when-let [label (or label (tr attribute))] + (when-let [label (or label (form.ui/attribute-label attribute))] [:label.field-label {:class "flex items-center"} label [:div.flex.ml-auto.items-center @@ -267,21 +265,20 @@ (map (fn [field] (field-editor {:parent entity :attribute attribute - :order-by :field/order - :expanded? (= expanded (:entity/id field)) + :expanded? (= expanded (:field/id field)) :toggle-expand! #(expand! (fn [old] - (when-not (= old (:entity/id field)) - (:entity/id field)))) + (when-not (= old (:field/id field)) + (:field/id field)))) :field field}))) doall)] - (when-let [{:as !form + (when-let [{:as ?new-field :syms [?type ?label]} @!new-field] [:div [:form.flex.gap-2.items-start.relative {:on-submit (ui/prevent-default (fn [e] - (forms/try-submit+ !form - (p/let [{:as result :keys [entity/id]} (data/add-field nil (:entity/id entity) attribute @!form)] + (forms/try-submit+ ?new-field + (p/let [{:as result :keys [field/id]} (data/add-field nil (:entity/id entity) attribute @?new-field)] (expand! id) (reset! !new-field nil) result))))} @@ -295,4 +292,4 @@ :wrapper-class "flex-auto"}] [:button.btn.btn-white.h-10 {:type "submit"} (tr :tr/add)]] - [:div.pl-12.py-2 (form.ui/show-field-messages !form)]])])) \ No newline at end of file + [:div.pl-12.py-2 (form.ui/show-field-messages ?new-field)]])])) \ No newline at end of file diff --git a/src/sb/app/field/data.cljc b/src/sb/app/field/data.cljc index 45e6b194..0694a569 100644 --- a/src/sb/app/field/data.cljc +++ b/src/sb/app/field/data.cljc @@ -38,7 +38,7 @@ (sch/register! {:image/url {s- :http/url} - + :field/id {s- :uuid}, :field/hint {s- :string}, :field/label {s- :string}, :field/default-value {s- :string} @@ -47,7 +47,6 @@ (? :field-option/color) (? :field-option/value) :field-option/label]} - :field/order {s- :int}, :field/required? {s- :boolean}, :field/show-as-filter? {:doc "Use this field as a filtering option" s- :boolean}, @@ -71,7 +70,7 @@ :field-option/label {s- :string}, :field-option/value {s- :string}, :video/url {s- :string} - :image-list/images {s- [:sequential :entity/id]} + :image-list/images {s- [:sequential :entity/id]} :link-list/links {s- [:sequential :link-list/link]} :select/value {s- :string} :field-entry/as-map {s- [:map {:closed true} @@ -87,9 +86,7 @@ "Orgs/boards should be able to override/add field.spec options." "Field specs should be globally merged so that fields representing the 'same' thing can be globally searched/filtered?"] s- [:map {:closed true} - :entity/id - :entity/kind - :field/order + :field/id :field/type (? :field/published?) (? :field/hint) @@ -101,13 +98,12 @@ (? :field/show-on-card?)]}}) (def field-keys [:field/hint - :entity/id + :field/id :field/label :field/default-value {:field/options [:field-option/color :field-option/value :field-option/label]} - :field/order :field/required? :field/show-as-filter? :field/show-at-registration? @@ -115,15 +111,15 @@ :field/type]) (def field-types {:field.type/prose {:icon icons/text - :label (tr :tr/text)} + :label (tr :tr/text)} :field.type/select {:icon icons/dropdown-menu - :label (tr :tr/menu)} + :label (tr :tr/menu)} :field.type/video {:icon icons/video - :label (tr :tr/video)} + :label (tr :tr/video)} :field.type/link-list {:icon icons/link-2 - :label (tr :tr/links)} - :field.type/image-list {:icon icons/photo - :label (tr :tr/image)} + :label (tr :tr/links)} + :field.type/image-list {:icon icons/photo + :label (tr :tr/image)} }) (defn blank? [color] @@ -205,32 +201,23 @@ (q/defx add-field {:prepare [az/with-account-id!]} - [{:keys [account-id]} e a field] + [{:keys [account-id]} e a new-field] (validate/assert-can-edit! e account-id) (let [e (sch/wrap-id e) - existing-fields (->> (a (db/entity e)) - (sort-by :field/order)) - field (-> field - (assoc :field/order (if-let [last-order (:field/order (last existing-fields))] - (inc last-order) - 0) - :entity/kind :field - :entity/id (dl/new-uuid :field)))] + existing-fields (a (db/entity e)) + field (assoc new-field :field/id (dl/new-uuid :field))] (validate/assert field :field/as-map) - (db/transact! [(assoc field :db/id -1) - [:db/add e a -1]]) - {:entity/id (:entity/id field)})) + (db/transact! [[:db/add e a (conj existing-fields field)]]) + {:field/id (:field/id field)})) (q/defx remove-field {:prepare [az/with-account-id!]} [{:keys [account-id]} parent-id a field-id] (validate/assert-can-edit! parent-id account-id) - (let [parent (db/entity (sch/wrap-id parent-id)) - field (db/entity (sch/wrap-id field-id))] - (db/transact! [[:db/retract - (:db/id parent) - a - (:db/id field)]]) + (let [parent (db/entity (sch/wrap-id parent-id))] + (db/transact! [[:db/add (:db/id parent) a (->> (get parent a) + (remove (comp #{field-id} :field/id)) + vec)]]) {})) (defmulti entry-value (fn [field entry] (:field/type field))) diff --git a/src/sb/app/field/ui.cljc b/src/sb/app/field/ui.cljc index 357914d6..beda2e93 100644 --- a/src/sb/app/field/ui.cljc +++ b/src/sb/app/field/ui.cljc @@ -45,7 +45,7 @@ (v/defview auto-size [props] (let [v! (h/use-state "") - props (merge props {:value (:value props @v!) + props (merge props {:value (or (:value props @v!) "") :on-change (:on-change props #(reset! v! (j/get-in % [:target :value])))})] [:div.auto-size @@ -82,7 +82,7 @@ [: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)] + (when-let [label (form.ui/get-label ?field (:label props))] [:div.flex.items-center.h-5 label]) (when (seq messages) (into [:div.text-gray-500] (map form.ui/view-message) messages))]]])) diff --git a/src/sb/app/form/ui.cljc b/src/sb/app/form/ui.cljc index c052113e..8e2de0a3 100644 --- a/src/sb/app/form/ui.cljc +++ b/src/sb/app/form/ui.cljc @@ -1,9 +1,10 @@ (ns sb.app.form.ui (:require [applied-science.js-interop :as j] + [inside-out.forms :as forms] [sb.app.views.ui :as ui] [inside-out.forms :as io] [yawn.view :as v] - [sb.i18n :refer [tr]] + [sb.i18n :refer [tr tr*]] [sb.util :as u] [sb.icons :as icons] [sb.color :as color])) @@ -29,8 +30,17 @@ :can-edit? :label)) +(defn attribute-label [attribute] + (or (get-in forms/global-meta [attribute :label]) + (tr* (keyword "tr" (name attribute))))) + +(defn get-label [?field label] + (u/some-or label + (:label ?field) + (some->> (:attribute ?field) attribute-label))) + (defn show-label [?field & [label]] - (when-let [label (u/some-or label (:label ?field))] + (when-let [label (get-label ?field label)] [:label.field-label {:for (field-id ?field)} label])) (defn ?field-props [?field @@ -64,7 +74,7 @@ (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 form-classes "flex-v gap-4 p-6 max-w-lg mx-auto bg-back relative text-sm") (ui/defview view-message [{:keys [type content]}] (case type diff --git a/src/sb/app/org/data.cljc b/src/sb/app/org/data.cljc index 1b84c485..82c93ffc 100644 --- a/src/sb/app/org/data.cljc +++ b/src/sb/app/org/data.cljc @@ -2,7 +2,7 @@ (:require [inside-out.forms :as forms] [promesa.core :as p] [re-db.api :as db] - [sb.app.domain.data :as domain.data] + [sb.app.domain-name.data :as domain.data] [sb.app.entity.data :as entity.data] [sb.app.member.data :as member.data] [sb.authorize :as az] @@ -38,7 +38,7 @@ (? :entity/social-feed) (? :entity/locale-default) (? :entity/locale-suggestions) - (? :entity/domain) + (? :entity/domain-name) (? :entity/public?) (? :org/default-board-template) (? :entity/created-at)]}}) diff --git a/src/sb/app/org/ui.cljc b/src/sb/app/org/ui.cljc index 1832162f..bbf3fc73 100644 --- a/src/sb/app/org/ui.cljc +++ b/src/sb/app/org/ui.cljc @@ -1,7 +1,7 @@ (ns sb.app.org.ui (:require [inside-out.forms :as forms] [promesa.core :as p] - [sb.app.domain.ui :as domain.ui] + [sb.app.domain-name.ui :as domain.ui] [sb.app.entity.data :as entity.data] [sb.app.entity.ui :as entity.ui] [sb.app.field.ui :as field.ui] @@ -67,7 +67,7 @@ [:div {:class form.ui/form-classes} (entity.ui/use-persisted org :entity/title field.ui/text-field) (entity.ui/use-persisted org :entity/description field.ui/prose-field) - (entity.ui/use-persisted org :entity/domain domain.ui/domain-field) + (entity.ui/use-persisted org :entity/domain-name domain.ui/domain-field) ;; TODO - uploading an image does not work (entity.ui/use-persisted org :image/avatar field.ui/image-field {:label (tr :tr/image.logo)}) @@ -78,8 +78,8 @@ :view/router :router/modal} [params] (forms/with-form [!org (u/prune - {:entity/title ?title - :entity/domain ?domain}) + {:entity/title ?title + :entity/domain-name ?domain}) :required [?title ?domain]] [:form {:class form.ui/form-classes @@ -90,5 +90,5 @@ (routes/nav! [`show {:org-id (:entity/id result)}])))} [:h2.text-2xl (tr :tr/new-org)] [field.ui/text-field ?title {:label (tr :tr/title)}] - (domain.ui/domain-field ?domain) + (domain.ui/domain-field ?domain nil) [form.ui/submit-form !org (tr :tr/create)]])) \ No newline at end of file diff --git a/src/sb/app/project/ui.cljc b/src/sb/app/project/ui.cljc index 15a68009..53201557 100644 --- a/src/sb/app/project/ui.cljc +++ b/src/sb/app/project/ui.cljc @@ -118,7 +118,7 @@ :keys [project/badges member/roles]} (data/show params) [can-edit? dev-panel] (use-dev-panel project) - fields (->> project :entity/parent :board/project-fields (sort-by :field/order)) + fields (->> project :entity/parent :board/project-fields) entries (->> project :entity/field-entries)] [:<> dev-panel @@ -151,7 +151,7 @@ (map (fn [bdg] [:li.rounded.bg-badge.text-badge-txt.py-1.px-2.text-sm.inline-flex (:badge/label bdg)])) badges)]) (for [field fields - :let [entry (get entries (:entity/id field))] + :let [entry (get entries (:field/id field))] :when (or can-edit? (field.data/entry-value field entry))] (field.ui/show-entry {:parent project diff --git a/src/sb/app/views/header.cljc b/src/sb/app/views/header.cljc index 1902f50b..dfb83a82 100644 --- a/src/sb/app/views/header.cljc +++ b/src/sb/app/views/header.cljc @@ -15,13 +15,6 @@ [yawn.hooks :as h] [yawn.util :as yu])) -(defn btn [{:keys [icon href]}] - [(if href :a :div) - {:class "btn-white" - :href href} - icon]) - - #?(:cljs (defn lang-menu-content [] (let [current-locale (i/current-locale) @@ -40,7 +33,7 @@ [:div.inline-flex.flex-row.items-center {:class ["hover:text-txt-faded" classes]} (radix/dropdown-menu - {:trigger [icons/languages "w-5 h-5"] + {:trigger [icons/languages] :children (lang-menu-content)})]) (ui/defview chats-list [] @@ -63,19 +56,12 @@ {:open @!open? :on-open-change #(reset! !open? %)} [:el Popover/Trigger {:as-child true} - [:button.relative.menu-darken.px-1.rounded {:tab-index 0} - ;; unread-count bubble + [:button {:tab-index 0} (when unread - [:div.z-10 - {:style {:width 10 - :height 10 - :top "50%" - :margin-top -14 - :right 2 - :position "absolute"} - :class ["rounded-full" - "bg-focus-accent focus-visible:bg-black"]}]) - [icons/chat-bubble-left "icon-lg -mb-[2px]"]]] + [:div.z-10.absolute.font-bold.text-xs.text-center.text-focus-accent + {:style {:top 2 :right 0 :width 20}} + unread]) + [icons/paper-plane (when unread "text-focus-accent")]]] [:el Popover/Portal {:container (yu/find-or-create-element :radix-modal)} [:Suspense {} @@ -89,8 +75,8 @@ (if-let [account (db/get :env/config :account)] [:<> (radix/dropdown-menu - {:trigger [:button.flex.items-center.focus-ring.rounded.px-1 {:tab-index 0} - [:img.rounded-full.h-6.w-6 {:src (asset.ui/asset-src (:image/avatar account) :avatar)}]] + {:trigger [:button {:tab-index 0} + [:img.rounded-full.icon-lg {:src (asset.ui/asset-src (:image/avatar account) :avatar)}]] :children [[{:on-click #(routes/nav! 'sb.app.account-ui/show)} (tr :tr/home)] [{:on-click #(routes/nav! 'sb.app.account-ui/logout!)} (tr :tr/logout)] [{:sub? true diff --git a/src/sb/app/views/radix.cljc b/src/sb/app/views/radix.cljc index e135da5d..acf17c1f 100644 --- a/src/sb/app/views/radix.cljc +++ b/src/sb/app/views/radix.cljc @@ -1,5 +1,6 @@ (ns sb.app.views.radix - (:require #?(:cljs ["@radix-ui/react-alert-dialog" :as alert]) + (:require #?(:cljs ["@radix-ui/react-accordion" :as accordion]) + #?(: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]) @@ -192,4 +193,18 @@ [:el tooltip/Arrow]]]]]) child)) - ) \ No newline at end of file + ) + +(defn accordion [props & sections] + [:el.accordion-root accordion/Root (v/merge-props {:default-value #js["0"] + :type "multiple"} + props) + (->> (partition 2 sections) + (map-indexed + (fn [i [trigger content]] + [:el.accordion-item accordion/Item {:key i + :value (str i)} + [:el accordion/Header + [:el.accordion-trigger accordion/Trigger (v/x trigger) [icons/chevron-down]]] + [:el.accordion-content accordion/Content + (v/x content)]])))]) \ No newline at end of file diff --git a/src/sb/app/views/ui.cljs b/src/sb/app/views/ui.cljs index 5ccf38f7..b21c0a59 100644 --- a/src/sb/app/views/ui.cljs +++ b/src/sb/app/views/ui.cljs @@ -19,8 +19,10 @@ [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])) + [yawn.view :as v] + [sb.i18n :as i18n])) (defn dev? [] (= "dev" (db/get :env/config :env))) @@ -134,11 +136,11 @@ :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])]) + "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)" @@ -160,13 +162,13 @@ @!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])) + "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." @@ -197,7 +199,7 @@ @!state)) (ui/defview redirect [to] - (h/use-effect #(routing/nav! to))) + (h/use-effect #(routing/nav! to))) (defn initials [display-name] (let [words (str/split display-name #"\s+")] @@ -255,12 +257,12 @@ #_[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))]]))) + (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)) @@ -278,5 +280,4 @@ (.preventDefault e) (f e))) -(defn pprint [x] (clojure.pprint/pprint x)) - +(defn pprint [x] (clojure.pprint/pprint x)) \ No newline at end of file diff --git a/src/sb/client/core.cljs b/src/sb/client/core.cljs index bea30d2e..a7826780 100644 --- a/src/sb/client/core.cljs +++ b/src/sb/client/core.cljs @@ -3,20 +3,16 @@ ["react-dom" :as react-dom] [applied-science.js-interop :as j] [inside-out.forms :as forms] + [org.sparkboard.slack.firebase :as firebase] [re-db.api :as db] [re-db.integrations.reagent] [sb.app :as app] - [sb.app.domain.ui :as domain.ui] - [sb.app.field.ui :as field.ui] - [sb.app.form.ui :as form.ui] + [sb.app.views.radix :as radix] + [sb.app.views.ui :as ui] [sb.client.scratch] - [sb.i18n :refer [tr]] [sb.routing :as routing] [sb.schema :as sch] - [org.sparkboard.slack.firebase :as firebase] [sb.transit :as transit] - [sb.app.views.ui :as ui] - [sb.app.views.radix :as radix] [yawn.root :as root] [yawn.view :as v])) @@ -44,7 +40,7 @@ (v/x [:div.p-6 [ui/hero {:class "bg-red-100 border-red-400/50 border border-4"} (ex-message e)]]))} - (ui/show-match root))] + (ui/show-match root))] (radix/dialog {:props/root {:open (boolean modal) :on-open-change #(when-not % (routing/dissoc-router! :router/modal))}} @@ -69,29 +65,7 @@ (cond-> (k field-meta) validator (update :validators conj validator)))) - (forms/set-global-meta! - {:account/email {:el field.ui/text-field - :props {:type "email" - :placeholder (tr :tr/email)} - :validators [form.ui/email-validator]} - :account/password {:el field.ui/text-field - :props {:type "password" - :placeholder (tr :tr/password)} - :validators [(forms/min-length 8)]} - :entity/title {:validators [(forms/min-length 3)] - :label (tr :tr/title)} - :board/project-fields {:label (tr :tr/project-fields)} - :board/member-fields {:label (tr :tr/member-fields)} - - :field/label {:label (tr :tr/label)} - :field/hint {:label (tr :tr/hint)} - :field/required? {:label (tr :tr/required)} - :field/show-as-filter? {:label (tr :tr/filter)} - :field/show-at-registration? {:label (tr :tr/show-at-registration)} - :field/show-on-card? {:label (tr :tr/show-on-card)} - :entity/description {:label (tr :tr/description)} - :entity/domain {:label (tr :tr/domain-name) - :validators (domain.ui/validators)}}) + (forms/set-global-meta! app/global-field-meta) ) (defn ^:dev/after-load init-endpoints! [] diff --git a/src/sb/i18n.cljc b/src/sb/i18n.cljc index 9301a07f..53fe112d 100644 --- a/src/sb/i18n.cljc +++ b/src/sb/i18n.cljc @@ -159,6 +159,9 @@ See https://iso639-3.sil.org/code_tables/639/data/all for list of codes" :tr/project-fields {:en "Project fields" :fr "Champs de projet" :es "Campos de proyecto"}, + :tr/member-tags {:en "Member tags" + :fr "Mots-clés des membres" + :es "Etiquetas de miembros"}, :tr/member-fields {:en "Member fields" :fr "Champs de membre" :es "Campos de miembro"}, @@ -339,16 +342,16 @@ See https://iso639-3.sil.org/code_tables/639/data/all for list of codes" :tr/hint {:en "Hint" :fr "Indice" :es "Pista"} - :tr/required {:en "Required" + :tr/required? {:en "Required" :fr "Obligatoire" :es "Requerido"} - :tr/filter {:en "Show as filter" + :tr/show-as-filter? {:en "Show as filter" :fr "Afficher comme filtre" :es "Mostrar como filtro"} - :tr/show-at-registration {:en "Show at registration" + :tr/show-at-registration? {:en "Show at registration" :fr "Afficher à l'inscription" :es "Mostrar en el registro"} - :tr/show-on-card {:en "Show on card" + :tr/show-on-card? {:en "Show on card" :fr "Afficher sur la carte" :es "Mostrar en la tarjeta"} :tr/untitled {:en "Untitled" @@ -360,20 +363,70 @@ See https://iso639-3.sil.org/code_tables/639/data/all for list of codes" :tr/find-a-member {:en "Find a member" :fr "Trouver un membre" :es "Encontrar un miembro"} + :tr/project-numbers {:en "Project numbers" + :fr "Numéros de projet" + :es "Números de proyecto"} + :tr/max-members-per-project {:en "Max members per project" + :fr "Nombre maximum de membres par projet" + :es "Máximo de miembros por proyecto"} + :tr/sharing-buttons {:en "Sharing buttons" + :fr "Boutons de partage" + :es "Botones de compartir"} + :tr/home-page-message {:en "Home page message" + :fr "Message de la page d'accueil" + :es "Mensaje de la página de inicio"} + :tr/max-projects-per-member {:en "Max projects per member" + :fr "Nombre maximum de projets par membre" + :es "Máximo de proyectos por miembro"} + :tr/registration-codes {:en "Registration codes" + :fr "Codes d'inscription" + :es "Códigos de registro"} + :tr/invite-email-text {:en "Invite email text" + :fr "Texte de l'e-mail d'invitation" + :es "Texto del correo electrónico de invitación"} + :tr/registration-newsletter-field? {:en "Registration newsletter field" + :fr "Champ d'inscription à la newsletter" + :es "Campo de registro de boletín"} + :tr/registration-open? {:en "Registration is open" + :fr "L'inscription est ouverte" + :es "El registro está abierto"} + :tr/registration-page-message {:en "Registration page message" + :fr "Message de la page d'inscription" + :es "Mensaje de la página de registro"} + :tr/registration-url-override {:en "Registration URL override" + :fr "Remplacement de l'URL d'inscription" + :es "Anulación de la URL de registro"} :tr/remove {:en "Remove" :fr "Supprimer" - :es "Eliminar"}})) + :es "Eliminar"} + :tr/basic-settings {:en "Basic settings" + :fr "Paramètres de base" + :es "Configuración básica"} + :tr/projects-and-members {:en "Projects and members" + :fr "Projets et membres" + :es "Proyectos y miembros"} + :tr/registration {:en "Registration" + :fr "Inscription" + :es "Registro"} + + })) + +(defn tr* + ([resource-ids] + (tempura/tr {:dict dict} (locales) (cond-> resource-ids + (keyword? resource-ids) + vector))) + ([resource-ids resource-args] + (tempura/tr {:dict dict} (locales) + (cond-> resource-ids + (keyword? resource-ids) + vector) + resource-args))) (defn tr - ([resource-ids] (or (tempura/tr {:dict dict} (locales) (cond-> resource-ids - (keyword? resource-ids) - vector)) + ([resource-ids] (or (tr* resource-ids) #?(:cljs (doto (str "Missing" resource-ids) js/console.warn)))) - ([resource-ids resource-args] (or (tempura/tr {:dict dict} (locales) - (cond-> resource-ids - (keyword? resource-ids) - vector) - resource-args) + ([resource-ids resource-args] (or (tr* resource-ids resource-args) #?(:cljs (doto (str "Missing" resource-ids) js/console.warn))))) (def supported-locales (into #{} (map name) (keys dict))) diff --git a/src/sb/icons.cljc b/src/sb/icons.cljc index 2f0d9bca..9dcdc5f9 100644 --- a/src/sb/icons.cljc +++ b/src/sb/icons.cljc @@ -176,4 +176,8 @@ (defn dash [& [classes]] (v/x - [:svg.icon {:classes classes :width "15" :height "15" :viewBox "0 0 15 15" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M5 7.5C5 7.22386 5.22386 7 5.5 7H9.5C9.77614 7 10 7.22386 10 7.5C10 7.77614 9.77614 8 9.5 8H5.5C5.22386 8 5 7.77614 5 7.5Z" :fill "currentColor" :fill-rule "evenodd" :clip-rule "evenodd"}]])) \ No newline at end of file + [:svg.icon {:class classes :width "15" :height "15" :viewBox "0 0 15 15" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M5 7.5C5 7.22386 5.22386 7 5.5 7H9.5C9.77614 7 10 7.22386 10 7.5C10 7.77614 9.77614 8 9.5 8H5.5C5.22386 8 5 7.77614 5 7.5Z" :fill "currentColor" :fill-rule "evenodd" :clip-rule "evenodd"}]])) + +(defn paper-plane [& [classes]] + (v/x + [:svg.icon {:class classes :width "15" :height "15" :viewBox "0 0 15 15" :fill "none" :xmlns "http://www.w3.org/2000/svg"} [:path {:d "M1.20308 1.04312C1.00481 0.954998 0.772341 1.0048 0.627577 1.16641C0.482813 1.32802 0.458794 1.56455 0.568117 1.75196L3.92115 7.50002L0.568117 13.2481C0.458794 13.4355 0.482813 13.672 0.627577 13.8336C0.772341 13.9952 1.00481 14.045 1.20308 13.9569L14.7031 7.95693C14.8836 7.87668 15 7.69762 15 7.50002C15 7.30243 14.8836 7.12337 14.7031 7.04312L1.20308 1.04312ZM4.84553 7.10002L2.21234 2.586L13.2689 7.50002L2.21234 12.414L4.84552 7.90002H9C9.22092 7.90002 9.4 7.72094 9.4 7.50002C9.4 7.27911 9.22092 7.10002 9 7.10002H4.84553Z" :fill "currentColor" :fill-rule "evenodd" :clip-rule "evenodd"}]])) \ No newline at end of file diff --git a/src/sb/migration/one_time.clj b/src/sb/migration/one_time.clj index e5239606..27e25654 100644 --- a/src/sb/migration/one_time.clj +++ b/src/sb/migration/one_time.clj @@ -242,7 +242,7 @@ (update-vals #(->> % (sort-by (comp bson-id-timestamp get-oid :_id))))))) -(defn unmunge-domain [s] (str/replace s "_" ".")) +(defn unmunge-domain-name [s] (str/replace s "_" ".")) (declare coll-entities) @@ -351,43 +351,43 @@ :id-string id-string :ref (uuid-ref kind id-string)})) -(defn parse-domain-target [target] +(defn parse-domain-name-target [target] ;; TODO - domains that point to URLs should be "owned" by someone (if (str/starts-with? target "redirect:") - {:domain/url (-> (subs target 9) - (str/replace "%3A" ":") - (str/replace "%2F" "/"))} + {:domain-name/redirect-url (-> (subs target 9) + (str/replace "%3A" ":") + (str/replace "%2F" "/"))} (let [{:keys [kind id-string ref uuid]} (parse-sparkboard-id target)] (if (= [kind id-string] [:site "account"]) - {:domain/url "https://account.sb.com"} + {:domain-name/redirect-url "https://account.sb.com"} (when-not (missing-uuid? uuid) - {:entity/_domain [{:entity/id uuid}]}))))) + {:entity/_domain-name [{:entity/id uuid}]}))))) (def !entity->domain (delay (->> ((read-firebase) "domain") (keep (fn [[name target]] (let [[kind v] (if (str/starts-with? target "redirect:") - [:domain/url (-> (subs target 9) - (str/replace "%3A" ":") - (str/replace "%2F" "/"))] + [:domain-name/redirect-url (-> (subs target 9) + (str/replace "%3A" ":") + (str/replace "%2F" "/"))] (let [{:keys [kind id-string ref uuid]} (parse-sparkboard-id target)] (if (= [kind id-string] [:site "account"]) - [:domain/url "https://account.sb.com"] + [:domain-name/redirect-url "https://account.sb.com"] [:entity/id uuid])))] (when (= kind :entity/id) - [v {:domain/name (unmunge-domain name)}])))) + [v {:domain-name/name (unmunge-domain-name name)}])))) (into {})))) (comment (db/transact! (for [[entity-id entry] @!entity->domain :when (db/get [:entity/id entity-id])] - {:entity/id entity-id - :entity/domain entry}))) + {:entity/id entity-id + :entity/domain-name entry}))) (defn lookup-domain [m] - (assoc-some-value m :entity/domain (@!entity->domain (:entity/id m)))) + (assoc-some-value m :entity/domain-name (@!entity->domain (:entity/id m)))) (defn smap [m] (apply sorted-map (apply concat m))) @@ -417,12 +417,12 @@ :field.type/prose)) (def !all-fields - (delay - (->> (coll-entities :board/as-map) - (mapcat (juxt :board/member-fields :board/project-fields)) - (mapcat identity) - (map (juxt :entity/id identity)) - (into {})))) + (delay + (->> (coll-entities :board/as-map) + (mapcat (juxt :board/member-fields :board/project-fields)) + (mapcat identity) + (map (juxt :entity/id identity)) + (into {})))) (defn prose [s] (when-not (str/blank? s) @@ -448,7 +448,6 @@ (let [field-id (composite-uuid :field (to-uuid :board managed-by) (to-uuid :field (subs (name k) 6))) - target-id (:entity/id m) v (m k) field-type (:field/type (@!all-fields field-id)) ;; NOTE - we ignore fields that do not have a spec @@ -584,7 +583,7 @@ (let [managed-by (:entity/id m)] (assoc m a (try (->> v - (flat-map :entity/id + (flat-map :field/id #(composite-uuid :field managed-by (to-uuid :field %))) @@ -595,7 +594,7 @@ ;; because fields have been duplicated everywhere ;; and have the same IDs but represent different instances. ;; unsure: how to re-use fields when searching across boards, etc. - (dissoc "id" "name") + (dissoc "id" "name" "order") (rename-keys {"type" :field/type "showOnCard" :field/show-on-card? "showAtCreate" :field/show-at-registration? @@ -603,8 +602,7 @@ "required" :field/required? "hint" :field/hint "label" :field/label - "options" :field/options - "order" :field/order}) + "options" :field/options}) (u/update-some {:field/options (partial mapv #(-> % @@ -617,15 +615,13 @@ (filter #(get % "default")) first (get "value"))) - (update :field/order #(or % (swap! !orders inc))) - (update :field/type parse-field-type) - (assoc :entity/kind :field))))) + (update :field/type parse-field-type))))) (catch Exception e (prn a v) (throw e))))))] ["groupFields" (& field-xf (rename :board/project-fields)) "userFields" (& field-xf (rename :board/member-fields))]) - "groupNumbers" (rename :board/show-project-numbers?) - "projectNumbers" (rename :board/show-project-numbers?) + "groupNumbers" (rename :board/project-numbers?) + "projectNumbers" (rename :board/project-numbers?) "userMaxGroups" (& (xf #(Integer. %)) (rename :board/max-projects-per-member)) "stickyColor" (rename :board/sticky-color) "tags" (& (fn [m a v] @@ -682,7 +678,7 @@ "description" (& (xf prose) (rename :entity/description)) ;; if = "Description..." then it's never used "publicWelcome" (& (xf prose) - (rename :board/instructions)) + (rename :board/home-page-message)) "css" (rename :board/custom-css) "parent" (& (xf (comp :ref parse-sparkboard-id)) @@ -694,11 +690,11 @@ "groupMaxMembers" (& (xf #(Integer. %)) (rename :board/max-members-per-project)) "headerJs" (rename :board/custom-js) "projectTags" rm - "registrationEmailBody" (rename :board/registration-invitation-email-text) + "registrationEmailBody" (rename :board/invite-email-text) "learnMoreLink" (rename :entity/website) "metaDesc" (rename :entity/meta-description) "registrationMessage" (& (xf prose) - (rename :board/registration-message)) + (rename :board/registration-page-message)) "defaultFilter" rm "defaultTag" rm "locales" (rename :entity/locale-dicts) @@ -1305,7 +1301,7 @@ ;; misc (explain-errors!) (:out (sh "ls" "-lh" (env/db-path))) - (map parse-domain-target (vals (read-coll :domain/as-map))) + (map parse-domain-name-target (vals (read-coll :domain-name/as-map))) ;; try validating generated docs (check-docs diff --git a/src/sb/server/core.clj b/src/sb/server/core.clj index 871d6e99..e271aa79 100644 --- a/src/sb/server/core.clj +++ b/src/sb/server/core.clj @@ -177,7 +177,7 @@ (r/reaction {:error (ex-message e)})))) (comment - (routing/tag->endpoint 'sb.app.domain.ui/check-availability :effect) + (routing/tag->endpoint 'sb.app.domain-name.ui/check-availability :effect) (routing/tag->endpoint 'sb.app.account.data/all :query) (resolve-query ['sb.app.org.data/show {}]) diff --git a/src/sb/validate.cljc b/src/sb/validate.cljc index 298526fd..67f6f745 100644 --- a/src/sb/validate.cljc +++ b/src/sb/validate.cljc @@ -70,15 +70,15 @@ "Conforms and validates an entity which may contain :entity/domain." [entity] {:pre [(:entity/id entity)]} - (if (:entity/domain entity) - (let [existing-domain (when-let [name (-> entity :entity/domain :domain/name)] - (db/entity [:domain/name name]))] + (if (:entity/domain-name entity) + (let [existing-domain (when-let [name (-> entity :entity/domain-name :domain-name/name)] + (db/entity [:domain-name/name name]))] (if (empty? existing-domain) entity ;; upsert new domain entry - (let [existing-id (-> existing-domain :entity/_domain first :entity/id)] + (let [existing-id (-> existing-domain :entity/_domain-name first :entity/id)] (if (= existing-id (:entity/id entity)) ;; no-op, domain is already pointing at this entity - (dissoc entity :entity/domain) + (dissoc entity :entity/domain-name) (throw (ex-info (tr :tr/domain-already-registered) (into {} existing-domain))))))) entity)) @@ -88,7 +88,7 @@ (-> m (conform-and-validate) (assert (-> (mu/optional-keys schema) - (mu/assoc :entity/domain (mu/optional-keys :domain/as-map))))))) + (mu/assoc :entity/domain-name (mu/optional-keys :domain-name/as-map))))))) (defn editing-role? [roles] (boolean (some #{:role/owner :role/admin :role/collaborate} roles))) diff --git a/src/sparkboard.css b/src/sparkboard.css index d98bb5ba..08540210 100644 --- a/src/sparkboard.css +++ b/src/sparkboard.css @@ -240,6 +240,35 @@ @layer components { + + .accordion-item { + @apply overflow-hidden + } + .accordion-content[data-state='open'] { + animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1); + } + .accordion-content[data-state='closed'] { + animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1); + } + .accordion-content { + @apply overflow-hidden p-3 + } + .accordion-trigger { + @apply + px-3 flex h-16 items-center justify-between w-full + rounded bg-gray-50 hover:bg-gray-100 data-[state=open]:bg-gray-100 + } + .accordion-trigger .icon { + transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1); + } + + .accordion-trigger[data-state='open'] { + + } + .accordion-trigger[data-state='open'] .icon { + transform: rotate(180deg); + } + .icon-sm { @apply w-[15px] h-[15px] } @@ -262,7 +291,7 @@ } .field-label { - @apply block font-bold text-base + @apply block font-semibold text-base } .field-wrapper { @apply gap-2 flex-v relative @@ -429,11 +458,11 @@ .header h3 { @apply items-center hidden sm:inline-flex } - .header button { - @apply h-10 px-2 rounded flex items-center menu-darken + .header button, .header .button { + @apply h-10 px-2 rounded flex items-center menu-darken relative } .header .icon { - @apply text-gray-400 + @apply icon-lg text-gray-400 } .card-grid { @@ -513,4 +542,22 @@ input[type="color"]::-webkit-color-swatch-wrapper { ::-webkit-color-swatch, ::-moz-color-swatch, ::-webkit-color-swatch-wrapper { border-color: transparent !important; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 126937c0..e653a668 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1078,6 +1078,22 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-accordion@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz#738441f7343e5142273cdef94d12054c3287966f" + integrity sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collapsible" "1.0.3" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-alert-dialog@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz#70dd529cbf1e4bff386814d3776901fcaa131b8c" @@ -1099,6 +1115,21 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-collapsible@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz#df0e22e7a025439f13f62d4e4a9e92c4a0df5b81" + integrity sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-collection@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"