♻️ Refactor dashboard (add teams)

This commit is contained in:
Andrey Antukh 2020-09-25 14:51:21 +02:00 committed by Alonso Torres
parent 47d347f357
commit b3252ec2b2
52 changed files with 1842 additions and 1421 deletions

View file

@ -43,7 +43,6 @@
profile (:profile storage)
authed? (and (not (nil? profile))
(not= (:id profile) uuid/zero))]
(cond
(and (or (= path "")
(nil? match))
@ -51,7 +50,7 @@
(st/emit! (rt/nav :auth-login))
(and (nil? match) authed?)
(st/emit! (rt/nav :dashboard-team {:team-id (:default-team-id profile)}))
(st/emit! (rt/nav :dashboard-projects {:team-id (:default-team-id profile)}))
(nil? match)
(st/emit! (rt/nav :not-found))

View file

@ -6,18 +6,18 @@
(ns app.main.data.dashboard
(:require
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.core :as ptk]
[app.common.data :as d]
[app.common.pages :as cp]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.main.repo :as rp]
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.timers :as ts]
[app.common.uuid :as uuid]))
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[potok.core :as ptk]))
;; --- Specs
@ -50,188 +50,96 @@
::modified-at
::project-id]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Initialization
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare search-files)
(defn initialize-search
[team-id search-term]
(ptk/reify ::initialize-search
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:search-result nil))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(when-not (empty? search-term)
(rx/of (search-files team-id search-term)))))))
(declare fetch-files)
(declare fetch-projects)
(declare fetch-recent-files)
(declare fetch-shared-files)
(def initialize-drafts
(ptk/reify ::initialize-drafts
ptk/UpdateEvent
(update [_ state]
(let [profile (:profile state)]
(update state :dashboard-local assoc
:project-for-edit nil
:team-id (:default-team-id profile)
:project-id (:default-project-id profile))))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-files (:project-id local))
(fetch-projects (:team-id local) nil))))))
(defn initialize-recent
[team-id]
(us/verify ::us/uuid team-id)
(ptk/reify ::initialize-recent
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:project-id nil
:team-id team-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-recent-files (:team-id local)))))))
(defn initialize-project
[team-id project-id]
(us/verify ::us/uuid team-id)
(us/verify ::us/uuid project-id)
(ptk/reify ::initialize-project
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:team-id team-id
:project-id project-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-files (:project-id local)))))))
(defn initialize-libraries
[team-id]
(us/verify ::us/uuid team-id)
(ptk/reify ::initialize-libraries
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:project-for-edit nil
:project-id nil
:team-id team-id))
ptk/WatchEvent
(watch [_ state stream]
(let [local (:dashboard-local state)]
(rx/of (fetch-projects (:team-id local) nil)
(fetch-shared-files (:team-id local)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Fetching
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Fetch Team
(defn fetch-team
[{:keys [id] :as params}]
(letfn [(fetched [team state]
(update state :teams assoc id team))]
(ptk/reify ::fetch-team
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :team params)
(rx/map #(partial fetched %)))))))
;; --- Fetch Projects
(declare projects-fetched)
(defn fetch-projects
[team-id project-id]
[{:keys [team-id] :as params}]
(us/assert ::us/uuid team-id)
(us/assert (s/nilable ::us/uuid) project-id)
(ptk/reify ::fetch-projects
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :projects {:team-id team-id})
(rx/map projects-fetched)
#_(rx/catch (fn [error]
(rx/of (rt/nav' :auth-login))))))))
(letfn [(fetched [projects state]
(assoc-in state [:projects team-id] (d/index-by :id projects)))]
(ptk/reify ::fetch-projects
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :projects {:team-id team-id})
(rx/map #(partial fetched %)))))))
(defn projects-fetched
[projects]
(us/verify (s/every ::project) projects)
(ptk/reify ::projects-fetched
ptk/UpdateEvent
(update [_ state]
(assoc state :projects (d/index-by :id projects)))))
;; --- Search Files
(declare files-searched)
(s/def :internal.event.search-files/team-id ::us/uuid)
(s/def :internal.event.search-files/search-term (s/nilable ::us/string))
(s/def :internal.event/search-files
(s/keys :req-un [:internal.event.search-files/search-term
:internal.event.search-files/team-id]))
(defn search-files
[team-id search-term]
(us/assert ::us/uuid team-id)
(us/assert ::us/string search-term)
(ptk/reify ::search-files
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :search-files {:team-id team-id :search-term search-term})
(rx/map files-searched)))))
[params]
(us/assert :internal.event/search-files params)
(letfn [(fetched [result state]
(update state :dashboard-local
assoc :search-result result))]
(ptk/reify ::search-files
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local
assoc :search-result nil))
(defn files-searched
[files]
(us/verify (s/every ::file) files)
(ptk/reify ::files-searched
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:search-result files))))
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :search-files params)
(rx/map #(partial fetched %)))))))
;; --- Fetch Files
(defn fetch-files
[project-id]
[{:keys [project-id] :as params}]
(us/assert ::us/uuid project-id)
(letfn [(on-fetched [files state]
(assoc state :files (d/index-by :id files)))]
(letfn [(fetched [files state]
(update state :files assoc project-id (d/index-by :id files)))]
(ptk/reify ::fetch-files
ptk/WatchEvent
(watch [_ state stream]
(let [params {:project-id project-id}]
(->> (rp/query :files params)
(rx/map #(partial on-fetched %))))))))
(->> (rp/query :files params)
(rx/map #(partial fetched %)))))))
;; --- Fetch Shared Files
(defn fetch-shared-files
[team-id]
(letfn [(on-fetched [files state]
(let [files (d/index-by :id files)]
(assoc state :files files)))]
[{:keys [team-id] :as params}]
(us/assert ::us/uuid team-id)
(letfn [(fetched [files state]
(update state :shared-files assoc team-id (d/index-by :id files)))]
(ptk/reify ::fetch-shared-files
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :shared-files {:team-id team-id})
(rx/map #(partial on-fetched %)))))))
(rx/map #(partial fetched %)))))))
;; --- Fetch recent files
(declare recent-files-fetched)
(defn fetch-recent-files
[team-id]
[{:keys [team-id] :as params}]
(us/assert ::us/uuid team-id)
(ptk/reify ::fetch-recent-files
ptk/WatchEvent
@ -241,21 +149,16 @@
(rx/map recent-files-fetched))))))
(defn recent-files-fetched
[recent-files]
[files]
(ptk/reify ::recent-files-fetched
ptk/UpdateEvent
(update [_ state]
(let [flatten-files #(reduce (fn [acc [project-id files]]
(merge acc (d/index-by :id files)))
{}
%1)
extract-ids #(reduce (fn [acc [project-id files]]
(assoc acc project-id (map :id files)))
{}
%1)]
(assoc state
:files (flatten-files recent-files)
:recent-file-ids (extract-ids recent-files))))))
(reduce-kv (fn [state project-id files]
(-> state
(update-in [:files project-id] merge (d/index-by :id files))
(assoc-in [:recent-files project-id] (into #{} (map :id) files))))
state
(group-by :project-id files)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Modification
@ -263,30 +166,37 @@
;; --- Create Project
(declare project-created)
(def create-project
(ptk/reify ::create-project
(defn create-team
[{:keys [name] :as params}]
(us/assert string? name)
(ptk/reify ::create-team
ptk/WatchEvent
(watch [_ state stream]
(let [name (name (gensym "New Project "))
team-id (get-in state [:dashboard-local :team-id])]
(->> (rp/mutation! :create-project {:name name :team-id team-id})
(rx/map project-created))))))
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)]
(->> (rp/mutation! :create-team {:name name})
(rx/tap on-success)
(rx/catch on-error))))))
(defn project-created
[data]
(us/verify ::project data)
(ptk/reify ::project-created
ptk/UpdateEvent
(update [_ state]
(-> state
(update :projects assoc (:id data) data)
(update :dashboard-local assoc :project-for-edit (:id data))))
ptk/WatchEvent
(watch [_ state stream]
(rx/of (rt/nav :dashboard-project {:team-id (:team-id data) :project-id (:id data)})))))
(defn create-project
[{:keys [team-id] :as params}]
(us/assert ::us/uuid team-id)
(letfn [(created [project state]
(-> state
(assoc-in [:projects team-id (:id project)] project)
(assoc-in [:dashboard-local :project-for-edit] (:id project))))]
(ptk/reify ::create-project
ptk/WatchEvent
(watch [_ state stream]
(let [name (name (gensym "New Project "))
{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)]
(->> (rp/mutation! :create-project {:name name :team-id team-id})
(rx/tap on-success)
(rx/map #(partial created %))
(rx/catch on-error)))))))
(def clear-project-for-edit
(ptk/reify ::clear-project-for-edit
@ -294,15 +204,29 @@
(update [_ state]
(assoc-in state [:dashboard-local :project-for-edit] nil))))
(defn toggle-project-pin
[{:keys [id is-pinned team-id] :as params}]
(us/assert ::project params)
(ptk/reify ::toggle-project-pin
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:projects team-id id :is-pinned] (not is-pinned)))
ptk/WatchEvent
(watch [_ state stream]
(let [params (select-keys params [:id :is-pinned :team-id])]
(->> (rp/mutation :toggle-project-pin params)
(rx/ignore))))))
;; --- Rename Project
(defn rename-project
[id name]
{:pre [(uuid? id) (string? name)]}
[{:keys [id name team-id] :as params}]
(us/assert ::project params)
(ptk/reify ::rename-project
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:projects id :name] name))
(assoc-in state [:projects team-id id :name] name))
ptk/WatchEvent
(watch [_ state stream]
@ -313,12 +237,12 @@
;; --- Delete Project (by id)
(defn delete-project
[id]
(us/verify ::us/uuid id)
[{:keys [id team-id] :as params}]
(us/assert ::project params)
(ptk/reify ::delete-project
ptk/UpdateEvent
(update [_ state]
(update state :projects dissoc id))
(update-in state [:projects team-id] dissoc id))
ptk/WatchEvent
(watch [_ state s]
@ -328,16 +252,14 @@
;; --- Delete File (by id)
(defn delete-file
[id]
(us/verify ::us/uuid id)
[{:keys [id project-id] :as params}]
(us/assert ::file params)
(ptk/reify ::delete-file
ptk/UpdateEvent
(update [_ state]
(let [project-id (get-in state [:files id :project-id])
recent-project-files (get-in state [:recent-file-ids project-id] [])]
(-> state
(update :files dissoc id)
(assoc-in [:recent-file-ids project-id] (remove #(= % id) recent-project-files)))))
(-> state
(update-in [:files project-id] dissoc id)
(update-in [:recent-files project-id] (fnil disj #{}) id)))
ptk/WatchEvent
(watch [_ state s]
@ -347,16 +269,16 @@
;; --- Rename File
(defn rename-file
[id name]
{:pre [(uuid? id) (string? name)]}
[{:keys [id name project-id] :as params}]
(us/assert ::file params)
(ptk/reify ::rename-file
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:files id :name] name))
(assoc-in state [:files project-id id :name] name))
ptk/WatchEvent
(watch [_ state stream]
(let [params {:id id :name name}]
(let [params (select-keys params [:id :name])]
(->> (rp/mutation :rename-file params)
(rx/ignore))))))
@ -381,48 +303,29 @@
(declare file-created)
(defn create-file
[project-id]
[{:keys [project-id] :as params}]
(us/assert ::us/uuid project-id)
(ptk/reify ::create-file
ptk/WatchEvent
(watch [_ state stream]
(let [name (name (gensym "New File "))
params {:name name :project-id project-id}]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error identity}} (meta params)
name (name (gensym "New File "))
params (assoc params :name name)]
(->> (rp/mutation! :create-file params)
(rx/map file-created))))))
(rx/tap on-success)
(rx/map file-created)
(rx/catch on-error))))))
(defn file-created
[data]
(us/verify ::file data)
[{:keys [project-id id] :as file}]
(us/verify ::file file)
(ptk/reify ::file-created
ptk/UpdateEvent
(update [_ state]
(let [project-id (:project-id data)
file-id (:id data)
recent-project-files (get-in state [:recent-file-ids project-id] [])]
(-> state
(assoc-in [:files file-id] data)
(assoc-in [:recent-file-ids project-id] (conj recent-project-files file-id)))))
ptk/WatchEvent
(watch [_ state stream]
(let [pparams {:project-id (:project-id data)
:file-id (:id data)}
qparams {:page-id (get-in data [:data :pages 0])}]
(rx/of (rt/nav :workspace pparams qparams))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UI State Handling
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; --- Update Opts (Filtering & Ordering)
;; (defn update-opts
;; [& {:keys [order filter] :as opts}]
;; (ptk/reify ::update-opts
;; ptk/UpdateEvent
;; (update [_ state]
;; (update state :dashboard-local merge
;; (when order {:order order})
;; (when filter {:filter filter})))))
(-> state
(assoc-in [:files project-id id] file)
(update-in [:recent-files project-id] (fnil conj #{}) id)))))

View file

@ -208,7 +208,7 @@
(watch [_ state stream]
(->> (rx/zip (rp/query :file {:id file-id})
(rp/query :file-users {:id file-id})
(rp/query :project-by-id {:project-id project-id})
(rp/query :project {:id project-id})
(rp/query :file-libraries {:file-id file-id}))
(rx/first)
(rx/map (fn [bundle] (apply bundle-fetched bundle)))

View file

@ -51,6 +51,10 @@
(apply ptk/emit! store (cons event events))
nil))
(defn emitf
[& events]
#(apply ptk/emit! store events))
(def initial-state
{:session-id (uuid/next)
:profile (:profile storage)})

View file

@ -62,10 +62,10 @@
["/dashboard"
["/team/:team-id"
["/" :dashboard-team]
["/projects" :dashboard-projects]
["/search" :dashboard-search]
["/project/:project-id" :dashboard-project]
["/libraries" :dashboard-libraries]]]
["/libraries" :dashboard-libraries]
["/projects/:project-id" :dashboard-files]]]
["/workspace/:project-id/:file-id" :workspace]])
@ -74,7 +74,9 @@
(let [data (ex-data error)]
(case (:type data)
:not-found [:& not-found-page {:error data}]
[:span "Internal application errror"])))
(do
(ptk/handle-error error)
[:span "Internal application errror"]))))
(mf/defc app
{::mf/wrap [#(mf/catch % {:fallback app-error})]}
@ -105,8 +107,8 @@
])
(:dashboard-search
:dashboard-team
:dashboard-project
:dashboard-projects
:dashboard-files
:dashboard-libraries)
[:& dashboard {:route route}]

View file

@ -9,22 +9,23 @@
(ns app.main.ui.dashboard
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.common.spec :as us]
[app.main.store :as st]
[app.common.uuid :as uuid]
[app.main.data.dashboard :as dd]
[app.main.refs :as refs]
[app.main.ui.dashboard.sidebar :refer [sidebar]]
[app.main.ui.dashboard.search :refer [search-page]]
[app.main.ui.dashboard.project :refer [project-page]]
[app.main.ui.dashboard.recent-files :refer [recent-files-page]]
[app.main.store :as st]
[app.main.ui.dashboard.files :refer [files-section]]
[app.main.ui.dashboard.libraries :refer [libraries-page]]
[app.main.ui.dashboard.profile :refer [profile-section]]
[app.main.ui.dashboard.projects :refer [projects-section]]
[app.main.ui.dashboard.search :refer [search-page]]
[app.main.ui.dashboard.sidebar :refer [sidebar]]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]
[app.util.i18n :as i18n :refer [t]]))
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.alpha :as mf]))
(defn ^boolean uuid-str?
[s]
@ -44,40 +45,71 @@
(assoc :team-id (uuid team-id))
(uuid-str? project-id)
(assoc :project-id (uuid project-id))
(assoc :project-id (uuid project-id)))))
;; TODO: delete the usage of "drafts"
(defn- team-ref
[id]
(l/derived (l/in [:teams id]) st/state))
(= "drafts" project-id)
(assoc :project-id (:default-project-id profile)))))
(defn- projects-ref
[team-id]
(l/derived (l/in [:projects team-id]) st/state))
(mf/defc dashboard-content
[{:keys [team projects project section search-term] :as props}]
[:div.dashboard-content
(case section
:dashboard-projects
[:& projects-section {:team team
:projects projects}]
:dashboard-files
(when project
[:& files-section {:team team :project project}])
:dashboard-search
[:& search-page {:team team
:search-term search-term}]
:dashboard-libraries
[:& libraries-page {:team team}]
nil)])
(mf/defc dashboard
[{:keys [route] :as props}]
(let [profile (mf/deref refs/profile)
page (get-in route [:data :name])
{:keys [search-term team-id project-id] :as params} (parse-params route profile)]
(let [profile (mf/deref refs/profile)
section (get-in route [:data :name])
params (parse-params route profile)
project-id (:project-id params)
team-id (:team-id params)
search-term (:search-term params)
projects-ref (mf/use-memo (mf/deps team-id) #(projects-ref team-id))
team-ref (mf/use-memo (mf/deps team-id) #(team-ref team-id))
team (mf/deref team-ref)
projects (mf/deref projects-ref)
project (get projects project-id)]
(mf/use-effect
(mf/deps team-id)
(fn []
(st/emit! (dd/fetch-team {:id team-id})
(dd/fetch-projects {:team-id team-id}))))
[:section.dashboard-layout
[:div.main-logo
[:a {:on-click #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))}
i/logo-icon]]
[:& profile-section {:profile profile}]
[:& sidebar {:team-id team-id
:project-id project-id
:section page
[:& sidebar {:team team
:projects projects
:project project
:section section
:search-term search-term}]
[:div.dashboard-content
(case page
:dashboard-search
[:& search-page {:team-id team-id :search-term search-term}]
:dashboard-team
[:& recent-files-page {:team-id team-id}]
:dashboard-libraries
[:& libraries-page {:team-id team-id}]
:dashboard-project
[:& project-page {:team-id team-id
:project-id project-id}])]]))
(when team
[:& dashboard-content {:projects projects
:project project
:section section
:search-term search-term
:team team}])]))

View file

@ -1,58 +0,0 @@
;; 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) 2019 Andrey Antukh <niwi@niwi.nz>
(ns app.main.ui.dashboard.common
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as k]
[app.util.dom :as dom]
[app.util.i18n :as t :refer [tr]]))
;; --- Page Title
(mf/defc grid-header
[{:keys [on-change on-delete value read-only?] :as props}]
(let [edit? (mf/use-state false)
input (mf/use-ref nil)]
(letfn [(save []
(let [new-value (-> (mf/ref-val input)
(dom/get-inner-text)
(str/trim))]
(on-change new-value)
(reset! edit? false)))
(cancel []
(reset! edit? false))
(edit []
(reset! edit? true))
(on-input-keydown [e]
(cond
(k/esc? e) (cancel)
(k/enter? e)
(do
(dom/prevent-default e)
(dom/stop-propagation e)
(save))))]
[:div.dashboard-title
[:h2
(if @edit?
[:div.dashboard-title-field
[:span.edit {:content-editable true
:ref input
:on-key-down on-input-keydown
:dangerouslySetInnerHTML {"__html" value}}]
[:span.close {:on-click cancel} i/close]]
(if-not read-only?
[:span.dashboard-title-field {:on-double-click edit} value]
[:span.dashboard-title-field value]))]
(when-not read-only?
[:div.edition
(if @edit?
[:span {:on-click save} i/save]
[:span {:on-click edit} i/pencil])
[:span {:on-click on-delete} i/trash]])])))

View file

@ -0,0 +1,126 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.files
(:require
[app.main.data.dashboard :as dd]
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]
[okulary.core :as l]
[rumext.alpha :as mf]))
(mf/defc header
[{:keys [team project] :as props}]
(let [local (mf/use-state {:menu-open false
:edition false})
locale (mf/deref i18n/locale)
project-id (:id project)
team-id (:id team)
on-menu-click
(mf/use-callback #(swap! local assoc :menu-open true))
on-menu-close
(mf/use-callback #(swap! local assoc :menu-open false))
on-edit
(mf/use-callback #(swap! local assoc :edition true :menu-open false))
on-blur
(mf/use-callback
(mf/deps project)
(fn [event]
(let [name (-> event dom/get-target dom/get-value)]
#_(st/emit! (dd/rename-project (:id project) name))
(swap! local assoc :edition false))))
on-key-down
(mf/use-callback
(mf/deps project)
(fn [event]
(cond
(kbd/enter? event) (on-blur event)
(kbd/esc? event) (swap! local assoc :edition false))))
delete-fn
(mf/use-callback
(mf/deps project)
(fn [event]
(st/emit! (dd/delete-project project)
(rt/nav :dashboard-projects {:team-id (:id team)}))))
on-delete
(mf/use-callback
(mf/deps project)
(fn [] (modal/show! :confirm-dialog {:on-accept delete-fn})))
on-create-clicked
(mf/use-callback
(mf/deps project)
(fn [event]
(dom/prevent-default event)
(st/emit! (dd/create-file (:id project)))))]
[:header.dashboard-header
(if (:is-default project)
[:h1.dashboard-title (t locale "dashboard.header.draft")]
[:*
[:h1.dashboard-title (t locale "dashboard.header.project" (:name project))]
[:div.icon {:on-click on-menu-click} i/actions]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]]}]
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name project)}])])
#_[:ul.main-nav
[:li.current
[:a "PROJECTS"]]
[:li
[:a "MEMBERS"]]]
[:a.btn-secondary.btn-small {:on-click on-create-clicked}
(t locale "dashboard.new-file")]]))
(defn files-ref
[project-id]
(l/derived (l/in [:files project-id]) st/state))
(mf/defc files-section
[{:keys [project team] :as props}]
(let [files-ref (mf/use-memo (mf/deps (:id project)) #(files-ref (:id project)))
files-map (mf/deref files-ref)
files (->> (vals files-map)
(sort-by :modified-at)
(reverse))]
(mf/use-effect
(mf/deps (:id project))
(fn []
(st/emit! (dd/fetch-files {:project-id (:id project)}))))
[:*
[:& header {:team team :project project}]
[:section.dashboard-grid-container
[:& grid {:id (:id project)
:files files
:hide-new? true}]]]))

View file

@ -10,8 +10,9 @@
(ns app.main.ui.dashboard.grid
(:require
[app.common.uuid :as uuid]
[app.common.math :as mth]
[app.config :as cfg]
[app.main.data.dashboard :as dsh]
[app.main.data.dashboard :as dd]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.main.ui.components.context-menu :refer [context-menu]]
@ -62,9 +63,9 @@
(let [local (mf/use-state {:menu-open false :edition false})
locale (mf/deref i18n/locale)
delete (mf/use-callback (mf/deps id) #(st/emit! nil (dsh/delete-file id)))
add-shared (mf/use-callback (mf/deps id) #(st/emit! (dsh/set-file-shared id true)))
del-shared (mf/use-callback (mf/deps id) #(st/emit! (dsh/set-file-shared id false)))
delete (mf/use-callback (mf/deps id) #(st/emit! (dd/delete-file file)))
add-shared (mf/use-callback (mf/deps id) #(st/emit! (dd/set-file-shared id true)))
del-shared (mf/use-callback (mf/deps id) #(st/emit! (dd/set-file-shared id false)))
on-close (mf/use-callback #(swap! local assoc :menu-open false))
on-delete
@ -125,8 +126,9 @@
(mf/use-callback
(mf/deps id)
(fn [event]
(let [name (-> event dom/get-target dom/get-value)]
(st/emit! (dsh/rename-file id name))
(let [name (-> event dom/get-target dom/get-value)
file (assoc file :name name)]
(st/emit! (dd/rename-file file))
(swap! local assoc :edition false))))
on-key-down
@ -164,19 +166,23 @@
[(t locale "dashboard.grid.remove-shared") on-del-shared]
[(t locale "dashboard.grid.add-shared") on-add-shared])]}]]]))
;; --- Grid
(mf/defc empty-placeholder
[]
(let [locale (mf/deref i18n/locale)]
[:div.grid-empty-placeholder
[:div.icon i/file-html]
[:div.text (t locale "dashboard.grid.empty-files")]]))
(mf/defc grid
[{:keys [id opts files hide-new?] :as props}]
(let [locale (mf/deref i18n/locale)
click #(st/emit! (dsh/create-file id))]
click #(st/emit! (dd/create-file id))]
[:section.dashboard-grid
(cond
(pos? (count files))
(if (pos? (count files))
[:div.dashboard-grid-row
(when (not hide-new?)
[:div.grid-item.add-file {:on-click click}
[:span (t locale "ds.new-file")]])
[:span (t locale "dashboard.new-file")]])
(for [item files]
[:& grid-item
@ -184,8 +190,61 @@
:file item
:key (:id item)}])]
(zero? (count files))
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.grid.empty-files")]
[:div.grid-files-link
[:a.btn-secondary.btn-small {:on-click click} (t locale "ds.new-file")]]])]))
[:& empty-placeholder])]))
(mf/defc line-grid-row
[{:keys [locale files] :as props}]
(let [rowref (mf/use-ref)
width (mf/use-state 900)
limit (mf/use-state 1)
itemsize 290]
(mf/use-layout-effect
(mf/deps width)
(fn []
(let [node (mf/ref-val rowref)
obs (new js/ResizeObserver
(fn [entries x]
(let [data (first entries)
rect (.-contentRect ^js data)]
(reset! width (.-width ^js rect)))))
nitems (/ @width itemsize)
num (mth/floor nitems)]
(.observe ^js obs node)
(cond
(< (* itemsize (count files)) @width)
(reset! limit num)
(< nitems (+ num 0.51))
(reset! limit (dec num))
:else
(reset! limit num))
(fn []
(.disconnect ^js obs)))))
[:div.grid-row.no-wrap {:ref rowref}
(for [item (take @limit files)]
[:& grid-item
{:id (:id item)
:file item
:key (:id item)}])
(when (> (count files) @limit)
[:div.grid-item.placeholder
[:div.placeholder-icon i/arrow-down]
[:div.placeholder-label "Show all files"]])]))
(mf/defc line-grid
[{:keys [project-id opts files] :as props}]
(let [locale (mf/deref i18n/locale)
click #(st/emit! (dd/create-file project-id))]
[:section.dashboard-grid
(if (pos? (count files))
[:& line-grid-row {:files files
:locale locale}]
[:& empty-placeholder])]))

View file

@ -9,35 +9,34 @@
(ns app.main.ui.dashboard.libraries
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[app.util.dom :as dom]
[app.util.router :as rt]
[app.main.data.dashboard :as dsh]
[app.main.data.dashboard :as dd]
[app.main.store :as st]
[app.main.ui.modal :as modal]
[app.main.ui.keyboard :as kbd]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]))
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt]
[okulary.core :as l]
[rumext.alpha :as mf]))
(def files-ref
(-> (comp vals :files)
(l/derived st/state)))
(defn files-ref
[team-id]
(l/derived (l/in [:shared-files team-id]) st/state))
(mf/defc libraries-page
[{:keys [section team-id] :as props}]
(let [files (->> (mf/deref files-ref)
(sort-by :modified-at)
(reverse))]
[{:keys [team] :as props}]
(let [files-ref (mf/use-memo (mf/deps (:id team)) #(files-ref (:id team)))
files-map (mf/deref files-ref)
files (->> (vals files-map)
(sort-by :modified-at)
(reverse))]
(mf/use-effect
(mf/deps section team-id)
#(st/emit! (dsh/initialize-libraries team-id)))
(mf/deps team)
#(st/emit! (dd/fetch-shared-files {:team-id (:id team)})))
[:*
[:header.main-bar
[:h1.dashboard-title (tr "dashboard.header.libraries")]]
[:section.libraries-page
[:& grid {:files files :hide-new? true}]]]))
[:header.dashboard-header
[:h1.dashboard-title (tr "dashboard.header.libraries")]]
[:section.dashboard-grid-container
[:& grid {:files files :hide-new? true}]]]))

View file

@ -1,57 +0,0 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2015-2020 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2015-2020 Juan de la Cruz <delacruzgarciajuan@gmail.com>
(ns app.main.ui.dashboard.profile
(:require
[cuerdas.core :as str]
[rumext.alpha :as mf]
[app.main.data.auth :as da]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t]]
[app.util.router :as rt]))
;; --- Component: Profile
(mf/defc profile-section
[{:keys [profile] :as props}]
(let [show (mf/use-state false)
photo (:photo-uri profile "")
photo (if (str/empty? photo)
"/images/avatar.jpg"
photo)
locale (i18n/use-locale)
on-click
(fn [event section]
(dom/stop-propagation event)
(if (keyword? section)
(st/emit! (rt/nav section))
(st/emit! section)))]
[:div.user-zone {:on-click #(reset! show true)}
[:img {:src photo}]
[:span (:fullname profile)]
[:& dropdown {:on-close #(reset! show false)
:show @show}
[:ul.profile-menu
[:li {:on-click #(on-click % :settings-profile)}
i/user
[:span (t locale "dashboard.header.profile-menu.profile")]]
[:li {:on-click #(on-click % :settings-password)}
i/lock
[:span (t locale "dashboard.header.profile-menu.password")]]
[:li {:on-click #(on-click % da/logout)}
i/exit
[:span (t locale "dashboard.header.profile-menu.logout")]]]]]))

View file

@ -1,85 +0,0 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.project
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t]]
[app.util.dom :as dom]
[app.util.router :as rt]
[app.main.data.dashboard :as dsh]
[app.main.store :as st]
[app.main.ui.modal :as modal]
[app.main.ui.keyboard :as kbd]
[app.main.ui.components.context-menu :refer [context-menu]]
[app.main.ui.dashboard.grid :refer [grid]]))
(def projects-ref
(l/derived :projects st/state))
(def files-ref
(-> (comp vals :files)
(l/derived st/state)))
(mf/defc project-header
[{:keys [team-id project-id] :as props}]
(let [local (mf/use-state {:menu-open false
:edition false})
projects (mf/deref projects-ref)
project (get projects project-id)
locale (i18n/use-locale)
on-menu-click #(swap! local assoc :menu-open true)
on-menu-close #(swap! local assoc :menu-open false)
on-edit #(swap! local assoc :edition true :menu-open false)
on-blur #(let [name (-> % dom/get-target dom/get-value)]
(st/emit! (dsh/rename-project project-id name))
(swap! local assoc :edition false))
on-key-down #(cond
(kbd/enter? %) (on-blur %)
(kbd/esc? %) (swap! local assoc :edition false))
delete-fn #(do
(st/emit! (dsh/delete-project project-id))
(st/emit! (rt/nav :dashboard-team {:team-id team-id})))
on-delete #(modal/show! :confirm-dialog {:on-accept delete-fn})]
[:header.main-bar
(if (:is-default project)
[:h1.dashboard-title (t locale "dashboard.header.draft")]
[:*
[:h1.dashboard-title (t locale "dashboard.header.project" (:name project))]
[:div.main-bar-icon {:on-click on-menu-click} i/arrow-down]
[:& context-menu {:on-close on-menu-close
:show (:menu-open @local)
:options [[(t locale "dashboard.grid.rename") on-edit]
[(t locale "dashboard.grid.delete") on-delete]]}]
(if (:edition @local)
[:input.element-name {:type "text"
:auto-focus true
:on-key-down on-key-down
:on-blur on-blur
:default-value (:name project)}])])
[:a.btn-secondary.btn-small {:on-click #(do
(dom/prevent-default %)
(st/emit! (dsh/create-file project-id)))}
(t locale "dashboard.header.new-file")]]))
(mf/defc project-page
[{:keys [section team-id project-id] :as props}]
(let [files (->> (mf/deref files-ref)
(sort-by :modified-at)
(reverse))]
(mf/use-effect
(mf/deps section team-id project-id)
#(st/emit! (dsh/initialize-project team-id project-id)))
[:*
[:& project-header {:team-id team-id :project-id project-id}]
[:section.projects-page
[:& grid { :id project-id :files files :hide-new? true}]]]))

View file

@ -0,0 +1,138 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.projects
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.common.exceptions :as ex]
[app.main.constants :as c]
[app.main.data.dashboard :as dd]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.dashboard.grid :refer [line-grid]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]))
;; --- Component: Recent files
(mf/defc header
{::mf/wrap [mf/memo]}
[{:keys [profile locale team] :as props}]
(let [create #(st/emit! (dd/create-project {:team-id (:id team)}))]
[:header.dashboard-header
[:h1.dashboard-title "Projects"]
[:a.btn-secondary.btn-small {:on-click create}
(t locale "dashboard.header.new-project")]]))
(defn files-ref
[project-id]
(l/derived (l/in [:files project-id]) st/state))
(defn recent-ref
[project-id]
(l/derived (l/in [:recent-files project-id]) st/state))
(mf/defc project-item
[{:keys [project first? locale] :as props}]
(let [files-ref (mf/use-memo (mf/deps project) #(files-ref (:id project)))
recent-ref (mf/use-memo (mf/deps project) #(recent-ref (:id project)))
files-map (mf/deref files-ref)
recent-ids (mf/deref recent-ref)
files (->> recent-ids
(map #(get files-map %))
(sort-by :modified-at)
(reverse))
project-id (:id project)
team-id (:team-id project)
file-count (or (:count project) 0)
on-nav
(mf/use-callback
(mf/deps project)
(fn []
(st/emit! (rt/nav :dashboard-files {:team-id (:team-id project)
:project-id (:id project)}))))
toggle-pin
(mf/use-callback
(mf/deps project)
(fn []
(st/emit! (dd/toggle-project-pin project))))
on-file-created
(mf/use-callback
(mf/deps project)
(fn [data]
(let [pparams {:project-id (:project-id data)
:file-id (:id data)}
qparams {:page-id (get-in data [:data :pages 0])}]
(st/emit! (rt/nav :workspace pparams qparams)))))
create-file
(mf/use-callback
(mf/deps project)
(fn []
(let [mdata {:on-success on-file-created}
params {:project-id (:id project)}]
(st/emit! (dd/create-file (with-meta params mdata))))))]
[:div.dashboard-project-row {:class (when first? "first")}
[:div.project
(when-not (:is-default project)
[:span.pin-icon
{:class (when (:is-pinned project) "active")
:on-click toggle-pin}
i/pin])
[:h2 {:on-click on-nav} (:name project)]
[:span.info (str file-count " files")]
(when (> file-count 0)
(let [time (-> (:modified-at project)
(dt/timeago {:locale locale}))]
[:span.recent-files-row-title-info (str ", " time)]))
[:a.btn-secondary.btn-small
{:on-click create-file}
(t locale "dashboard.new-file")]]
[:& line-grid
{:project-id (:id project)
:files files}]]))
(mf/defc projects-section
[{:keys [team projects] :as props}]
(let [projects (->> (vals projects)
(sort-by :modified-at)
(reverse))
locale (mf/deref i18n/locale)]
(mf/use-effect
(mf/deps team)
(fn []
(st/emit! (dd/fetch-recent-files {:team-id (:id team)}))))
(when (seq projects)
[:*
[:& header {:locale locale
:team team}]
[:section.dashboard-grid-container
(for [project projects]
[:& project-item {:project project
:locale locale
:first? (= project (first projects))
:key (:id project)}])]])))

View file

@ -1,95 +0,0 @@
;; 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/.
;;
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.recent-files
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.common.exceptions :as ex]
[app.main.constants :as c]
[app.main.data.dashboard :as dsh]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]))
;; --- Component: Content
(def projects-ref
(l/derived :projects st/state))
(def recent-file-ids-ref
(l/derived :recent-file-ids st/state))
(def files-ref
(l/derived :files st/state))
;; --- Component: Recent files
(mf/defc recent-files-header
[{:keys [profile] :as props}]
(let [locale (i18n/use-locale)]
[:header#main-bar.main-bar
[:h1.dashboard-title "Recent"]
[:a.btn-secondary.btn-small {:on-click #(st/emit! dsh/create-project)}
(t locale "dashboard.header.new-project")]]))
(mf/defc recent-project
[{:keys [project files first? locale] :as props}]
(let [project-id (:id project)
team-id (:team-id project)
file-count (or (:file-count project) 0)]
[:div.recent-files-row
{:class-name (when first? "first")}
[:div.recent-files-row-title
[:h2.recent-files-row-title-name {:on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id
:project-id project-id}))
:style {:cursor "pointer"}} (:name project)]
[:span.recent-files-row-title-info (str file-count " files")]
(when (> file-count 0)
(let [time (-> (:modified-at project)
(dt/timeago {:locale locale}))]
[:span.recent-files-row-title-info (str ", " time)]))]
[:& grid {:id (:id project)
:files files
:hide-new? true}]]))
(mf/defc recent-files-page
[{:keys [team-id] :as props}]
(let [projects (->> (mf/deref projects-ref)
(vals)
(sort-by :modified-at)
(reverse))
files (mf/deref files-ref)
recent-file-ids (mf/deref recent-file-ids-ref)
locale (i18n/use-locale)
setup #(st/emit! (dsh/initialize-recent team-id))]
(-> (mf/deps team-id)
(mf/use-effect #(st/emit! (dsh/initialize-recent team-id))))
(when (and projects recent-file-ids)
[:*
[:& recent-files-header]
[:section.recent-files-page
(for [project projects]
[:& recent-project {:project project
:locale locale
:key (:id project)
:files (->> (get recent-file-ids (:id project))
(map #(get files %))
(filter identity)) ;; avoid failure if a "project only" files list is in global state
:first? (= project (first projects))}])]])))

View file

@ -5,47 +5,50 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2015-2017 Juan de la Cruz <delacruzgarciajuan@gmail.com>
;; Copyright (c) 2015-2020 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.search
(:require
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.data.dashboard :as dd]
[app.main.store :as st]
[app.main.data.dashboard :as dsh]
[app.main.ui.dashboard.grid :refer [grid]]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [t]]
[app.main.ui.dashboard.grid :refer [grid]]))
[okulary.core :as l]
[rumext.alpha :as mf]))
;; --- Component: Search
(def search-result-ref
(-> #(get-in % [:dashboard-local :search-result])
(l/derived st/state)))
(def result-ref
(l/derived (l/in [:dashboard-local :search-result]) st/state))
(mf/defc search-page
[{:keys [team-id search-term] :as props}]
(let [search-result (mf/deref search-result-ref)
locale (i18n/use-locale)]
[{:keys [team search-term] :as props}]
(let [result (mf/deref result-ref)
locale (mf/deref i18n/locale)]
(mf/use-effect
(mf/deps search-term)
#(st/emit! (dsh/initialize-search team-id search-term)))
(mf/deps team search-term)
(st/emitf (dd/search-files {:team-id (:id team)
:search-term search-term})))
[:section.search-page
[:section.dashboard-grid
(cond
(empty? search-term)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.type-something")]]
[:section.dashboard-grid-container.search
(cond
(empty? search-term)
[:div.grid-empty-placeholder
[:div.icon i/search]
[:div.text (t locale "dashboard.search.type-something")]]
(nil? search-result)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.searching-for" search-term)]]
(nil? result)
[:div.grid-empty-placeholder
[:div.icon i/search]
[:div.text (t locale "dashboard.search.searching-for" search-term)]]
(empty? search-result)
[:div.grid-files-empty
[:div.grid-files-desc (t locale "dashboard.search.no-matches-for" search-term)]]
:else
[:& grid { :files search-result :hide-new? true}])]]))
(empty? result)
[:div.grid-empty-placeholder
[:div.icon i/search]
[:div.text (t locale "dashboard.search.no-matches-for" search-term)]]
:else
[:& grid {:files result
:hide-new? true}])]))

View file

@ -5,195 +5,389 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0.
;;
;; Copyright (c) 2020 Andrey Antukh <niwi@niwi.nz>
;; Copyright (c) 2020 Juan de la Cruz <delacruzgarciajuan@gmail.com>
;; Copyright (c) 2020 UXBOX Labs SL
(ns app.main.ui.dashboard.sidebar
(:require
[app.common.data :as d]
[app.common.spec :as us]
[app.main.constants :as c]
[app.main.data.auth :as da]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.forms :refer [input submit-button form]]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.ui.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[goog.functions :as f]
[okulary.core :as l]
[rumext.alpha :as mf]
[app.main.constants :as c]
[app.main.data.dashboard :as dsh]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.dashboard.common :as common]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.router :as rt]
[app.util.time :as dt]))
[rumext.alpha :as mf]))
;; --- Component: Sidebar
(mf/defc sidebar-project-edition
[{:keys [item on-end] :as props}]
(let [name (mf/use-state (:name item))
input-ref (mf/use-ref)
(mf/defc sidebar-project
[{:keys [id name selected? team-id] :as props}]
(let [dashboard-local @refs/dashboard-local
project-for-edit (:project-for-edit dashboard-local)
local (mf/use-state {:name name
:editing (= id project-for-edit)})
editable? (not (nil? id))
edit-input-ref (mf/use-ref)
on-input
(mf/use-callback
(fn [event]
(->> event
(dom/get-target)
(dom/get-value)
(reset! name))))
on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id :project-id id}))
on-dbl-click #(when editable? (swap! local assoc :editing true))
on-input #(as-> % $
(dom/get-target $)
(dom/get-value $)
(swap! local assoc :name $))
on-cancel #(do
(st/emit! dsh/clear-project-for-edit)
(swap! local assoc :editing false :name name))
on-keyup #(cond
(kbd/esc? %)
(on-cancel)
on-cancel
(mf/use-callback
(fn []
(st/emit! dd/clear-project-for-edit)
(on-end)))
(kbd/enter? %)
(let [name (-> % dom/get-target dom/get-value)]
(st/emit! dsh/clear-project-for-edit)
(st/emit! (dsh/rename-project id name))
(swap! local assoc :editing false)))]
on-keyup
(mf/use-callback
(fn [event]
(cond
(kbd/esc? event)
(on-cancel)
(kbd/enter? event)
(let [name (-> event
dom/get-target
dom/get-value)]
(st/emit! dd/clear-project-for-edit
(dd/rename-project (assoc item :name name)))
(on-end)))))]
(mf/use-effect
(mf/deps (:editing @local))
#(when (:editing @local)
(let [edit-input (mf/ref-val edit-input-ref)]
(dom/focus! edit-input)
(dom/select-text! edit-input))
nil))
(fn []
(let [node (mf/ref-val input-ref)]
(dom/focus! node)
(dom/select-text! node))))
[:div.edit-wrapper
[:input.element-title {:value @name
:ref input-ref
:on-change on-input
:on-key-down on-keyup}]
[:span.close {:on-click on-cancel} i/close]]))
(mf/defc sidebar-project
[{:keys [item selected?] :as props}]
(let [dstate (mf/deref refs/dashboard-local)
edit-id (:project-for-edit dstate)
edition? (mf/use-state (= (:id item) edit-id))
on-click
(mf/use-callback
(mf/deps item)
(fn []
(st/emit! (rt/nav :dashboard-files {:team-id (:team-id item)
:project-id (:id item)}))))
on-dbl-click
(mf/use-callback #(reset! edition? true))]
[:li {:on-click on-click
:on-double-click on-dbl-click
:class-name (when selected? "current")}
(if (:editing @local)
[:div.edit-wrapper
[:input.element-title {:value (:name @local)
:ref edit-input-ref
:on-change on-input
:on-key-down on-keyup}]
[:span.close {:on-click on-cancel} i/close]]
[:*
i/folder
[:span.element-title name]])]))
(def projects-iref
(l/derived :projects st/state))
(mf/defc sidebar-projects
[{:keys [team-id selected-project-id] :as props}]
(let [projects (->> (mf/deref projects-iref)
(vals)
(remove #(:is-default %))
(sort-by :created-at))]
(for [item projects]
[:& sidebar-project
{:id (:id item)
:key (:id item)
:name (:name item)
:selected? (= (:id item) selected-project-id)
:team-id team-id
}])))
(mf/defc sidebar-team
[{:keys [profile
team-id
selected-section
selected-project-id
selected-team-id] :as props}]
(let [home? (and (= selected-section :dashboard-team)
(= selected-team-id (:default-team-id profile)))
drafts? (and (= selected-section :dashboard-project)
(= selected-team-id (:default-team-id profile))
(= selected-project-id (:default-project-id profile)))
libraries? (= selected-section :dashboard-libraries)
;; library? (and (str/starts-with? (name selected-section) "dashboard-library")
;; (= selected-team-id (:default-team-id profile)))
locale (i18n/use-locale)]
[:div.sidebar-team
[:ul.dashboard-elements.dashboard-common
[:li.recent-projects
{:on-click #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
:class-name (when home? "current")}
i/recent
[:span.element-title (t locale "dashboard.sidebar.recent")]]
[:li
{:on-click #(st/emit! (rt/nav :dashboard-project {:team-id team-id
:project-id "drafts"}))
:class-name (when drafts? "current")}
i/file-html
[:span.element-title (t locale "dashboard.sidebar.drafts")]]
[:li
{:on-click #(st/emit! (rt/nav :dashboard-libraries {:team-id team-id}))
:class-name (when libraries? "current")}
i/library
[:span.element-title (t locale "dashboard.sidebar.libraries")]]]
[:div.projects-row
[:span "PROJECTS"]
[:a.btn-icon-light.btn-small {:on-click #(st/emit! dsh/create-project)}
i/close]]
[:ul.dashboard-elements
[:& sidebar-projects
{:selected-team-id selected-team-id
:selected-project-id selected-project-id
:team-id team-id}]]]
))
:class (when selected? "current")}
(if @edition?
[:& sidebar-project-edition {:item item
:on-end #(reset! edition? false)}]
[:span.element-title (:name item)])]))
(def debounced-emit! (f/debounce st/emit! 500))
(mf/defc sidebar-search
[{:keys [search-term team-id locale] :as props}]
(let [search-term (or search-term "")
(mf/defc sidebar
[{:keys [section team-id project-id search-term] :as props}]
(let [locale (i18n/use-locale)
profile (mf/deref refs/profile)
search-term-not-nil (or search-term "")
emit! (mf/use-memo #(f/debounce st/emit! 500))
on-search-focus
(fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
(dom/select-text! target)
(if (empty? value)
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {}))
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value})))))
(mf/use-callback
(mf/deps team-id)
(fn [event]
(let [target (dom/get-target event)
value (dom/get-value target)]
(dom/select-text! target)
(if (empty? value)
(emit! (rt/nav :dashboard-search {:team-id team-id} {}))
(emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value}))))))
on-search-change
(fn [event]
(let [value (-> (dom/get-target event)
(dom/get-value))]
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value}))))
(mf/use-callback
(mf/deps team-id)
(fn [event]
(let [value (-> (dom/get-target event)
(dom/get-value))]
(emit! (rt/nav :dashboard-search {:team-id team-id} {:search-term value})))))
on-clear-click
(fn [event]
(let [search-input (dom/get-element "search-input")]
(dom/clean-value! search-input)
(dom/focus! search-input)
(debounced-emit! (rt/nav :dashboard-search {:team-id team-id} {}))))]
(mf/use-callback
(mf/deps team-id)
(fn [event]
(let [search-input (dom/get-element "search-input")]
(dom/clean-value! search-input)
(dom/focus! search-input)
(emit! (rt/nav :dashboard-search {:team-id team-id} {})))))]
[:form.sidebar-search
[:input.input-text
{:key :images-search-box
:id "search-input"
:type "text"
:placeholder (t locale "ds.search.placeholder")
:default-value search-term
:auto-complete "off"
:on-focus on-search-focus
:on-change on-search-change
:ref #(when % (set! (.-value %) search-term))}]
[:div.clear-search
{:on-click on-clear-click}
i/close]]))
(mf/defc sidebar-team-switch
[{:keys [team profile] :as props}]
(let [show-dropdown? (mf/use-state false)
show-team-opts-ddwn? (mf/use-state false)
show-teams-ddwn? (mf/use-state false)
teams (mf/use-state [])
on-nav
(mf/use-callback #(st/emit! (rt/nav :dashboard-projects {:team-id %})))
on-create-clicked
(mf/use-callback #(modal/show! :team-form {}))]
(mf/use-effect
(mf/deps (:id teams))
(fn []
(->> (rp/query! :teams)
(rx/subs #(reset! teams %)))))
[:div.sidebar-team-switch
[:div.switch-content
[:div.current-team
[:div.team-name
[:span.team-icon i/logo-icon]
(if (:is-default team)
[:span.team-text "Your penpot"]
[:span.team-text (:name team)])]
[:span.switch-icon {:on-click #(reset! show-teams-ddwn? true)}
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)}
[:ul.dropdown.teams-dropdown
[:li.title "Switch Team"]
[:hr]
[:li.team-item {:on-click (partial on-nav (:default-team-id profile))}
[:span.icon i/logo-icon]
[:span.text "Your penpot"]]
(for [team (remove :is-default @teams)]
[:* {:key (:id team)}
[:hr]
[:li.team-item {:on-click (partial on-nav (:id team))}
[:span.icon i/logo-icon]
[:span.text (:name team)]]])
[:hr]
[:li.action {:on-click on-create-clicked}
"+ Create new team"]]]
[:& dropdown {:show @show-team-opts-ddwn?
:on-close #(reset! show-team-opts-ddwn? false)}
[:ul.dropdown.options-dropdown
[:li "Members"]
[:li "Settings"]
[:hr]
[:li "Rename"]
[:li "Leave team"]
[:li "Delete team"]]]
]))
(s/def ::name ::us/not-empty-string)
(s/def ::team-form
(s/keys :req-un [::name]))
(mf/defc team-form-modal
{::mf/register modal/components
::mf/register-as :team-form}
[props]
(let [locale (mf/deref i18n/locale)
on-success
(mf/use-callback
(fn [form response]
(modal/hide!)
(let [msg "Team created successfuly"]
(st/emit!
(dm/success msg)
(rt/nav :dashboard-projects {:team-id (:id response)})))))
on-error
(mf/use-callback
(fn [form response]
(let [msg "Error on creating team."]
(st/emit! (dm/error msg)))))
on-submit
(mf/use-callback
(fn [form]
(let [mdata {:on-success (partial on-success form)
:on-error (partial on-error form)}
params {:name (get-in form [:clean-data :name])}]
(st/emit! (dd/create-team (with-meta params mdata))))))]
[:div.modal-overlay
[:div.generic-modal.team-form-modal
[:span.close {:on-click #(modal/hide!)} i/close]
[:section.modal-content.generic-form
[:h2 "CREATE NEW TEAM"]
[:& form {:on-submit on-submit
:spec ::team-form
:initial {}}
[:& input {:type "text"
:name :name
:label "Enter new team name:"}]
[:div.buttons-row
[:& submit-button
{:label "Create team"}]]]]]]))
(mf/defc sidebar-content
[{:keys [locale projects profile section team project search-term] :as props}]
(let [default-project-id
(->> (vals projects)
(d/seek :is-default)
(:id))
team-id (:id team)
projects? (= section :dashboard-projects)
libs? (= section :dashboard-libraries)
drafts? (and (= section :dashboard-files)
(= (:id project) default-project-id))
go-projects #(st/emit! (rt/nav :dashboard-projects {:team-id (:id team)}))
go-default #(st/emit! (rt/nav :dashboard-files {:team-id (:id team) :project-id default-project-id}))
go-libs #(st/emit! (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)
:locale locale}]
[:div.sidebar-content-section
[:ul.sidebar-nav.no-overflow
[:li.recent-projects
{:on-click go-projects
:class-name (when projects? "current")}
i/recent
[:span.element-title (t locale "dashboard.sidebar.projects")]]
[:li {:on-click go-default
:class-name (when drafts? "current")}
i/file-html
[:span.element-title (t locale "dashboard.sidebar.drafts")]]
[:li {:on-click go-libs
:class-name (when libs? "current")}
i/library
[:span.element-title (t locale "dashboard.sidebar.libraries")]]]]
[:hr]
[:div.sidebar-content-section
(if (seq pinned-projects)
[:ul.sidebar-nav
(for [item pinned-projects]
[:& sidebar-project
{:item item
:id (:id item)
:selected? (= (:id item) (:id project))}])]
[:div.sidebar-empty-placeholder
[:span.icon i/pin]
[:span.text "Pinned projects will appear here"]])]]))
(mf/defc profile-section
[{:keys [profile locale] :as props}]
(let [show (mf/use-state false)
photo (:photo-uri profile "")
photo (if (str/empty? photo)
"/images/avatar.jpg"
photo)
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 {: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 (t locale "dashboard.header.profile-menu.profile")]]
[:hr]
[:li {:on-click (partial on-click :settings-password)}
[:span.icon i/lock]
[:span.text (t locale "dashboard.header.profile-menu.password")]]
[:hr]
[:li {:on-click (partial on-click da/logout)}
[:span.icon i/exit]
[:span.text (t locale "dashboard.header.profile-menu.logout")]]]]]))
(mf/defc sidebar
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[props]
(let [locale (mf/deref i18n/locale)
profile (mf/deref refs/profile)
props (-> (obj/clone props)
(obj/set! "locale" locale)
(obj/set! "profile" profile))]
[:div.dashboard-sidebar
[:div.dashboard-sidebar-inside
[:form.dashboard-search
[:input.input-text
{:key :images-search-box
:id "search-input"
:type "text"
:placeholder (t locale "ds.search.placeholder")
:default-value search-term-not-nil
:auto-complete "off"
:on-focus on-search-focus
:on-change on-search-change
:ref #(when % (set! (.-value %) search-term-not-nil))}]
[:div.clear-search
{:on-click on-clear-click}
i/close]]
[:& sidebar-team {:selected-team-id team-id
:selected-project-id project-id
:selected-section section
:profile profile
:team-id (:default-team-id profile)}]]]))
[:div.sidebar-inside
[:> sidebar-content props]
[:& profile-section {:profile profile
:locale locale}]]]))

View file

@ -16,7 +16,6 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.util.router :as rt]
[app.main.ui.dashboard.profile :refer [profile-section]]
[app.main.ui.settings.header :refer [header]]
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.options :refer [options-page]]

View file

@ -215,7 +215,7 @@
(mf/defc header
[{:keys [file layout project page-id] :as props}]
(let [team-id (:team-id project)
go-back #(st/emit! (rt/nav :dashboard-team {:team-id team-id}))
go-back #(st/emit! (rt/nav :dashboard-projects {:team-id team-id}))
zoom (mf/deref refs/selected-zoom)
locale (mf/deref i18n/locale)
router (mf/deref refs/router)

View file

@ -9,7 +9,7 @@
(ns app.util.object
"A collection of helpers for work with javascript objects."
(:refer-clojure :exclude [set! get get-in assoc!])
(:refer-clojure :exclude [set! get get-in merge clone])
(:require
[cuerdas.core :as str]
[goog.object :as gobj]
@ -44,12 +44,22 @@
:else (throw (js/Error. "unexpected input")))]
(omit obj keys)))
(defn clone
[a]
(js/Object.assign #js {} a))
(defn merge!
([a b]
(js/Object.assign a b))
([a b & more]
(reduce merge! (merge! a b) more)))
(defn merge
([a b]
(js/Object.assign #js {} a b))
([a b & more]
(reduce merge! (merge a b) more)))
(defn set!
[obj key value]
(unchecked-set obj key value)