;; 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}]))]]]])))