mirror of
https://github.com/penpot/penpot.git
synced 2025-05-05 05:55:52 +02:00
Mainly removes an inconsistent use of path params and normalize all routes to use query params for make it extensible without breaking urls.
380 lines
14 KiB
Clojure
380 lines
14 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.projects
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.geom.point :as gpt]
|
|
[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.project :as dpj]
|
|
[app.main.refs :as refs]
|
|
[app.main.store :as st]
|
|
[app.main.ui.dashboard.grid :refer [line-grid]]
|
|
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
|
|
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
|
|
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
|
|
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
|
|
[app.main.ui.hooks :as hooks]
|
|
[app.main.ui.icons :as i]
|
|
[app.util.dom :as dom]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.keyboard :as kbd]
|
|
[app.util.storage :as storage]
|
|
[app.util.time :as dt]
|
|
[cuerdas.core :as str]
|
|
[okulary.core :as l]
|
|
[potok.v2.core :as ptk]
|
|
[rumext.v2 :as mf]))
|
|
|
|
(def ^:private show-more-icon
|
|
(i/icon-xref :arrow (stl/css :show-more-icon)))
|
|
|
|
(def ^:private close-icon
|
|
(i/icon-xref :close (stl/css :close-icon)))
|
|
|
|
(def ^:private add-icon
|
|
(i/icon-xref :add (stl/css :add-icon)))
|
|
|
|
(def ^:private menu-icon
|
|
(i/icon-xref :menu (stl/css :menu-icon)))
|
|
|
|
(mf/defc header*
|
|
{::mf/wrap [mf/memo]
|
|
::mf/props :obj
|
|
::mf/private true}
|
|
[{:keys [can-edit]}]
|
|
(let [on-click (mf/use-fn #(st/emit! (dd/create-project)))]
|
|
[:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"}
|
|
[:div#dashboard-projects-title {:class (stl/css :dashboard-title)}
|
|
[:h1 (tr "dashboard.projects-title")]]
|
|
(when can-edit
|
|
[:button {:class (stl/css :btn-secondary :btn-small)
|
|
:on-click on-click
|
|
:data-testid "new-project-button"}
|
|
(tr "dashboard.new-project")])]))
|
|
|
|
(mf/defc team-hero*
|
|
{::mf/wrap [mf/memo]
|
|
::mf/props :obj}
|
|
[{:keys [team on-close]}]
|
|
(let [on-nav-members-click (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members)))
|
|
|
|
on-invite
|
|
(mf/use-fn
|
|
(mf/deps team)
|
|
(fn []
|
|
(st/emit! (modal/show {:type :invite-members
|
|
:team team
|
|
:origin :hero}))))
|
|
on-close'
|
|
(mf/use-fn
|
|
(mf/deps on-close)
|
|
(fn [event]
|
|
(dom/prevent-default event)
|
|
(on-close event)))]
|
|
|
|
[:div {:class (stl/css :team-hero)}
|
|
[:div {:class (stl/css :img-wrapper)}
|
|
[:img {:src "images/deco-team-banner.png"
|
|
:border "0"
|
|
:role "presentation"}]]
|
|
[:div {:class (stl/css :text)}
|
|
[:div {:class (stl/css :title)} (tr "dasboard.team-hero.title")]
|
|
[:div {:class (stl/css :info)}
|
|
[:span (tr "dasboard.team-hero.text")]
|
|
[:a {:on-click on-nav-members-click} (tr "dasboard.team-hero.management")]]
|
|
[:button
|
|
{:class (stl/css :btn-primary :invite)
|
|
:on-click on-invite}
|
|
(tr "onboarding.choice.team-up.invite-members")]]
|
|
|
|
[:button {:class (stl/css :close)
|
|
:on-click on-close'
|
|
:aria-label (tr "labels.close")}
|
|
close-icon]]))
|
|
|
|
(mf/defc project-item*
|
|
{::mf/props :obj
|
|
::mf/private true}
|
|
[{:keys [project is-first team files can-edit]}]
|
|
(let [locale (mf/deref i18n/locale)
|
|
|
|
project-id (:id project)
|
|
|
|
file-count (or (:count project) 0)
|
|
is-draft? (:is-default project)
|
|
empty? (and (not can-edit)
|
|
(= 0 file-count))
|
|
|
|
dstate (mf/deref refs/dashboard-local)
|
|
edit-id (:project-for-edit dstate)
|
|
|
|
local (mf/use-state {:menu-open false
|
|
:menu-pos nil
|
|
:edition (= (:id project) edit-id)})
|
|
|
|
[rowref limit]
|
|
(hooks/use-dynamic-grid-item-width)
|
|
|
|
on-nav
|
|
(mf/use-fn
|
|
(mf/deps project-id)
|
|
(fn []
|
|
(st/emit! (dcm/go-to-dashboard-files :project-id project-id))))
|
|
|
|
toggle-pin
|
|
(mf/use-fn
|
|
(mf/deps project)
|
|
(fn [event]
|
|
(dom/stop-propagation event)
|
|
(st/emit! (dd/toggle-project-pin project))))
|
|
|
|
on-menu-click
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(dom/prevent-default event)
|
|
|
|
(let [client-position (dom/get-client-position event)
|
|
position (if (and (nil? (:y client-position)) (nil? (:x client-position)))
|
|
(let [target-element (dom/get-target event)
|
|
points (dom/get-bounding-rect target-element)
|
|
y (:top points)
|
|
x (:left points)]
|
|
(gpt/point x y))
|
|
client-position)]
|
|
(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 project)
|
|
(fn [name]
|
|
(let [name (str/trim name)]
|
|
(when-not (str/empty? name)
|
|
(st/emit! (-> (dd/rename-project (assoc project :name name))
|
|
(with-meta {::ev/origin "dashboard"}))))
|
|
(swap! local assoc :edition false))))
|
|
|
|
on-file-created
|
|
(mf/use-fn
|
|
(fn [{:keys [id data]}]
|
|
(let [page-id (get-in data [:pages 0])]
|
|
(st/emit! (dcm/go-to-workspace :file-id id :page-id page-id)))))
|
|
|
|
create-file
|
|
(mf/use-fn
|
|
(mf/deps project-id on-file-created)
|
|
(fn [origin]
|
|
(let [mdata {:on-success on-file-created}
|
|
params {:project-id project-id}]
|
|
(st/emit! (-> (dd/create-file (with-meta params mdata))
|
|
(with-meta {::ev/origin origin}))))))
|
|
|
|
on-create-click
|
|
(mf/use-fn
|
|
(mf/deps create-file)
|
|
(fn [_]
|
|
(create-file "dashboard:grid-header-plus-button")))
|
|
|
|
on-import
|
|
(mf/use-fn
|
|
(mf/deps project-id)
|
|
(fn []
|
|
(st/emit! (dpj/fetch-files project-id)
|
|
(dd/fetch-recent-files)
|
|
(dd/fetch-projects)
|
|
(dd/clear-selected-files))))
|
|
|
|
handle-create-click
|
|
(mf/use-callback
|
|
(mf/deps on-create-click)
|
|
(fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-create-click event))))
|
|
|
|
handle-menu-click
|
|
(mf/use-callback
|
|
(mf/deps on-menu-click)
|
|
(fn [event]
|
|
(when (kbd/enter? event)
|
|
(dom/stop-propagation event)
|
|
(on-menu-click event))))
|
|
title-width (/ 100 limit)]
|
|
|
|
[:article {:class (stl/css-case :dashboard-project-row true :first is-first)}
|
|
[:header {:class (stl/css :project)}
|
|
[:div {:class (stl/css :project-name-wrapper)}
|
|
(if (:edition @local)
|
|
[:& inline-edition {:content (:name project)
|
|
:on-end on-edit}]
|
|
[:h2 {:on-click on-nav
|
|
:style {:max-width (str title-width "%")}
|
|
:class (stl/css :project-name)
|
|
:title (if (:is-default project)
|
|
(tr "labels.drafts")
|
|
(:name project))
|
|
:on-context-menu (when can-edit on-menu-click)}
|
|
(if (:is-default project)
|
|
(tr "labels.drafts")
|
|
(:name project))])
|
|
|
|
[:div {:class (stl/css :info-wrapper)}
|
|
|
|
;; We group these two spans under a div to avoid having extra space between them.
|
|
[:div
|
|
[:span {:class (stl/css :info)} (str (tr "labels.num-of-files" (i18n/c file-count)))]
|
|
|
|
(let [time (-> (:modified-at project)
|
|
(dt/timeago {:locale locale}))]
|
|
[:span {:class (stl/css :recent-files-row-title-info)} (str ", " time)])]
|
|
|
|
[:div {:class (stl/css-case :project-actions true
|
|
:pinned-project (:is-pinned project))}
|
|
(when-not (:is-default project)
|
|
[:> pin-button* {:class (stl/css :pin-button) :is-pinned (:is-pinned project) :on-click toggle-pin :tab-index 0}])
|
|
|
|
(when ^boolean can-edit
|
|
[:button {:class (stl/css :add-file-btn)
|
|
:on-click on-create-click
|
|
:title (tr "dashboard.new-file")
|
|
:aria-label (tr "dashboard.new-file")
|
|
:data-testid "project-new-file"
|
|
:on-key-down handle-create-click}
|
|
add-icon])
|
|
|
|
(when ^boolean can-edit
|
|
[:button {:class (stl/css :options-btn)
|
|
:on-click on-menu-click
|
|
:title (tr "dashboard.options")
|
|
:aria-label (tr "dashboard.options")
|
|
:data-testid "project-options"
|
|
:on-key-down handle-menu-click}
|
|
menu-icon])]
|
|
|
|
(when ^boolean can-edit
|
|
[:> project-menu*
|
|
{:project project
|
|
:show (:menu-open @local)
|
|
:left (+ 24 (:x (:menu-pos @local)))
|
|
:top (:y (:menu-pos @local))
|
|
:on-edit on-edit-open
|
|
:on-menu-close on-menu-close
|
|
:on-import on-import}])]]]
|
|
|
|
[:div {:class (stl/css :grid-container) :ref rowref}
|
|
(if ^boolean empty?
|
|
[:> empty-placeholder* {:title (if ^boolean is-draft?
|
|
(tr "dashboard.empty-placeholder-drafts-title")
|
|
(tr "dashboard.empty-placeholder-files-title"))
|
|
:class (stl/css :placeholder-placement)
|
|
:type 1
|
|
:subtitle (if ^boolean is-draft?
|
|
(tr "dashboard.empty-placeholder-drafts-subtitle")
|
|
(tr "dashboard.empty-placeholder-files-subtitle"))}]
|
|
|
|
[:& line-grid
|
|
{:project project
|
|
:team team
|
|
:files files
|
|
:create-fn create-file
|
|
:can-edit can-edit
|
|
:limit limit}])]
|
|
|
|
(when (and (> limit 0)
|
|
(> file-count limit))
|
|
[:button {:class (stl/css :show-more)
|
|
:on-click on-nav
|
|
:tab-index "0"
|
|
:on-key-down (fn [event]
|
|
(when (kbd/enter? event)
|
|
(on-nav)))}
|
|
[:span {:class (stl/css :placeholder-label)} (tr "dashboard.show-all-files")]
|
|
show-more-icon])]))
|
|
|
|
(def ^:private ref:recent-files
|
|
(l/derived :recent-files st/state))
|
|
|
|
(mf/defc projects-section*
|
|
{::mf/props :obj}
|
|
[{:keys [team projects profile]}]
|
|
|
|
(let [projects
|
|
(mf/with-memo [projects]
|
|
(->> projects
|
|
(sort-by :modified-at)
|
|
(reverse)))
|
|
|
|
recent-map (mf/deref ref:recent-files)
|
|
permisions (:permissions team)
|
|
|
|
can-edit (:can-edit permisions)
|
|
can-invite (or (:is-owner permisions)
|
|
(:is-admin permisions))
|
|
|
|
show-team-hero* (mf/use-state #(get storage/global ::show-team-hero true))
|
|
show-team-hero? (deref show-team-hero*)
|
|
|
|
is-my-penpot (= (:default-team-id profile) (:id team))
|
|
is-defalt-team? (:is-default team)
|
|
|
|
on-close
|
|
(mf/use-fn
|
|
(fn []
|
|
(reset! show-team-hero* false)
|
|
(st/emit! (ptk/data-event ::ev/event {::ev/name "dont-show-team-up-hero"
|
|
::ev/origin "dashboard"}))))]
|
|
|
|
(mf/with-effect [show-team-hero?]
|
|
(swap! storage/global assoc ::show-eam-hero show-team-hero?))
|
|
|
|
(mf/with-effect [team]
|
|
(let [tname (if (:is-default team)
|
|
(tr "dashboard.your-penpot")
|
|
(:name team))]
|
|
(dom/set-html-title (tr "title.dashboard.projects" tname))))
|
|
|
|
(mf/with-effect []
|
|
(st/emit! (dd/fetch-recent-files)
|
|
(dd/clear-selected-files)))
|
|
|
|
(when (seq projects)
|
|
[:*
|
|
[:> header* {:can-edit can-edit}]
|
|
[:div {:class (stl/css :projects-container)}
|
|
[:*
|
|
(when (and show-team-hero?
|
|
can-invite
|
|
(not is-defalt-team?))
|
|
[:> team-hero* {:team team :on-close on-close}])
|
|
|
|
[:div {:class (stl/css-case :dashboard-container true
|
|
:no-bg true
|
|
:dashboard-projects true
|
|
:with-team-hero (and (not is-my-penpot)
|
|
(not is-defalt-team?)
|
|
show-team-hero?
|
|
can-invite))}
|
|
(for [{:keys [id] :as project} projects]
|
|
(let [files (when recent-map
|
|
(->> (vals recent-map)
|
|
(filterv #(= id (:project-id %)))
|
|
(sort-by :modified-at #(compare %2 %1))))]
|
|
[:> project-item* {:project project
|
|
:team team
|
|
:files files
|
|
:can-edit can-edit
|
|
:is-first (= project (first projects))
|
|
:key id}]))]]]])))
|