mirror of
https://github.com/penpot/penpot.git
synced 2026-05-20 07:23:42 +00:00
521 lines
17 KiB
Clojure
521 lines
17 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) UXBOX Labs SL
|
|
|
|
(ns app.main.ui.dashboard.sidebar
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.spec :as us]
|
|
[app.config :as cfg]
|
|
[app.main.data.dashboard :as dd]
|
|
[app.main.data.messages :as dm]
|
|
[app.main.data.modal :as modal]
|
|
[app.main.data.users :as du]
|
|
[app.main.refs :as refs]
|
|
[app.main.store :as st]
|
|
[app.main.ui.components.dropdown :refer [dropdown]]
|
|
[app.main.ui.components.forms :as fm]
|
|
[app.main.ui.dashboard.comments :refer [comments-section]]
|
|
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
|
[app.main.ui.dashboard.project-menu :refer [project-menu]]
|
|
[app.main.ui.dashboard.team-form]
|
|
[app.main.ui.icons :as i]
|
|
[app.util.dom :as dom]
|
|
[app.util.dom.dnd :as dnd]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.object :as obj]
|
|
[app.util.router :as rt]
|
|
[cljs.spec.alpha :as s]
|
|
[goog.functions :as f]
|
|
[rumext.alpha :as mf]))
|
|
|
|
(mf/defc sidebar-project
|
|
[{:keys [item selected?] :as props}]
|
|
(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
|
|
{:menu-open false
|
|
:menu-pos nil
|
|
:edition? (= (:id item) edit-id)
|
|
:dragging? false})
|
|
|
|
on-click
|
|
(mf/use-callback
|
|
(mf/deps item)
|
|
(fn []
|
|
(st/emit! (dd/go-to-files (:id item)))))
|
|
|
|
on-menu-click
|
|
(mf/use-callback
|
|
(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-callback #(swap! local assoc :menu-open false))
|
|
|
|
on-edit-open
|
|
(mf/use-callback #(swap! local assoc :edition? true))
|
|
|
|
on-edit
|
|
(mf/use-callback
|
|
(mf/deps item)
|
|
(fn [name]
|
|
(st/emit! (dd/rename-project (assoc item :name name)))
|
|
(swap! local assoc :edition? false)))
|
|
|
|
on-drag-enter
|
|
(mf/use-callback
|
|
(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-callback
|
|
(fn [e]
|
|
(when (dnd/has-type? e "penpot/files")
|
|
(dom/prevent-default e))))
|
|
|
|
on-drag-leave
|
|
(mf/use-callback
|
|
(fn [e]
|
|
(when-not (dnd/from-child? e)
|
|
(swap! local assoc :dragging? false))))
|
|
|
|
on-drop-success
|
|
(mf/use-callback
|
|
(mf/deps (:id item))
|
|
(st/emitf (dm/success (tr "dashboard.success-move-file"))
|
|
(dd/go-to-files (:id item))))
|
|
|
|
on-drop
|
|
(mf/use-callback
|
|
(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 {:class (if selected? "current"
|
|
(when (:dragging? @local) "dragging"))
|
|
:on-click on-click
|
|
: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.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-menu-close on-menu-close}]]))
|
|
|
|
(mf/defc sidebar-search
|
|
[{:keys [search-term team-id] :as props}]
|
|
(let [search-term (or search-term "")
|
|
focused? (mf/use-state false)
|
|
emit! (mf/use-memo #(f/debounce st/emit! 500))
|
|
|
|
on-search-focus
|
|
(mf/use-callback
|
|
(mf/deps team-id)
|
|
(fn [event]
|
|
(reset! focused? true)
|
|
(let [value (dom/get-target-val event)]
|
|
(dom/select-text! (dom/get-target event))
|
|
(emit! (dd/go-to-search value)))))
|
|
|
|
on-search-blur
|
|
(mf/use-callback
|
|
(fn [_]
|
|
(reset! focused? false)))
|
|
|
|
on-search-change
|
|
(mf/use-callback
|
|
(mf/deps team-id)
|
|
(fn [event]
|
|
(let [value (dom/get-target-val event)]
|
|
(emit! (dd/go-to-search value)))))
|
|
|
|
on-clear-click
|
|
(mf/use-callback
|
|
(mf/deps team-id)
|
|
(fn [_]
|
|
(let [search-input (dom/get-element "search-input")]
|
|
(dom/clean-value! search-input)
|
|
(dom/focus! search-input)
|
|
(emit! (dd/go-to-search)))))]
|
|
|
|
[:form.sidebar-search
|
|
[:input.input-text
|
|
{:key :images-search-box
|
|
:id "search-input"
|
|
:type "text"
|
|
: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
|
|
:ref #(when % (set! (.-value %) search-term))}]
|
|
|
|
(if (or @focused? (seq search-term))
|
|
[:div.clear-search
|
|
{:on-click on-clear-click}
|
|
i/close]
|
|
|
|
[:div.search
|
|
{:on-click on-clear-click}
|
|
i/search])]))
|
|
|
|
(mf/defc teams-selector-dropdown
|
|
[{:keys [profile] :as props}]
|
|
(let [teams (mf/deref refs/teams)
|
|
|
|
on-create-clicked
|
|
(mf/use-callback
|
|
(st/emitf (modal/show :team-form {})))
|
|
|
|
team-selected
|
|
(mf/use-callback
|
|
(fn [team-id]
|
|
(st/emit! (dd/go-to-projects team-id))))]
|
|
|
|
[:ul.dropdown.teams-dropdown
|
|
[:li.title (tr "dashboard.switch-team")]
|
|
[:hr]
|
|
[:li.team-name {:on-click (partial team-selected (:default-team-id profile))}
|
|
[:span.team-icon i/logo-icon]
|
|
[:span.team-text (tr "dashboard.your-penpot")]]
|
|
|
|
(for [team (remove :is-default (vals teams))]
|
|
[:* {:key (:id team)}
|
|
[:li.team-name {:on-click (partial team-selected (:id team))}
|
|
[:span.team-icon
|
|
[:img {:src (cfg/resolve-team-photo-url team)}]]
|
|
[:span.team-text {:title (:name team)} (:name team)]]])
|
|
|
|
[:hr]
|
|
[:li.action {:on-click on-create-clicked}
|
|
(tr "dashboard.create-new-team")]]))
|
|
|
|
(s/def ::member-id ::us/uuid)
|
|
(s/def ::leave-modal-form
|
|
(s/keys :req-un [::member-id]))
|
|
|
|
(mf/defc leave-and-reassign-modal
|
|
{::mf/register modal/components
|
|
::mf/register-as ::leave-and-reassign}
|
|
[{:keys [members profile team accept]}]
|
|
(let [form (fm/use-form :spec ::leave-modal-form :initial {})
|
|
members (some->> members (filterv #(not= (:id %) (:id profile))))
|
|
options (into [{:value ""
|
|
:label (tr "modals.leave-and-reassign.select-memeber-to-promote")}]
|
|
(map #(hash-map :label (:name %) :value (str (:id %))) members))
|
|
|
|
on-cancel (st/emitf (modal/hide))
|
|
on-accept
|
|
(fn [_]
|
|
(let [member-id (get-in @form [:clean-data :member-id])]
|
|
(accept member-id)))]
|
|
|
|
[:div.modal-overlay
|
|
[:div.modal-container.confirm-dialog
|
|
[:div.modal-header
|
|
[:div.modal-header-title
|
|
[:h2 (tr "modals.leave-and-reassign.title")]]
|
|
[:div.modal-close-button
|
|
{:on-click on-cancel} i/close]]
|
|
|
|
[:div.modal-content.generic-form
|
|
[:p (tr "modals.leave-and-reassign.hint1" (:name team))]
|
|
|
|
(if (empty? members)
|
|
[:p (tr "modals.leave-and-reassign.forbiden")]
|
|
[:*
|
|
[:p (tr "modals.leave-and-reassign.hint2")]
|
|
[:& fm/form {:form form}
|
|
[:& fm/select {:name :member-id
|
|
:options options}]]])]
|
|
|
|
[:div.modal-footer
|
|
[:div.action-buttons
|
|
[:input.cancel-button
|
|
{:type "button"
|
|
:value (tr "labels.cancel")
|
|
:on-click on-cancel}]
|
|
|
|
[:input.accept-button
|
|
{:type "button"
|
|
:class (if (:valid @form) "primary" "btn-disabled")
|
|
:disabled (not (:valid @form))
|
|
:value (tr "modals.leave-and-reassign.promote-and-leave")
|
|
:on-click on-accept}]]]]]))
|
|
|
|
(mf/defc team-options-dropdown
|
|
[{:keys [team profile] :as props}]
|
|
(let [go-members (st/emitf (dd/go-to-team-members))
|
|
go-settings (st/emitf (dd/go-to-team-settings))
|
|
|
|
members-map (mf/deref refs/dashboard-team-members)
|
|
members (vals members-map)
|
|
|
|
on-rename-clicked
|
|
(st/emitf (modal/show :team-form {:team team}))
|
|
|
|
on-leaved-success
|
|
(fn []
|
|
(st/emit! (modal/hide)
|
|
(dd/go-to-projects (:default-team-id profile))))
|
|
|
|
leave-fn
|
|
(st/emitf (dd/leave-team (with-meta {} {:on-success on-leaved-success})))
|
|
|
|
leave-and-reassign-fn
|
|
(fn [member-id]
|
|
(let [params {:reassign-to member-id}]
|
|
(st/emit! (dd/leave-team (with-meta params {:on-success on-leaved-success})))))
|
|
|
|
on-leave-clicked
|
|
(st/emitf (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
|
|
(st/emitf (modal/show
|
|
{:type ::leave-and-reassign
|
|
:profile profile
|
|
:team team
|
|
:members members
|
|
:accept leave-and-reassign-fn}))
|
|
|
|
delete-fn
|
|
(st/emitf (dd/delete-team (with-meta team {:on-success on-leaved-success})))
|
|
|
|
on-delete-clicked
|
|
(st/emitf
|
|
(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}))]
|
|
|
|
[:ul.dropdown.options-dropdown
|
|
[:li {:on-click go-members} (tr "labels.members")]
|
|
[:li {:on-click go-settings} (tr "labels.settings")]
|
|
[:hr]
|
|
[:li {:on-click on-rename-clicked} (tr "labels.rename")]
|
|
|
|
(cond
|
|
(:is-owner team)
|
|
[:li {:on-click on-leave-as-owner-clicked} (tr "dashboard.leave-team")]
|
|
|
|
(> (count members) 1)
|
|
[:li {:on-click on-leave-clicked} (tr "dashboard.leave-team")])
|
|
|
|
|
|
(when (:is-owner team)
|
|
[:li {:on-click on-delete-clicked} (tr "dashboard.delete-team")])]))
|
|
|
|
|
|
(mf/defc sidebar-team-switch
|
|
[{:keys [team profile] :as props}]
|
|
(let [show-team-opts-ddwn? (mf/use-state false)
|
|
show-teams-ddwn? (mf/use-state false)]
|
|
|
|
[:div.sidebar-team-switch
|
|
[:div.switch-content
|
|
[:div.current-team {:on-click #(reset! show-teams-ddwn? true)}
|
|
(if (:is-default team)
|
|
[:div.team-name
|
|
[:span.team-icon i/logo-icon]
|
|
[:span.team-text (tr "dashboard.default-team-name")]]
|
|
[:div.team-name
|
|
[:span.team-icon
|
|
[:img {:src (cfg/resolve-team-photo-url team)}]]
|
|
[:span.team-text {:title (:name team)} (:name team)]])
|
|
|
|
[:span.switch-icon
|
|
i/arrow-down]]
|
|
|
|
(when-not (:is-default team)
|
|
[:div.switch-options {:on-click #(reset! show-team-opts-ddwn? true)}
|
|
i/actions])]
|
|
|
|
;; Teams Dropdown
|
|
[:& dropdown {:show @show-teams-ddwn?
|
|
:on-close #(reset! show-teams-ddwn? false)}
|
|
[:& teams-selector-dropdown {:team team
|
|
:profile profile}]]
|
|
|
|
[:& dropdown {:show @show-team-opts-ddwn?
|
|
:on-close #(reset! show-team-opts-ddwn? false)}
|
|
[:& team-options-dropdown {:team team
|
|
:profile profile}]]]))
|
|
|
|
(mf/defc sidebar-content
|
|
[{:keys [projects profile section team project search-term] :as props}]
|
|
(let [default-project-id
|
|
(->> (vals projects)
|
|
(d/seek :is-default)
|
|
(:id))
|
|
|
|
projects? (= section :dashboard-projects)
|
|
fonts? (= section :dashboard-fonts)
|
|
libs? (= section :dashboard-libraries)
|
|
drafts? (and (= section :dashboard-files)
|
|
(= (:id project) default-project-id))
|
|
|
|
go-projects
|
|
(mf/use-callback
|
|
(mf/deps team)
|
|
(st/emitf (rt/nav :dashboard-projects {:team-id (:id team)})))
|
|
|
|
go-fonts
|
|
(mf/use-callback
|
|
(mf/deps team)
|
|
(st/emitf (rt/nav :dashboard-fonts {:team-id (:id team)})))
|
|
|
|
go-drafts
|
|
(mf/use-callback
|
|
(mf/deps team default-project-id)
|
|
(fn []
|
|
(st/emit! (rt/nav :dashboard-files
|
|
{:team-id (:id team)
|
|
:project-id default-project-id}))))
|
|
go-libs
|
|
(mf/use-callback
|
|
(mf/deps team)
|
|
(st/emitf (rt/nav :dashboard-libraries {:team-id (:id team)})))
|
|
|
|
pinned-projects
|
|
(->> (vals projects)
|
|
(remove :is-default)
|
|
(filter :is-pinned))]
|
|
|
|
[:div.sidebar-content
|
|
[:& sidebar-team-switch {:team team :profile profile}]
|
|
[:hr]
|
|
[:& sidebar-search {:search-term search-term
|
|
:team-id (:id team)}]
|
|
[:div.sidebar-content-section
|
|
[:ul.sidebar-nav.no-overflow
|
|
[:li.recent-projects
|
|
{:on-click go-projects
|
|
:class-name (when projects? "current")}
|
|
[:span.element-title (tr "labels.projects")]]
|
|
|
|
[:li {:on-click go-drafts
|
|
:class-name (when drafts? "current")}
|
|
[:span.element-title (tr "labels.drafts")]]
|
|
|
|
|
|
[:li {:on-click go-libs
|
|
:class-name (when libs? "current")}
|
|
[:span.element-title (tr "labels.shared-libraries")]]]]
|
|
|
|
[:hr]
|
|
|
|
[:div.sidebar-content-section
|
|
[:ul.sidebar-nav.no-overflow
|
|
[:li.recent-projects
|
|
{:on-click go-fonts
|
|
:class-name (when fonts? "current")}
|
|
[:span.element-title (tr "labels.fonts")]]]]
|
|
|
|
[:hr]
|
|
[:div.sidebar-content-section
|
|
(if (seq pinned-projects)
|
|
[:ul.sidebar-nav
|
|
(for [item pinned-projects]
|
|
[:& sidebar-project
|
|
{:item item
|
|
:key (:id item)
|
|
:id (:id item)
|
|
:team-id (:id team)
|
|
:selected? (= (:id item) (:id project))}])]
|
|
[:div.sidebar-empty-placeholder
|
|
[:span.icon i/pin]
|
|
[:span.text (tr "dashboard.no-projects-placeholder")]])]]))
|
|
|
|
|
|
(mf/defc profile-section
|
|
[{:keys [profile team] :as props}]
|
|
(let [show (mf/use-state false)
|
|
photo (cfg/resolve-profile-photo-url profile)
|
|
|
|
on-click
|
|
(mf/use-callback
|
|
(fn [section event]
|
|
(dom/stop-propagation event)
|
|
(if (keyword? section)
|
|
(st/emit! (rt/nav section))
|
|
(st/emit! section))))]
|
|
|
|
[:div.profile-section
|
|
[:div.profile {:on-click #(reset! show true)}
|
|
[:img {:src photo}]
|
|
[:span (:fullname profile)]
|
|
|
|
[:& dropdown {:on-close #(reset! show false)
|
|
:show @show}
|
|
[:ul.dropdown
|
|
[:li {:on-click (partial on-click :settings-profile)}
|
|
[:span.icon i/user]
|
|
[:span.text (tr "labels.profile")]]
|
|
[:li {:on-click (partial on-click :settings-password)}
|
|
[:span.icon i/lock]
|
|
[:span.text (tr "labels.password")]]
|
|
[:li {:on-click (partial on-click (du/logout))}
|
|
[:span.icon i/exit]
|
|
[:span.text (tr "labels.logout")]]
|
|
|
|
(when cfg/feedback-enabled
|
|
[:li.feedback {:on-click (partial on-click :settings-feedback)}
|
|
[:span.icon i/msg-info]
|
|
[:span.text (tr "labels.give-feedback")]
|
|
[:span.primary-badge "ALPHA"]])]]]
|
|
|
|
(when (and team profile)
|
|
[:& comments-section {:profile profile
|
|
:team team}])]))
|
|
|
|
(mf/defc sidebar
|
|
{::mf/wrap-props false
|
|
::mf/wrap [mf/memo]}
|
|
[props]
|
|
(let [team (obj/get props "team")
|
|
profile (obj/get props "profile")]
|
|
[:div.dashboard-sidebar
|
|
[:div.sidebar-inside
|
|
[:> sidebar-content props]
|
|
[:& profile-section
|
|
{:profile profile
|
|
:team team}]]]))
|