Files
penpot/frontend/src/app/main/ui/dashboard/sidebar.cljs
2025-12-22 20:16:41 +01:00

1083 lines
40 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.dashboard.sidebar
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :as da]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.link :refer [link]]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
menu-team-icon*
show-subscription-dashboard-banner?
subscription-sidebar*]]
[app.main.ui.dashboard.team-form]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.functions :as f]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(def ^:private clear-search-icon
(deprecated-icon/icon-xref :delete-text (stl/css :clear-search-icon)))
(def ^:private search-icon
(deprecated-icon/icon-xref :search (stl/css :search-icon)))
(def ^:private tick-icon
(deprecated-icon/icon-xref :tick (stl/css :tick-icon)))
(def ^:private logo-icon
(deprecated-icon/icon-xref :logo (stl/css :logo-icon)))
(def ^:private add-icon
(deprecated-icon/icon-xref :add (stl/css :add-icon)))
(def ^:private arrow-icon
(deprecated-icon/icon-xref :arrow (stl/css :arrow-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(def ^:private pin-icon
(deprecated-icon/icon-xref :pin (stl/css :pin-icon)))
(def ^:private exit-icon
(deprecated-icon/icon-xref :exit (stl/css :exit-icon)))
(mf/defc sidebar-project*
{::mf/private true}
[{:keys [item is-selected]}]
(let [dstate (mf/deref refs/dashboard-local)
selected-files (:selected-files dstate)
selected-project (:selected-project dstate)
edit-id (:project-for-edit dstate)
local* (mf/use-state
#(do {:menu-open false
:menu-pos nil
:edition? (= (:id item) edit-id)
:dragging? false}))
local (deref local*)
project-id (get item :id)
on-click
(mf/use-fn
(mf/deps project-id)
(fn []
(st/emit! (dcm/go-to-dashboard-files :project-id project-id))))
on-key-down
(mf/use-fn
(mf/deps project-id)
(fn [event]
(when (kbd/enter? event)
(st/emit!
(dcm/go-to-dashboard-files :project-id project-id)
(ts/schedule-on-idle
(fn []
(when-let [title (dom/get-element (str project-id))]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1"))))))))
on-menu-click
(mf/use-fn
(fn [event]
(let [position (dom/get-client-position event)]
(dom/prevent-default event)
(swap! local* assoc
:menu-open true
:menu-pos position))))
on-menu-close
(mf/use-fn #(swap! local* assoc :menu-open false))
on-edit-open
(mf/use-fn #(swap! local* assoc :edition? true))
on-edit
(mf/use-fn
(mf/deps item)
(fn [name]
(when-not (str/blank? name)
(st/emit! (-> (dd/rename-project (assoc item :name name))
(with-meta {::ev/origin "dashboard:sidebar"}))))
(swap! local* assoc :edition? false)))
on-drag-enter
(mf/use-fn
(mf/deps selected-project)
(fn [e]
(when (dnd/has-type? e "penpot/files")
(dom/prevent-default e)
(when-not (dnd/from-child? e)
(when (not= selected-project (:id item))
(swap! local* assoc :dragging? true))))))
on-drag-over
(mf/use-fn
(fn [e]
(when (dnd/has-type? e "penpot/files")
(dom/prevent-default e))))
on-drag-leave
(mf/use-fn
(fn [e]
(when-not (dnd/from-child? e)
(swap! local* assoc :dragging? false))))
on-drop-success
(mf/use-fn
(mf/deps project-id)
(fn [_]
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)
(ntf/success (tr "dashboard.success-move-file")))))
on-drop
(mf/use-fn
(mf/deps item selected-files)
(fn [_]
(swap! local* assoc :dragging? false)
(when (not= selected-project (:id item))
(let [data {:ids selected-files
:project-id (:id item)}
mdata {:on-success on-drop-success}]
(st/emit! (dd/move-files (with-meta data mdata)))))))]
[:*
[:li {:tab-index "0"
:class (stl/css-case :project-element true
:sidebar-nav-item true
:current is-selected
:dragging (:dragging? local))
:on-click on-click
:on-key-down on-key-down
:on-double-click on-edit-open
:on-context-menu on-menu-click
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
:on-drop on-drop}
(if (:edition? local)
[:& inline-edition {:content (:name item)
:on-end on-edit}]
[:span {:class (stl/css :element-title)} (:name item)])]
[:> project-menu* {:project item
:show (:menu-open local)
:left (:x (:menu-pos local))
:top (:y (:menu-pos local))
:on-edit on-edit-open
:on-close on-menu-close}]]))
(mf/defc sidebar-search*
{::mf/private true}
[{:keys [search-term team-id]}]
(let [search-term (d/nilv search-term "")
focused? (mf/use-state false)
emit! (mf/use-memo #(f/debounce st/emit! 500))
on-search-blur
(mf/use-fn
(fn [_]
(reset! focused? false)))
on-search-change
(mf/use-fn
(fn [event]
(let [value (dom/get-target-val event)]
(emit! (dcm/go-to-dashboard-search :term value)))))
on-clear-click
(mf/use-fn
(mf/deps team-id)
(fn [e]
(emit! (dcm/go-to-dashboard-search))
(let [search-input (dom/get-element "search-input")]
(dom/clean-value! search-input)
(dom/focus! search-input)
(dom/prevent-default e)
(dom/stop-propagation e))))
on-key-press
(mf/use-fn
(fn [e]
(when (kbd/enter? e)
(ts/schedule-on-idle
(fn []
(let [search-title (dom/get-element (str "dashboard-search-title"))]
(when search-title
(dom/set-attribute! search-title "tabindex" "0")
(dom/focus! search-title)
(dom/set-attribute! search-title "tabindex" "-1")))))
(dom/prevent-default e)
(dom/stop-propagation e))))
handle-clear-search
(mf/use-fn
(mf/deps on-clear-click)
(fn [event]
(when (kbd/enter? event)
(on-clear-click event))))]
[:form {:class (stl/css :sidebar-search)}
[:input {:class (stl/css :input-text)
:key "images-search-box"
:id "search-input"
:type "text"
:aria-label (tr "dashboard.search-placeholder")
:placeholder (tr "dashboard.search-placeholder")
:default-value search-term
:auto-complete "off"
;; :on-focus on-search-focus
:on-blur on-search-blur
:on-change on-search-change
:on-key-press on-key-press
:ref #(when % (set! (.-value %) search-term))}]
(if (or @focused? (seq search-term))
[:button {:class (stl/css :search-btn :clear-search-btn)
:tab-index "0"
:aria-label "dashboard-clear-search"
:on-click on-clear-click
:on-key-down handle-clear-search}
clear-search-icon]
[:button {:class (stl/css :search-btn)
:aria-label "dashboard-search"
:on-click on-clear-click}
search-icon])]))
(mf/defc teams-selector-dropdown*
{::mf/private true}
[{:keys [team profile teams] :rest props}]
(let [on-create-click
(mf/use-fn #(st/emit! (modal/show :team-form {})))
on-team-click
(mf/use-fn
(fn [event]
(let [team-id (-> (dom/get-current-target event)
(dom/get-data "value")
(uuid/parse))]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:default-team-id profile)
:class (stl/css :team-dropdown-item)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")]
(when (= (:default-team-id profile) (:id team))
tick-icon)]
(for [team-item (remove :is-default (vals teams))]
[:> dropdown-menu-item* {:on-click on-team-click
:data-value (:id team-item)
:class (stl/css :team-dropdown-item)
:key (str (:id team-item))}
[:img {:src (cf/resolve-team-photo-url team-item)
:class (stl/css :team-picture)
:alt (:name team-item)}]
(if (and (contains? cf/flags :subscriptions)
(#{"unlimited" "enterprise"} (get-subscription-type (:subscription team-item))))
[:div {:class (stl/css :team-text-with-icon)}
[:span {:class (stl/css :team-text) :title (:name team-item)} (:name team-item)]
[:> menu-team-icon* {:subscription-type (get-subscription-type (:subscription team-item))}]]
[:span {:class (stl/css :team-text)
:title (:name team-item)} (:name team-item)])
(when (= (:id team-item) (:id team))
tick-icon)])
[:hr {:role "separator" :class (stl/css :team-separator)}]
[:> dropdown-menu-item* {:on-click on-create-click
:class (stl/css :team-dropdown-item :action)}
[:span {:class (stl/css :icon-wrapper)} add-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.create-new-team")]]]))
(mf/defc team-options-dropdown*
{::mf/private true}
[{:keys [team profile] :rest props}]
(let [go-members #(st/emit! (dcm/go-to-dashboard-members))
go-invitations #(st/emit! (dcm/go-to-dashboard-invitations))
go-webhooks #(st/emit! (dcm/go-to-dashboard-webhooks))
go-settings #(st/emit! (dcm/go-to-dashboard-settings))
members (get team :members)
permissions (get team :permissions)
can-rename? (or (:is-owner permissions)
(:is-admin permissions))
on-success
(fn []
;; FIXME: this should be handled in the event, not here
(let [team-id (:default-team-id profile)]
(rx/of (dcm/go-to-dashboard-recent :team-id team-id)
(modal/hide))))
on-error
(fn [{:keys [code] :as error}]
(condp = code
:no-enough-members-for-leave
(rx/of (ntf/error (tr "errors.team-leave.insufficient-members")))
:member-does-not-exist
(rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists")))
:owner-cant-leave-team
(rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave")))
(rx/throw error)))
leave-fn
(mf/use-fn
(mf/deps on-success on-error)
(fn [member-id]
(let [params (cond-> {} (uuid? member-id) (assoc :reassign-to member-id))]
(st/emit! (dtm/leave-current-team (with-meta params
{:on-success on-success
:on-error on-error}))))))
delete-fn
(mf/use-fn
(mf/deps team on-success on-error)
(fn []
(st/emit! (dtm/delete-team (with-meta team {:on-success on-success
:on-error on-error})))))
on-rename-clicked
(mf/use-fn
(mf/deps team)
(fn []
(st/emit! (modal/show :team-form {:team team}))))
on-leave-clicked
(mf/use-fn
(mf/deps leave-fn)
#(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-confirm.message")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept leave-fn})))
on-leave-as-owner-clicked
(mf/use-fn
(mf/deps team profile leave-fn)
(fn []
(st/emit! (dtm/fetch-members)
(modal/show
{:type :leave-and-reassign
:profile profile
:team team
:accept leave-fn}))))
leave-and-close
(mf/use-fn
(mf/deps team delete-fn)
#(st/emit! (modal/show
{:type :confirm
:title (tr "modals.leave-confirm.title")
:message (tr "modals.leave-and-close-confirm.message" (:name team))
:scd-message (tr "modals.leave-and-close-confirm.hint")
:accept-label (tr "modals.leave-confirm.accept")
:on-accept delete-fn})))
on-delete-clicked
(mf/use-fn
(mf/deps delete-fn)
#(st/emit!
(modal/show
{:type :confirm
:title (tr "modals.delete-team-confirm.title")
:message (tr "modals.delete-team-confirm.message")
:accept-label (tr "modals.delete-team-confirm.accept")
:on-accept delete-fn})))]
[:> dropdown-menu* props
[:> dropdown-menu-item* {:on-click go-members
:class (stl/css :team-options-item)
:data-testid "team-members"}
(tr "labels.members")]
[:> dropdown-menu-item* {:on-click go-invitations
:class (stl/css :team-options-item)
:data-testid "team-invitations"}
(tr "labels.invitations")]
(when (contains? cf/flags :webhooks)
[:> dropdown-menu-item* {:on-click go-webhooks
:class (stl/css :team-options-item)}
(tr "labels.webhooks")])
[:> dropdown-menu-item* {:on-click go-settings
:class (stl/css :team-options-item)
:data-testid "team-settings"}
(tr "labels.settings")]
[:hr {:class (stl/css :team-option-separator)}]
(when can-rename?
[:> dropdown-menu-item* {:on-click on-rename-clicked
:class (stl/css :team-options-item)
:data-testid "rename-team"}
(tr "labels.rename")])
(cond
(= (count members) 1)
[:> dropdown-menu-item* {:on-click leave-and-close
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")]
(get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-leave-as-owner-clicked
:class (stl/css :team-options-item)
:data-testid "leave-team"}
(tr "dashboard.leave-team")]
(> (count members) 1)
[:> dropdown-menu-item* {:on-click on-leave-clicked
:class (stl/css :team-options-item)}
(tr "dashboard.leave-team")])
(when (get-in team [:permissions :is-owner])
[:> dropdown-menu-item* {:on-click on-delete-clicked
:class (stl/css :team-options-item :warning)
:data-testid "delete-team"}
(tr "dashboard.delete-team")])]))
(mf/defc sidebar-team-switch*
[{:keys [team profile]}]
(let [teams (mf/deref refs/teams)
subscription
(get team :subscription)
subscription-type
(get-subscription-type subscription)
show-team-options-menu*
(mf/use-state false)
show-team-options-menu?
(deref show-team-options-menu*)
show-teams-menu*
(mf/use-state false)
show-teams-menu?
(deref show-teams-menu*)
on-show-teams-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-teams-menu* not)))
on-show-teams-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-team-options-menu
(mf/use-fn #(reset! show-team-options-menu* false))
on-show-options-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-team-options-menu* not)))
on-show-options-keydown
(mf/use-fn
(fn [event]
(when (or (kbd/space? event)
(kbd/enter? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(some-> (dom/get-current-target event)
(dom/click!)))))
close-teams-menu
(mf/use-fn #(reset! show-teams-menu* false))]
[:div {:class (stl/css :sidebar-team-switch)}
[:div {:class (stl/css :switch-content)}
[:button {:class (stl/css :current-team)
:on-click on-show-teams-click
:on-key-down on-show-teams-keydown}
(cond
(:is-default team)
[:div {:class (stl/css :team-name)}
[:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon]
[:span {:class (stl/css :team-text)} (tr "dashboard.default-team-name")]]
(and (contains? cf/flags :subscriptions)
(not (:is-default team))
(or (= "unlimited" subscription-type) (= "enterprise" subscription-type)))
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:div {:class (stl/css :team-text-with-icon)}
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]
[:> menu-team-icon* {:subscription-type subscription-type}]]]
(and (not (:is-default team))
(or (not= "unlimited" subscription-type) (not= "enterprise" subscription-type)))
[:div {:class (stl/css :team-name)}
[:img {:src (cf/resolve-team-photo-url team)
:class (stl/css :team-picture)
:alt (:name team)}]
[:span {:class (stl/css :team-text) :title (:name team)} (:name team)]])
arrow-icon]
(when-not (:is-default team)
[:button {:class (stl/css :switch-options)
:on-click on-show-options-click
:aria-label "team-management"
:tab-index "0"
:on-key-down on-show-options-keydown}
menu-icon])]
;; Teams Dropdown
[:> teams-selector-dropdown* {:show show-teams-menu?
:on-close close-teams-menu
:id "team-list"
:class (stl/css :dropdown :teams-dropdown)
:team team
:profile profile
:teams teams}]
[:> team-options-dropdown* {:show show-team-options-menu?
:on-close close-team-options-menu
:id "team-options"
:class (stl/css :dropdown :options-dropdown)
:team team
:profile profile}]]))
(mf/defc sidebar-content*
{::mf/private true}
[{:keys [projects profile section team project search-term default-project] :as props}]
(let [default-project-id
(get default-project :id)
team-id (get team :id)
projects? (= section :dashboard-recent)
fonts? (= section :dashboard-fonts)
libs? (= section :dashboard-libraries)
drafts? (and (= section :dashboard-files)
(= (:id project) default-project-id))
container (mf/use-ref nil)
overflow* (mf/use-state false)
overflow? (deref overflow*)
go-projects
(mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent)))
go-projects-with-key
(mf/use-fn
(mf/deps team-id)
(fn []
(st/emit!
(dcm/go-to-dashboard-recent :team-id team-id)
(ts/schedule-on-idle
(fn []
(when-let [projects-title (dom/get-element "dashboard-projects-title")]
(dom/set-attribute! projects-title "tabindex" "0")
(dom/focus! projects-title)
(dom/set-attribute! projects-title "tabindex" "-1")))))))
go-fonts
(mf/use-fn
(mf/deps team-id)
#(st/emit! (dcm/go-to-dashboard-fonts :team-id team-id)))
go-fonts-with-key
(mf/use-fn
(mf/deps team)
(fn []
(st/emit!
(dcm/go-to-dashboard-fonts :team-id team-id)
(ts/schedule-on-idle
(fn []
(let [font-title (dom/get-element "dashboard-fonts-title")]
(when font-title
(dom/set-attribute! font-title "tabindex" "0")
(dom/focus! font-title)
(dom/set-attribute! font-title "tabindex" "-1"))))))))
go-drafts
(mf/use-fn
(mf/deps team-id default-project-id)
(fn []
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))))
go-drafts-with-key
(mf/use-fn
(mf/deps team-id default-project-id)
(fn []
(st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id))
(ts/schedule-on-idle
(fn []
(when-let [title (dom/get-element "dashboard-drafts-title")]
(dom/set-attribute! title "tabindex" "0")
(dom/focus! title)
(dom/set-attribute! title "tabindex" "-1"))))))
go-libs
(mf/use-fn
(mf/deps team-id)
(fn [] (st/emit! (dcm/go-to-dashboard-libraries :team-id team-id))))
go-libs-with-key
(mf/use-fn
(mf/deps team-id)
(fn []
(st/emit!
(dcm/go-to-dashboard-libraries :team-id team-id)
(ts/schedule-on-idle
(fn []
(let [libs-title (dom/get-element "dashboard-libraries-title")]
(when libs-title
(dom/set-attribute! libs-title "tabindex" "0")
(dom/focus! libs-title)
(dom/set-attribute! libs-title "tabindex" "-1"))))))))
pinned-projects
(mf/with-memo [projects]
(->> projects
(remove :deleted-at)
(remove :is-default)
(filter :is-pinned)
(sort-by :name)
(not-empty)))]
(mf/with-layout-effect [pinned-projects]
(let [node (mf/ref-val container)
client-height (.-clientHeight ^js node)
scroll-height (.-scrollHeight ^js node)]
(reset! overflow* (> scroll-height client-height))))
[:*
[:div {:class (stl/css-case :sidebar-content true)
:ref container}
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term
:team-id (:id team)}]
[:div {:class (stl/css :sidebar-content-section)}
[:ul {:class (stl/css :sidebar-nav)}
[:li {:class (stl/css-case :recent-projects true
:sidebar-nav-item true
:current projects?)}
[:& link {:action go-projects
:class (stl/css :sidebar-link)
:keyboard-action go-projects-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.projects")]]]
[:li {:class (stl/css-case :current drafts?
:sidebar-nav-item true)}
[:& link {:action go-drafts
:class (stl/css :sidebar-link)
:keyboard-action go-drafts-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.drafts")]]]]]
[:div {:class (stl/css :sidebar-content-section)}
[:div {:class (stl/css :sidebar-section-title)}
(tr "labels.sources")]
[:ul {:class (stl/css :sidebar-nav)}
[:li {:class (stl/css-case :sidebar-nav-item true
:current fonts?)}
[:& link {:action go-fonts
:class (stl/css :sidebar-link)
:keyboard-action go-fonts-with-key
:data-testid "fonts"}
[:span {:class (stl/css :element-title)} (tr "labels.fonts")]]]
[:li {:class (stl/css-case :current libs?
:sidebar-nav-item true)}
[:& link {:action go-libs
:data-testid "libs-link-sidebar"
:class (stl/css :sidebar-link)
:keyboard-action go-libs-with-key}
[:span {:class (stl/css :element-title)} (tr "labels.shared-libraries")]]]]]
[:div {:class (stl/css :sidebar-content-section)
:data-testid "pinned-projects"}
[:div {:class (stl/css :sidebar-section-title)}
(tr "labels.pinned-projects")]
(if (some? pinned-projects)
[:ul {:class (stl/css :sidebar-nav :pinned-projects)}
(for [item pinned-projects]
[:> sidebar-project*
{:item item
:key (dm/str (:id item))
:id (:id item)
:team-id (:id team)
:is-selected (= (:id item) (:id project))}])]
[:div {:class (stl/css :sidebar-empty-placeholder)}
pin-icon
[:span {:class (stl/css :empty-text)} (tr "dashboard.no-projects-placeholder")]])]]
[:div {:class (stl/css-case :separator true :overflow-separator overflow?)}]]))
(mf/defc help-learning-menu*
{::mf/props :obj
::mf/private true}
[{:keys [on-close on-click]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
(let [url (-> (dom/get-current-target event)
(dom/get-data "url"))
eventname (-> (dom/get-current-target event)
(dom/get-data "eventname"))]
(st/emit! (ptk/event ::ev/event {::ev/name eventname
::ev/origin "menu:in-app"}))
(dom/open-new-window url))))
handle-feedback-click
(mf/use-fn #(on-click :settings-feedback %))]
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :help-learning)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://help.penpot.app"
:on-click handle-click-url
:data-eventname "explore-help-center-click"}
(tr "labels.help-center")]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://penpot.app/learning-center"
:on-click handle-click-url
:data-eventname "explore-learning-center-click"}
(tr "labels.learning-center")]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://penpot.app/penpothub"
:on-click handle-click-url
:data-eventname "explore-penpot-hub-click"}
(tr "labels.penpot-hub")]
(when (contains? cf/flags :user-feedback)
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click handle-feedback-click}
(tr "labels.give-feedback")])]))
(mf/defc community-contributions-menu*
{::mf/props :obj
::mf/private true}
[{:keys [on-close]}]
(let [handle-click-url
(mf/use-fn
(fn [event]
(let [url (-> (dom/get-current-target event)
(dom/get-data "url"))
eventname (-> (dom/get-current-target event)
(dom/get-data "eventname"))]
(st/emit! (ptk/event ::ev/event {::ev/name eventname
::ev/origin "menu:in-app"}))
(dom/open-new-window url))))]
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :community)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://github.com/penpot/penpot"
:on-click handle-click-url
:data-eventname "explore-github-repository-click"}
(tr "labels.github-repo")]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://community.penpot.app"
:on-click handle-click-url
:data-eventname "explore-community-click"}
(tr "labels.community")]]))
(mf/defc about-penpot-menu*
{::mf/props :obj
::mf/private true}
[{:keys [on-close]}]
(let [version cf/version
show-release-notes
(mf/use-fn
(fn [event]
(st/emit! (ptk/event ::ev/event {::ev/name "show-release-notes" :version (:main version)}))
(if (and (kbd/alt? event) (kbd/mod? event))
(st/emit! (modal/show {:type :onboarding}))
(st/emit! (modal/show {:type :release-notes :version (:main version)})))))
handle-click-url
(mf/use-fn
(fn [event]
(let [url (-> (dom/get-current-target event)
(dom/get-data "url"))
eventname (-> (dom/get-current-target event)
(dom/get-data "eventname"))]
(st/emit! (ptk/event ::ev/event {::ev/name eventname
::ev/origin "menu:in-app"}))
(dom/open-new-window url))))]
[:> dropdown-menu* {:show true
:class (stl/css :sub-menu :about)
:on-close on-close}
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click show-release-notes}
(tr "labels.version-notes" (:base version))]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://github.com/penpot/penpot/blob/develop/CHANGES.md"
:on-click handle-click-url
:data-eventname "explore-changelog-click"}
(tr "labels.penpot-changelog")]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:data-url "https://penpot.app/terms"
:on-click handle-click-url
:data-eventname "explore-terms-service-click"}
(tr "auth.terms-of-service")]]))
(mf/defc profile-section*
[{:keys [profile team]}]
(let [show-profile-menu* (mf/use-state false)
show-profile-menu? (deref show-profile-menu*)
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
version (:base cf/version)
close-sub-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! sub-menu* nil)))
photo
(cf/resolve-profile-photo-url profile)
on-click
(mf/use-fn
(fn [section event]
(dom/stop-propagation event)
(reset! show-profile-menu* false)
(if (keyword? section)
(st/emit! (rt/nav section))
(st/emit! section))))
show-comments* (mf/use-state false)
show-comments? @show-comments*
handle-hide-comments
(mf/use-fn
(fn []
(reset! show-comments* false)))
handle-show-comments
(mf/use-fn
(fn []
(reset! show-comments* true)))
handle-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! show-profile-menu* not)))
handle-key-down
(mf/use-fn
(fn [event]
(when (kbd/enter? event)
(reset! show-profile-menu* true))))
on-close
(mf/use-fn #(reset! show-profile-menu* false))
handle-logout-click
(mf/use-fn
#(on-click (da/logout) %))
handle-set-profile
(mf/use-fn
#(on-click :settings-profile %))
on-menu-click
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(let [menu (-> (dom/get-current-target event)
(dom/get-data "testid")
(keyword))]
(reset! sub-menu* menu))))
on-power-up-click
(mf/use-fn
(fn []
(st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"}))
(dom/open-new-window "https://penpot.app/pricing")))]
[:*
(when (contains? cf/flags :subscriptions)
(if (show-subscription-dashboard-banner? profile)
[:> dashboard-cta* {:profile profile}]
[:> subscription-sidebar* {:profile profile}]))
;; TODO remove this block when subscriptions is full implemented
(when (contains? cf/flags :subscriptions-old)
[:button {:class (stl/css :upgrade-plan-section)
:on-click on-power-up-click}
[:div {:class (stl/css :penpot-free)}
[:span (tr "dashboard.upgrade-plan.penpot-free")]
[:span {:class (stl/css :no-limits)}
(tr "dashboard.upgrade-plan.no-limits")]]
[:div {:class (stl/css :power-up)}
(tr "subscription.dashboard.upgrade-plan.power-up")]])
(when (and team profile)
[:& comments-section
{:profile profile
:team team
:show? show-comments?
:on-show-comments handle-show-comments
:on-hide-comments handle-hide-comments}])
[:div {:class (stl/css :profile-section)}
[:button {:class (stl/css :profile)
:tab-index "0"
:on-click handle-click
:on-key-down handle-key-down
:data-testid "profile-btn"}
[:img {:src photo
:class (stl/css :profile-img)
:alt (:fullname profile)}]
[:span {:class (stl/css :profile-fullname)} (:fullname profile)]]
[:> dropdown-menu* {:on-close on-close
:show show-profile-menu?
:id "profile-menu"
:class (stl/css :profile-dropdown)}
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item)
:on-click handle-set-profile
:data-testid "profile-profile-opt"}
(tr "labels.your-account")]
[:li {:class (stl/css :profile-separator)}]
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-testid "help-learning"
:id "help-learning"}
[:span {:class (stl/css :item-name)} (tr "labels.help-learning")]
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-testid "community-contributions"
:id "community-contributions"}
[:span {:class (stl/css :item-name)} (tr "labels.community-contributions")]
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
[:> dropdown-menu-item* {:class (stl/css-case :profile-dropdown-item true)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))
:on-pointer-enter on-menu-click
:data-testid "about-penpot"
:id "about-penpot"}
[:div {:class (stl/css :about-penpot)}
[:span {:class (stl/css :item-name)} (tr "labels.about-penpot")]
[:span {:class (stl/css :menu-version) :title version} version]]
[:> icon* {:icon-id i/arrow :class (stl/css :open-arrow)}]]
[:li {:class (stl/css :profile-separator)}]
[:> dropdown-menu-item* {:class (stl/css :profile-dropdown-item :item-with-icon)
:on-click handle-logout-click
:data-testid "logout-profile-opt"}
exit-icon
(tr "labels.logout")]]
(when (and team profile)
[:> comments-icon*
{:profile profile
:on-show-comments handle-show-comments}])]
(when show-profile-menu?
(case sub-menu
:help-learning
[:> help-learning-menu* {:on-close close-sub-menu :on-click on-click}]
:community-contributions
[:> community-contributions-menu* {:on-close close-sub-menu}]
:about-penpot
[:> about-penpot-menu* {:on-close close-sub-menu}]
nil))]))
(mf/defc sidebar*
{::mf/props :obj
::mf/wrap [mf/memo]}
[{:keys [team profile] :as props}]
[:nav {:class (stl/css :dashboard-sidebar) :data-testid "dashboard-sidebar"}
[:> sidebar-content* props]
[:> profile-section*
{:profile profile
:team team}]])