diff --git a/CHANGES.md b/CHANGES.md
index b2d836bb83..548b9c0ab3 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,8 @@
### :sparkles: New features
+- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590)
+
### :bug: Bugs fixed
## 2.3.0
diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index 5226e5152f..0f24a6d776 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -412,7 +412,10 @@
:fn (mg/resource "app/migrations/sql/0129-mod-file-change-table.sql")}
{:name "0130-mod-file-change-table"
- :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}])
+ :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}
+
+ {:name "0131-mod-webhook-table"
+ :fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}])
(defn apply-migrations!
[pool name migrations]
diff --git a/backend/src/app/migrations/sql/0131-mod-webhook-table.sql b/backend/src/app/migrations/sql/0131-mod-webhook-table.sql
new file mode 100644
index 0000000000..67995d60d4
--- /dev/null
+++ b/backend/src/app/migrations/sql/0131-mod-webhook-table.sql
@@ -0,0 +1,6 @@
+ALTER TABLE webhook
+ ADD COLUMN profile_id uuid NULL REFERENCES profile (id) ON DELETE SET NULL;
+
+CREATE INDEX webhook__profile_id__idx
+ ON webhook (profile_id)
+ WHERE profile_id IS NOT NULL;
\ No newline at end of file
diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj
index 6b2b69c900..144a1eddab 100644
--- a/backend/src/app/rpc/commands/binfile.clj
+++ b/backend/src/app/rpc/commands/binfile.clj
@@ -89,7 +89,7 @@
::sse/stream? true
::sm/params schema:import-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name project-id file] :as params}]
- (projects/check-read-permissions! pool profile-id project-id)
+ (projects/check-edition-permissions! pool profile-id project-id)
(let [cfg (-> cfg
(assoc ::bf.v1/project-id project-id)
(assoc ::bf.v1/profile-id profile-id)
diff --git a/backend/src/app/rpc/commands/projects.clj b/backend/src/app/rpc/commands/projects.clj
index 1b83102320..f3532f27fa 100644
--- a/backend/src/app/rpc/commands/projects.clj
+++ b/backend/src/app/rpc/commands/projects.clj
@@ -222,7 +222,7 @@
::webhooks/event? true}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id team-id is-pinned] :as params}]
(db/with-atomic [conn pool]
- (check-edition-permissions! conn profile-id id)
+ (check-read-permissions! conn profile-id id)
(db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned])
nil))
diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj
index e162e3358d..ffe7f42821 100644
--- a/backend/src/app/rpc/commands/teams.clj
+++ b/backend/src/app/rpc/commands/teams.clj
@@ -12,6 +12,7 @@
[app.common.features :as cfeat]
[app.common.logging :as l]
[app.common.schema :as sm]
+ [app.common.types.team :as tt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
@@ -20,6 +21,7 @@
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
+ [app.msgbus :as mbus]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
@@ -605,14 +607,8 @@
nil)))
;; --- Mutation: Team Update Role
-
-;; Temporarily disabled viewer role
-;; https://tree.taiga.io/project/penpot/issue/1083
-(def valid-roles
- #{:owner :admin :editor #_:viewer})
-
(def schema:role
- [::sm/one-of valid-roles])
+ [::sm/one-of tt/valid-roles])
(defn role->params
[role]
@@ -623,7 +619,7 @@
:viewer {:is-owner false :is-admin false :can-edit false}))
(defn update-team-member-role
- [conn {:keys [profile-id team-id member-id role] :as params}]
+ [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id team-id member-id role] :as params}]
;; We retrieve all team members instead of query the
;; database for a single member. This is just for
;; convenience, if this becomes a bottleneck or problematic,
@@ -631,7 +627,6 @@
(let [perms (get-permissions conn profile-id team-id)
members (get-team-members conn team-id)
member (d/seek #(= member-id (:id %)) members)
-
is-owner? (:is-owner perms)
is-admin? (:is-admin perms)]
@@ -655,6 +650,13 @@
(ex/raise :type :validation
:code :cant-promote-to-owner))
+ (mbus/pub! msgbus
+ :topic member-id
+ :message {:type :team-role-change
+ :subs-id member-id
+ :team-id team-id
+ :role role})
+
(let [params (role->params role)]
;; Only allow single owner on team
(when (= role :owner)
@@ -678,9 +680,8 @@
(sv/defmethod ::update-team-member-role
{::doc/added "1.17"
::sm/params schema:update-team-member-role}
- [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
- (db/with-atomic [conn pool]
- (update-team-member-role conn (assoc params :profile-id profile-id))))
+ [cfg {:keys [::rpc/profile-id] :as params}]
+ (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id)))
;; --- Mutation: Delete Team Member
@@ -692,9 +693,10 @@
(sv/defmethod ::delete-team-member
{::doc/added "1.17"
::sm/params schema:delete-team-member}
- [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}]
+ [{:keys [::db/pool ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}]
(db/with-atomic [conn pool]
- (let [perms (get-permissions conn profile-id team-id)]
+ (let [team (get-team pool :profile-id profile-id :team-id team-id)
+ perms (get-permissions conn profile-id team-id)]
(when-not (or (:is-owner perms)
(:is-admin perms))
(ex/raise :type :validation
@@ -707,6 +709,14 @@
(db/delete! conn :team-profile-rel {:profile-id member-id
:team-id team-id})
+ (mbus/pub! msgbus
+ :topic member-id
+ :message {:type :team-membership-change
+ :change :removed
+ :subs-id member-id
+ :team-id team-id
+ :team-name (:name team)})
+
nil)))
;; --- Mutation: Update Team Photo
@@ -724,6 +734,7 @@
::sm/params schema:update-team-photo}
[cfg {:keys [::rpc/profile-id file] :as params}]
;; Validate incoming mime type
+
(media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
(update-team-photo cfg (assoc params :profile-id profile-id)))
@@ -789,7 +800,7 @@
[:map
[:id ::sm/uuid]
[:fullname :string]]]
- [:role [::sm/one-of valid-roles]]
+ [:role [::sm/one-of tt/valid-roles]]
[:email ::sm/email]])
(def ^:private check-create-invitation-params!
@@ -1115,7 +1126,7 @@
::sm/params schema:update-team-invitation-role}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}]
(db/with-atomic [conn pool]
- (let [perms (get-permissions conn profile-id team-id)]
+ (let [perms (get-permissions conn profile-id team-id)]
(when-not (:is-admin perms)
(ex/raise :type :validation
@@ -1124,6 +1135,7 @@
(db/update! conn :team-invitation
{:role (name role) :updated-at (dt/now)}
{:team-id team-id :email-to (profile/clean-email email)})
+
nil)))
;; --- Mutation: Delete invitation
diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj
index e2a56691e5..1eb999c3e4 100644
--- a/backend/src/app/rpc/commands/webhooks.clj
+++ b/backend/src/app/rpc/commands/webhooks.clj
@@ -15,12 +15,27 @@
[app.http.client :as http]
[app.loggers.webhooks :as webhooks]
[app.rpc :as-alias rpc]
- [app.rpc.commands.teams :refer [check-edition-permissions! check-read-permissions!]]
+ [app.rpc.commands.teams :refer [check-read-permissions!] :as t]
[app.rpc.doc :as-alias doc]
+ [app.rpc.permissions :as perms]
[app.util.services :as sv]
[app.util.time :as dt]
[cuerdas.core :as str]))
+(defn get-webhooks-permissions
+ [conn profile-id team-id creator-id]
+ (let [permissions (t/get-permissions conn profile-id team-id)
+
+ can-edit (boolean (or (:can-edit permissions)
+ (= profile-id creator-id)))]
+ (assoc permissions :can-edit can-edit)))
+
+(def has-webhook-edit-permissions?
+ (perms/make-edition-predicate-fn get-webhooks-permissions))
+
+(def check-webhook-edition-permissions!
+ (perms/make-check-fn has-webhook-edit-permissions?))
+
(defn decode-row
[{:keys [uri] :as row}]
(cond-> row
@@ -65,11 +80,12 @@
max-hooks-for-team)))))
(defn- insert-webhook!
- [{:keys [::db/pool]} {:keys [team-id uri mtype is-active] :as params}]
+ [{:keys [::db/pool]} {:keys [team-id uri mtype is-active ::rpc/profile-id] :as params}]
(-> (db/insert! pool :webhook
{:id (uuid/next)
:team-id team-id
:uri (str uri)
+ :profile-id profile-id
:is-active is-active
:mtype mtype})
(decode-row)))
@@ -101,7 +117,7 @@
{::doc/added "1.17"
::sm/params schema:create-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
- (check-edition-permissions! pool profile-id team-id)
+ (check-webhook-edition-permissions! pool profile-id team-id profile-id)
(validate-quotes! cfg params)
(validate-webhook! cfg nil params)
(insert-webhook! cfg params))
@@ -118,7 +134,7 @@
::sm/params schema:update-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
- (check-edition-permissions! pool profile-id (:team-id whook))
+ (check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
(validate-webhook! cfg whook params)
(update-webhook! cfg whook params)))
@@ -132,15 +148,17 @@
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}]
(db/with-atomic [conn pool]
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
- (check-edition-permissions! conn profile-id (:team-id whook))
+ (check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
(db/delete! conn :webhook {:id id})
nil)))
;; --- Query: Webhooks
(def sql:get-webhooks
- "select id, uri, mtype, is_active, error_code, error_count
- from webhook where team_id = ? order by uri")
+ "SELECT id, uri, mtype, is_active, error_code, error_count, profile_id
+ FROM webhook
+ WHERE team_id = ?
+ ORDER BY uri")
(def ^:private schema:get-webhooks
[:map {:title "get-webhooks"}
diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj
index e77b51d6a5..1e26d98107 100644
--- a/backend/test/backend_tests/helpers.clj
+++ b/backend/test/backend_tests/helpers.clj
@@ -332,6 +332,7 @@
(t/is (nil? (:error out)))
(:result out)))
+
(defn create-webhook*
([params] (create-webhook* *system* params))
([system {:keys [team-id id uri mtype is-active]
diff --git a/backend/test/backend_tests/rpc_project_test.clj b/backend/test/backend_tests/rpc_project_test.clj
index f35105a97f..4613e6257e 100644
--- a/backend/test/backend_tests/rpc_project_test.clj
+++ b/backend/test/backend_tests/rpc_project_test.clj
@@ -152,7 +152,7 @@
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :not-found))))
-(t/deftest permissions-checks-delete-project
+(t/deftest permissions-checks-pin-project
(let [profile1 (th/create-profile* 1)
profile2 (th/create-profile* 2)
project (th/create-project* 1 {:team-id (:default-team-id profile1)
diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj
index c020c54854..bc1da4c64f 100644
--- a/backend/test/backend_tests/rpc_webhooks_test.clj
+++ b/backend/test/backend_tests/rpc_webhooks_test.clj
@@ -19,6 +19,23 @@
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
+(defn create-webhook-params [id team]
+ {::th/type :create-webhook
+ ::rpc/profile-id id
+ :team-id team
+ :uri (u/uri "http://example.com")
+ :mtype "application/json"})
+
+(defn check-webhook-format
+ ([result]
+ (t/is (contains? result :id))
+ (t/is (contains? result :team-id))
+ (t/is (contains? result :created-at))
+ (t/is (contains? result :profile-id))
+ (t/is (contains? result :updated-at))
+ (t/is (contains? result :uri))
+ (t/is (contains? result :mtype))))
+
(t/deftest webhook-crud
(with-mocks [http-mock {:target 'app.http.client/req!
:return {:status 200}}]
@@ -39,15 +56,8 @@
(t/is (nil? (:error out)))
(t/is (= 1 (:call-count @http-mock)))
- ;; (th/print-result! out)
-
(let [result (:result out)]
- (t/is (contains? result :id))
- (t/is (contains? result :team-id))
- (t/is (contains? result :created-at))
- (t/is (contains? result :updated-at))
- (t/is (contains? result :uri))
- (t/is (contains? result :mtype))
+ (check-webhook-format result)
(t/is (= (:uri params) (:uri result)))
(t/is (= (:team-id params) (:team-id result)))
@@ -69,12 +79,7 @@
(t/is (= 0 (:call-count @http-mock)))
(let [result (:result out)]
- (t/is (contains? result :id))
- (t/is (contains? result :team-id))
- (t/is (contains? result :created-at))
- (t/is (contains? result :updated-at))
- (t/is (contains? result :uri))
- (t/is (contains? result :mtype))
+ (check-webhook-format result)
(t/is (= (:id params) (:id result)))
(t/is (= (:id @whook) (:id result)))
@@ -130,13 +135,14 @@
(let [rows (th/db-exec! ["select * from webhook"])]
(t/is (= 0 (count rows))))))
- (t/testing "delete webhook (unauthorozed)"
+ (th/reset-mock! http-mock)
+
+ (t/testing "delete webhook (unauthorized)"
(let [params {::th/type :delete-webhook
::rpc/profile-id uuid/zero
:id (:id @whook)}
out (th/command! params)]
- ;; (th/print-result! out)
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
@@ -144,6 +150,124 @@
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found))))))))
+(t/deftest webhooks-permissions-crud-viewer-only
+ (with-mocks [http-mock {:target 'app.http.client/req!
+ :return {:status 200}}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ viewer (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})
+ whook (volatile! nil)]
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id viewer)
+ :role :viewer})
+ ;; Assert all roles for team
+ (let [roles (th/db-query :team-profile-rel {:team-id (:id team)})]
+ (t/is (= 2 (count roles))))
+
+ (t/testing "viewer creates a webhook"
+ (let [viewers-webhook (create-webhook-params (:id viewer) (:id team))
+ out (th/command! viewers-webhook)]
+ (t/is (nil? (:error out)))
+ (t/is (= 1 (:call-count @http-mock)))
+
+ (let [result (:result out)]
+ (check-webhook-format result)
+ (t/is (= (:uri viewers-webhook) (:uri result)))
+ (t/is (= (:team-id viewers-webhook) (:team-id result)))
+ (t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result)))
+ (t/is (= (:mtype viewers-webhook) (:mtype result)))
+ (vreset! whook result))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "viewer updates it's own webhook (success)"
+ (let [params {::th/type :update-webhook
+ ::rpc/profile-id (:id viewer)
+ :id (:id @whook)
+ :uri (:uri @whook)
+ :mtype "application/transit+json"
+ :is-active false}
+ out (th/command! params)
+ result (:result out)]
+
+ (t/is (nil? (:error out)))
+ (t/is (= 0 (:call-count @http-mock)))
+ (check-webhook-format result)
+ (t/is (= (:is-active params) (:is-active result)))
+ (t/is (= (:team-id @whook) (:team-id result)))
+ (t/is (= (:mtype params) (:mtype result)))
+ (vreset! whook result)))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "viewer deletes it's own webhook (success)"
+ (let [params {::th/type :delete-webhook
+ ::rpc/profile-id (:id viewer)
+ :id (:id @whook)}
+ out (th/command! params)]
+ (t/is (= 0 (:call-count @http-mock)))
+ (t/is (nil? (:error out)))
+ (t/is (nil? (:result out)))
+ (let [rows (th/db-exec! ["select * from webhook"])]
+ (t/is (= 0 (count rows))))))
+
+ (th/reset-mock! http-mock))))
+
+(t/deftest webhooks-permissions-crud-viewer-owner
+ (with-mocks [http-mock {:target 'app.http.client/req!
+ :return {:status 200}}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ viewer (th/create-profile* 2 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})
+ whook (volatile! nil)]
+ (th/create-team-role* {:team-id (:id team)
+ :profile-id (:id viewer)
+ :role :viewer})
+ (t/testing "owner creates a wehbook"
+ (let [owners-webhook (create-webhook-params (:id owner) (:id team))
+ out (th/command! owners-webhook)
+ result (:result out)]
+ (t/is (nil? (:error out)))
+ (t/is (= 1 (:call-count @http-mock)))
+ (check-webhook-format result)
+ (t/is (= (:uri owners-webhook) (:uri result)))
+ (t/is (= (:team-id owners-webhook) (:team-id result)))
+ (t/is (= (:mtype owners-webhook) (:mtype result)))
+ (vreset! whook result)))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "viewer updates owner's webhook (unauthorized)"
+ (let [params {::th/type :update-webhook
+ ::rpc/profile-id (:id viewer)
+ :id (:id @whook)
+ :uri (str (:uri @whook) "/test")
+ :mtype "application/transit+json"
+ :is-active false}
+ out (th/command! params)]
+
+ (t/is (= 0 (:call-count @http-mock)))
+
+ (let [error (:error out)
+ error-data (ex-data error)]
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found)))))
+
+ (th/reset-mock! http-mock)
+
+ (t/testing "viewer deletes owner's webhook (unauthorized)"
+ (let [params {::th/type :delete-webhook
+ ::rpc/profile-id (:id viewer)
+ :id (:id @whook)}
+ out (th/command! params)
+ error (:error out)
+ error-data (ex-data error)]
+ (t/is (= 0 (:call-count @http-mock)))
+ (t/is (th/ex-info? error))
+ (t/is (= (:type error-data) :not-found))
+ (t/is (= (:code error-data) :object-not-found)))))))
+
(t/deftest webhooks-quotes
(with-mocks [http-mock {:target 'app.http.client/req!
:return {:status 200}}]
diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc
index 59b1665553..7d384b0efb 100644
--- a/common/src/app/common/test_helpers/files.cljc
+++ b/common/src/app/common/test_helpers/files.cljc
@@ -22,7 +22,7 @@
;; ----- Files
(defn sample-file
- [label & {:keys [page-label name] :as params}]
+ [label & {:keys [page-label name view-only?] :as params}]
(binding [ffeat/*current* #{"components/v2"}]
(let [params (cond-> params
label
@@ -35,7 +35,8 @@
(assoc :name "Test file"))
file (-> (ctf/make-file (dissoc params :page-label))
- (assoc :features #{"components/v2"}))
+ (assoc :features #{"components/v2"})
+ (assoc :permissions {:can-edit (not (true? view-only?))}))
page (-> file
:data
diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc
new file mode 100644
index 0000000000..aed6f20397
--- /dev/null
+++ b/common/src/app/common/types/team.cljc
@@ -0,0 +1,17 @@
+;; 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.common.types.team)
+
+(def valid-roles
+ #{:owner :admin :editor :viewer})
+
+(def permissions-for-role
+ {:viewer {:can-edit false :is-admin false :is-owner false}
+ :editor {:can-edit true :is-admin false :is-owner false}
+ :admin {:can-edit true :is-admin true :is-owner false}
+ :owner {:can-edit true :is-admin true :is-owner true}})
+
diff --git a/frontend/resources/images/assets/empty-placeholder-1-left.svg b/frontend/resources/images/assets/empty-placeholder-1-left.svg
new file mode 100644
index 0000000000..dc40e1fbf7
--- /dev/null
+++ b/frontend/resources/images/assets/empty-placeholder-1-left.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/assets/empty-placeholder-1-right.svg b/frontend/resources/images/assets/empty-placeholder-1-right.svg
new file mode 100644
index 0000000000..102b75d2c2
--- /dev/null
+++ b/frontend/resources/images/assets/empty-placeholder-1-right.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/assets/empty-placeholder-2-left.svg b/frontend/resources/images/assets/empty-placeholder-2-left.svg
new file mode 100644
index 0000000000..7ccd30fe44
--- /dev/null
+++ b/frontend/resources/images/assets/empty-placeholder-2-left.svg
@@ -0,0 +1,2 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/assets/empty-placeholder-2-right.svg b/frontend/resources/images/assets/empty-placeholder-2-right.svg
new file mode 100644
index 0000000000..25c48b1427
--- /dev/null
+++ b/frontend/resources/images/assets/empty-placeholder-2-right.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs
index 080f193fa4..0f64541416 100644
--- a/frontend/src/app/main/data/changes.cljs
+++ b/frontend/src/app/main/data/changes.cljs
@@ -178,19 +178,23 @@
(ptk/reify ::commit-changes
ptk/WatchEvent
(watch [_ state _]
- (let [file-id (or file-id (:current-file-id state))
- uchg (vec undo-changes)
- rchg (vec redo-changes)
- features (features/get-team-enabled-features state)]
+ (let [file-id (or file-id (:current-file-id state))
+ uchg (vec undo-changes)
+ rchg (vec redo-changes)
+ features (features/get-team-enabled-features state)
+ user-viewer? (not (dm/get-in state [:workspace-file :permissions :can-edit]))]
- (rx/of (-> params
- (assoc :undo-group undo-group)
- (assoc :features features)
- (assoc :tags tags)
- (assoc :stack-undo? stack-undo?)
- (assoc :save-undo? save-undo?)
- (assoc :file-id file-id)
- (assoc :file-revn (resolve-file-revn state file-id))
- (assoc :undo-changes uchg)
- (assoc :redo-changes rchg)
- (commit)))))))
+ ;; Prevent commit changes by a viewer team member (it really should never happen)
+ (if user-viewer?
+ (rx/empty)
+ (rx/of (-> params
+ (assoc :undo-group undo-group)
+ (assoc :features features)
+ (assoc :tags tags)
+ (assoc :stack-undo? stack-undo?)
+ (assoc :save-undo? save-undo?)
+ (assoc :file-id file-id)
+ (assoc :file-revn (resolve-file-revn state file-id))
+ (assoc :undo-changes uchg)
+ (assoc :redo-changes rchg)
+ (commit))))))))
diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs
index a9b219f784..136fafd6bb 100644
--- a/frontend/src/app/main/data/common.cljs
+++ b/frontend/src/app/main/data/common.cljs
@@ -7,7 +7,9 @@
(ns app.main.data.common
"A general purpose events."
(:require
+ [app.common.data.macros :as dm]
[app.common.types.components-list :as ctkl]
+ [app.common.types.team :as tt]
[app.config :as cf]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
@@ -15,6 +17,7 @@
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
+ [app.util.router :as rt]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -170,3 +173,50 @@
(->> (rp/cmd! :create-team-access-request params)
(rx/tap on-success)
(rx/catch on-error))))))
+
+(defn- change-role-msg
+ [role]
+ (case role
+ :viewer (tr "dashboard.permissions-change.viewer")
+ :editor (tr "dashboard.permissions-change.editor")
+ :admin (tr "dashboard.permissions-change.admin")
+ :owner (tr "dashboard.permissions-change.owner")))
+
+
+(defn change-team-permissions
+ [{:keys [team-id role workspace?]}]
+ (dm/assert! (uuid? team-id))
+ (dm/assert! (contains? tt/valid-roles role))
+ (ptk/reify ::change-team-permissions
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (ntf/info (change-role-msg role))))
+
+ ptk/UpdateEvent
+ (update [_ state]
+ (let [route (if workspace?
+ [:workspace-file :permissions]
+ [:teams team-id :permissions])]
+ (update-in state route
+ (fn [permissions]
+ (merge permissions (get tt/permissions-for-role role))))))))
+
+
+
+(defn team-membership-change
+ [{:keys [team-id team-name change]}]
+ (dm/assert! (uuid? team-id))
+ (ptk/reify ::team-membership-change
+ ptk/WatchEvent
+ (watch [_ state _]
+ (when (= :removed change)
+ (let [msg (tr "dashboard.removed-from-team" team-name)]
+
+ (rx/concat
+ (rx/of (rt/nav :dashboard-projects {:team-id (get-in state [:profile :default-team-id])}))
+ (->> (rx/of (ntf/info msg))
+ ;; Delay so the navigation can finish
+ (rx/delay 250))))))))
+
+
+
diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs
index 1bedc29dd0..03ccc2ec76 100644
--- a/frontend/src/app/main/data/dashboard.cljs
+++ b/frontend/src/app/main/data/dashboard.cljs
@@ -12,13 +12,15 @@
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.schema :as sm]
+ [app.common.types.team :as tt]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
- [app.main.data.common :refer [handle-notification]]
+ [app.main.data.common :as dc]
[app.main.data.events :as ev]
[app.main.data.fonts :as df]
[app.main.data.media :as di]
+ [app.main.data.modal :as modal]
[app.main.data.users :as du]
[app.main.data.websocket :as dws]
[app.main.features :as features]
@@ -42,6 +44,7 @@
(declare fetch-projects)
(declare fetch-team-members)
+(declare process-message)
(defn initialize
[{:keys [id]}]
@@ -77,11 +80,10 @@
(->> stream
(rx/filter (ptk/type? ::dws/message))
(rx/map deref)
- (rx/filter (fn [{:keys [subs-id type] :as msg}]
- (and (or (= subs-id uuid/zero)
- (= subs-id profile-id))
- (= :notification type))))
- (rx/map handle-notification))
+ (rx/filter (fn [{:keys [subs-id] :as msg}]
+ (or (= subs-id uuid/zero)
+ (= subs-id profile-id))))
+ (rx/map process-message))
;; Once the teams are fecthed, initialize features related
;; to currently active team
@@ -480,7 +482,8 @@
(defn update-team-member-role
[{:keys [role member-id] :as params}]
(dm/assert! (uuid? member-id))
- (dm/assert! (keyword? role)) ; FIXME: validate proper role?
+ (dm/assert! (contains? tt/valid-roles role))
+
(ptk/reify ::update-team-member-role
ptk/WatchEvent
(watch [_ state _]
@@ -602,7 +605,7 @@
(sm/check-email! email))
(dm/assert! (uuid? team-id))
- (dm/assert! (keyword? role)) ;; FIXME validate role
+ (dm/assert! (contains? tt/valid-roles role))
(ptk/reify ::update-team-invitation-role
IDeref
@@ -1203,3 +1206,24 @@
(let [file (get-in state [:dashboard-files (first files)])]
(rx/of (go-to-workspace file)))
(rx/empty))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Notifications
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+
+(defn- handle-change-team-permissions-dashboard
+ [msg]
+ (ptk/reify ::handle-change-team-permissions-dashboard
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (rx/of (dc/change-team-permissions (assoc msg :workspace? false))
+ (modal/hide)))))
+
+(defn- process-message
+ [{:keys [type] :as msg}]
+ (case type
+ :notification (dc/handle-notification msg)
+ :team-role-change (handle-change-team-permissions-dashboard msg)
+ :team-membership-change (dc/team-membership-change msg)
+ nil))
\ No newline at end of file
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index 5afe5eeeb8..78c0f8615f 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -2106,24 +2106,7 @@
(pcb/mod-page {:background (:color color)}))]
(rx/of (dch/commit-changes changes)))))))
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; Read only
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-(defn set-workspace-read-only
- [read-only?]
- (ptk/reify ::set-workspace-read-only
- ptk/UpdateEvent
- (update [_ state]
- (assoc-in state [:workspace-global :read-only?] read-only?))
-
- ptk/WatchEvent
- (watch [_ _ _]
- (if read-only?
- (rx/of :interrupt
- (remove-layout-flag :colorpalette)
- (remove-layout-flag :textpalette))
- (rx/empty)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Measurements
diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs
index 5879951058..3902252c57 100644
--- a/frontend/src/app/main/data/workspace/common.cljs
+++ b/frontend/src/app/main/data/workspace/common.cljs
@@ -7,6 +7,8 @@
(ns app.main.data.workspace.common
(:require
[app.common.logging :as log]
+ [app.main.data.workspace.layout :as dwl]
+ [beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; Change this to :info :debug or :trace to debug this module
@@ -56,3 +58,22 @@
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :hide-toolbar] not))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Read only
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn set-workspace-read-only
+ [read-only?]
+ (ptk/reify ::set-workspace-read-only
+ ptk/UpdateEvent
+ (update [_ state]
+ (assoc-in state [:workspace-global :read-only?] read-only?))
+
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (if read-only?
+ (rx/of :interrupt
+ (dwl/remove-layout-flag :colorpalette)
+ (dwl/remove-layout-flag :textpalette))
+ (rx/empty)))))
diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs
index e602618e19..449c26e6b0 100644
--- a/frontend/src/app/main/data/workspace/notifications.cljs
+++ b/frontend/src/app/main/data/workspace/notifications.cljs
@@ -12,8 +12,13 @@
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
- [app.main.data.common :refer [handle-notification]]
+ [app.main.data.common :as dc]
+ [app.main.data.modal :as modal]
[app.main.data.websocket :as dws]
+ [app.main.data.workspace.common :as dwc]
+ [app.main.data.workspace.edition :as dwe]
+ [app.main.data.workspace.layout :as dwly]
+
[app.main.data.workspace.libraries :as dwl]
[app.util.globals :refer [global]]
[app.util.mouse :as mse]
@@ -92,17 +97,40 @@
(rx/concat stream (rx/of (dws/send endmsg)))))))
+
+(defn- handle-change-team-permissions
+ [{:keys [role] :as msg}]
+ (ptk/reify ::handle-change-team-permissions
+ ptk/WatchEvent
+ (watch [_ _ _]
+ (let [viewer? (= :viewer role)]
+
+ (rx/concat
+ (rx/of :interrupt
+ (dwe/clear-edition-mode)
+ (dwc/set-workspace-read-only false))
+ (->> (rx/of (dc/change-team-permissions msg))
+ ;; Delay so anything that launched :interrupt can finish
+ (rx/delay 100))
+ (if viewer?
+ (rx/of (modal/hide)
+ (dwly/set-options-mode :inspect))
+ (rx/of (dwly/set-options-mode :design))))))))
+
+
(defn- process-message
[{:keys [type] :as msg}]
(case type
- :join-file (handle-presence msg)
- :leave-file (handle-presence msg)
- :presence (handle-presence msg)
- :disconnect (handle-presence msg)
- :pointer-update (handle-pointer-update msg)
- :file-change (handle-file-change msg)
- :library-change (handle-library-change msg)
- :notification (handle-notification msg)
+ :join-file (handle-presence msg)
+ :leave-file (handle-presence msg)
+ :presence (handle-presence msg)
+ :disconnect (handle-presence msg)
+ :pointer-update (handle-pointer-update msg)
+ :file-change (handle-file-change msg)
+ :library-change (handle-library-change msg)
+ :notification (dc/handle-notification msg)
+ :team-role-change (handle-change-team-permissions (assoc msg :workspace? true))
+ :team-membership-change (dc/team-membership-change msg)
nil))
(defn- handle-pointer-send
@@ -256,4 +284,4 @@
(watch [_ state _]
(when (contains? (:workspace-libraries state) file-id)
(rx/of (dwl/ext-library-changed file-id modified-at revn changes)
- (dwl/notify-sync-file file-id))))))
+ (dwl/notify-sync-file file-id))))))
\ No newline at end of file
diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs
index fd045067a4..03f27594c5 100644
--- a/frontend/src/app/main/data/workspace/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/shortcuts.cljs
@@ -44,8 +44,12 @@
(defn emit-when-no-readonly
[& events]
- (when-not (deref refs/workspace-read-only?)
- (run! st/emit! events)))
+ (let [file (deref refs/workspace-file)
+ user-viewer? (not (dm/get-in file [:permissions :can-edit]))
+ read-only? (or (deref refs/workspace-read-only?)
+ user-viewer?)]
+ (when-not read-only?
+ (run! st/emit! events))))
(def esc-pressed
(ptk/reify ::esc-pressed
@@ -324,7 +328,7 @@
:toggle-focus-mode {:command "f"
:tooltip "F"
:subsections [:basics :tools]
- :fn #(emit-when-no-readonly (dw/toggle-focus-mode))}
+ :fn #(st/emit! (dw/toggle-focus-mode))}
;; ITEM ALIGNMENT
diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs
index 0970fca8bb..446aa015b9 100644
--- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs
+++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs
@@ -7,6 +7,7 @@
(ns app.main.data.workspace.text.shortcuts
(:require
[app.common.data :as d]
+ [app.common.data.macros :as dm]
[app.common.text :as txt]
[app.main.data.shortcuts :as ds]
[app.main.data.workspace.texts :as dwt]
@@ -189,7 +190,10 @@
(defn- update-attrs-when-no-readonly [props]
(let [undo-id (js/Symbol)
- read-only? (deref refs/workspace-read-only?)
+ file (deref refs/workspace-file)
+ user-viewer? (not (dm/get-in file [:permissions :can-edit]))
+ read-only? (or (deref refs/workspace-read-only?)
+ user-viewer?)
shapes-with-children (deref refs/selected-shapes-with-children)
text-shapes (filter #(= (:type %) :text) shapes-with-children)
props (if (> (count text-shapes) 1)
diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs
index c007896b9f..9323171ce7 100644
--- a/frontend/src/app/main/ui/context.cljs
+++ b/frontend/src/app/main/ui/context.cljs
@@ -31,3 +31,5 @@
(def workspace-read-only? (mf/create-context nil))
(def is-component? (mf/create-context false))
(def sidebar (mf/create-context nil))
+
+(def user-viewer? (mf/create-context nil))
diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs
index a158221043..37e023cc04 100644
--- a/frontend/src/app/main/ui/dashboard.cljs
+++ b/frontend/src/app/main/ui/dashboard.cljs
@@ -65,6 +65,7 @@
content-width (mf/use-state 0)
project-id (:id project)
team-id (:id team)
+ you-viewer? (not (dm/get-in team [:permissions :can-edit]))
dashboard-local (mf/deref refs/dashboard-local)
file-menu-open? (:menu-open dashboard-local)
@@ -84,7 +85,10 @@
clear-selected-fn
(mf/use-fn
- #(st/emit! (dd/clear-selected-files)))]
+ #(st/emit! (dd/clear-selected-files)))
+
+ show-templates (and (contains? cf/flags :dashboard-templates-section)
+ (not you-viewer?))]
(mf/with-effect []
(let [key1 (events/listen js/window "resize" on-resize)]
@@ -105,7 +109,7 @@
:profile profile
:default-project-id default-project-id}]
- (when (contains? cf/flags :dashboard-templates-section)
+ (when show-templates
[:& templates-section {:profile profile
:project-id project-id
:team-id team-id
@@ -113,7 +117,7 @@
:content-width @content-width}])]
:dashboard-fonts
- [:& fonts-page {:team team}]
+ [:& fonts-page {:team team :you-viewer? you-viewer?}]
:dashboard-font-providers
[:& font-providers-page {:team team}]
@@ -121,8 +125,8 @@
:dashboard-files
(when project
[:*
- [:& files-section {:team team :project project}]
- (when (contains? cf/flags :dashboard-templates-section)
+ [:& files-section {:team team :project project :you-viewer? you-viewer?}]
+ (when show-templates
[:& templates-section {:profile profile
:team-id team-id
:project-id project-id
@@ -134,7 +138,7 @@
:search-term search-term}]
:dashboard-libraries
- [:& libraries-page {:team team}]
+ [:& libraries-page {:team team :you-viewer? you-viewer?}]
:dashboard-team-members
[:& team-members-page {:team team :profile profile :invite-email invite-email}]
diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs
index 8d6e01f7bc..aa87cb9074 100644
--- a/frontend/src/app/main/ui/dashboard/file_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs
@@ -11,6 +11,7 @@
[app.main.data.events :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
+ [app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
@@ -55,7 +56,7 @@
(mf/defc file-menu
{::mf/wrap-props false}
- [{:keys [files show? on-edit on-menu-close top left navigate? origin parent-id]}]
+ [{:keys [files show? on-edit on-menu-close top left navigate? origin parent-id you-viewer?]}]
(assert (seq files) "missing `files` prop")
(assert (boolean? show?) "missing `show?` prop")
(assert (fn? on-edit) "missing `on-edit` prop")
@@ -73,7 +74,10 @@
current-team-id (mf/use-ctx ctx/current-team-id)
teams (mf/use-state nil)
- current-team (get @teams current-team-id)
+ default-team (-> (mf/deref refs/teams)
+ (get current-team-id))
+
+ current-team (or (get @teams current-team-id) default-team)
other-teams (remove #(= (:id %) current-team-id) (vals @teams))
current-projects (remove #(= (:id %) (:project-id file))
(:projects current-team))
@@ -237,11 +241,13 @@
(:id sub-project))})})}]))
options (if multi?
- [{:option-name (tr "dashboard.duplicate-multi" file-count)
- :id "file-duplicate-multi"
- :option-handler on-duplicate
- :data-testid "duplicate-multi"}
- (when (or (seq current-projects) (seq other-teams))
+ [(when-not you-viewer?
+ {:option-name (tr "dashboard.duplicate-multi" file-count)
+ :id "file-duplicate-multi"
+ :option-handler on-duplicate
+ :data-testid "duplicate-multi"})
+ (when (and (or (seq current-projects) (seq other-teams))
+ (not you-viewer?))
{:option-name (tr "dashboard.move-to-multi" file-count)
:id "file-move-multi"
:sub-options sub-options
@@ -252,12 +258,14 @@
{:option-name (tr "dashboard.export-standard-multi" file-count)
:id "file-standard-export-multi"
:option-handler on-export-standard-files}
- (when (:is-shared file)
+ (when (and (:is-shared file)
+ (not you-viewer?))
{:option-name (tr "labels.unpublish-multi-files" file-count)
:id "file-unpublish-multi"
:option-handler on-del-shared
:data-testid "file-del-shared"})
- (when (not is-lib-page?)
+ (when (and (not is-lib-page?)
+ (not you-viewer?))
{:option-name :separator}
{:option-name (tr "labels.delete-multi-files" file-count)
:id "file-delete-multi"
@@ -267,22 +275,28 @@
[{:option-name (tr "dashboard.open-in-new-tab")
:id "file-open-new-tab"
:option-handler on-new-tab}
- (when (not is-search-page?)
+ (when (and (not is-search-page?)
+ (not you-viewer?))
{:option-name (tr "labels.rename")
:id "file-rename"
:option-handler on-edit
:data-testid "file-rename"})
- (when (not is-search-page?)
+ (when (and (not is-search-page?)
+ (not you-viewer?))
{:option-name (tr "dashboard.duplicate")
:id "file-duplicate"
:option-handler on-duplicate
:data-testid "file-duplicate"})
- (when (and (not is-lib-page?) (not is-search-page?) (or (seq current-projects) (seq other-teams)))
+ (when (and (not is-lib-page?)
+ (not is-search-page?)
+ (or (seq current-projects) (seq other-teams))
+ (not you-viewer?))
{:option-name (tr "dashboard.move-to")
:id "file-move-to"
:sub-options sub-options
:data-testid "file-move-to"})
- (when (not is-search-page?)
+ (when (and (not is-search-page?)
+ (not you-viewer?))
(if (:is-shared file)
{:option-name (tr "dashboard.unpublish-shared")
:id "file-del-shared"
@@ -301,7 +315,7 @@
:id "file-download-standard"
:option-handler on-export-standard-files
:data-testid "download-standard-file"}
- (when (and (not is-lib-page?) (not is-search-page?))
+ (when (and (not is-lib-page?) (not is-search-page?) (not you-viewer?))
{:option-name :separator}
{:option-name (tr "labels.delete")
:id "file-delete"
diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs
index e533a6b857..13ea519c62 100644
--- a/frontend/src/app/main/ui/dashboard/files.cljs
+++ b/frontend/src/app/main/ui/dashboard/files.cljs
@@ -15,6 +15,7 @@
[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]
@@ -28,7 +29,7 @@
(i/icon-xref :menu (stl/css :menu-icon)))
(mf/defc header
- [{:keys [project create-fn] :as props}]
+ [{:keys [project create-fn you-viewer?] :as props}]
(let [local (mf/use-state
{:menu-open false
:edition false})
@@ -71,7 +72,8 @@
[:div#dashboard-drafts-title {:class (stl/css :dashboard-title)}
[:h1 (tr "labels.drafts")]]
- (if (:edition @local)
+ (if (and (:edition @local)
+ (not you-viewer?))
[:& inline-edition
{:content (:name project)
:on-end (fn [name]
@@ -86,23 +88,16 @@
:id (:id project)}
(:name project)]]))
- [:& project-menu {:project project
- :show? (:menu-open @local)
- :left (- (:x (:menu-pos @local)) 180)
- :top (:y (:menu-pos @local))
- :on-edit on-edit
- :on-menu-close on-menu-close
- :on-import on-import}]
-
[:div {:class (stl/css :dashboard-header-actions)}
- [:a {:class (stl/css :btn-secondary :btn-small :new-file)
- :tab-index "0"
- :on-click on-create-click
- :data-testid "new-file"
- :on-key-down (fn [event]
- (when (kbd/enter? event)
- (on-create-click event)))}
- (tr "dashboard.new-file")]
+ (when-not you-viewer?
+ [:a {:class (stl/css :btn-secondary :btn-small :new-file)
+ :tab-index "0"
+ :on-click on-create-click
+ :data-testid "new-file"
+ :on-key-down (fn [event]
+ (when (kbd/enter? event)
+ (on-create-click event)))}
+ (tr "dashboard.new-file")])
(when-not (:is-default project)
[:> pin-button*
@@ -111,19 +106,30 @@
:on-click toggle-pin
:on-key-down (fn [event] (when (kbd/enter? event) (toggle-pin event)))}])
- [:div {:class (stl/css :icon)
- :tab-index "0"
- :on-click on-menu-click
- :title (tr "dashboard.options")
- :on-key-down (fn [event]
- (when (kbd/enter? event)
- (on-menu-click event)))}
- menu-icon]]]))
+ (when-not you-viewer?
+ [:div {:class (stl/css :icon)
+ :tab-index "0"
+ :on-click on-menu-click
+ :title (tr "dashboard.options")
+ :on-key-down (fn [event]
+ (when (kbd/enter? event)
+ (on-menu-click event)))}
+ menu-icon])
+
+ (when-not you-viewer?
+ [:& project-menu {:project project
+ :show? (:menu-open @local)
+ :left (- (:x (:menu-pos @local)) 180)
+ :top (:y (:menu-pos @local))
+ :on-edit on-edit
+ :on-menu-close on-menu-close
+ :on-import on-import}])]]))
(mf/defc files-section
- [{:keys [project team] :as props}]
+ [{:keys [project team you-viewer?] :as props}]
(let [files-map (mf/deref refs/dashboard-files)
project-id (:id project)
+ is-draft-proyect (:is-default project)
[rowref limit] (hooks/use-dynamic-grid-item-width)
@@ -132,6 +138,9 @@
(filter #(= project-id (:project-id %)))
(sort-by :modified-at)
(reverse)))
+ file-count (or (count files) 0)
+ empty-state-viewer (and you-viewer?
+ (= 0 file-count))
on-file-created
(mf/use-fn
@@ -164,12 +173,23 @@
[:*
[:& header {:team team
:project project
+ :you-viewer? you-viewer?
:create-fn create-file}]
[:section {:class (stl/css :dashboard-container :no-bg)
:ref rowref}
- [:& grid {:project project
- :files files
- :origin :files
- :create-fn create-file
- :limit limit}]]]))
+ (if empty-state-viewer
+ [:> empty-placeholder* {:title (if is-draft-proyect
+ (tr "dashboard.empty-placeholder-drafts-title")
+ (tr "dashboard.empty-placeholder-files-title"))
+ :class (stl/css :placeholder-placement)
+ :type 1
+ :subtitle (if is-draft-proyect
+ (tr "dashboard.empty-placeholder-drafts-subtitle")
+ (tr "dashboard.empty-placeholder-files-subtitle"))}]
+ [:& grid {:project project
+ :files files
+ :you-viewer? you-viewer?
+ :origin :files
+ :create-fn create-file
+ :limit limit}])]]))
diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss
index 7c37cd57c4..692eed37cd 100644
--- a/frontend/src/app/main/ui/dashboard/files.scss
+++ b/frontend/src/app/main/ui/dashboard/files.scss
@@ -35,3 +35,7 @@
@extend .button-icon;
stroke: var(--icon-foreground);
}
+
+.placeholder-placement {
+ margin: $s-16 $s-32;
+}
diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs
index 519599243d..9c2f09cf2a 100644
--- a/frontend/src/app/main/ui/dashboard/fonts.cljs
+++ b/frontend/src/app/main/ui/dashboard/fonts.cljs
@@ -16,6 +16,7 @@
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu-a11y]]
[app.main.ui.components.file-uploader :refer [file-uploader]]
+ [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.icons :as i]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.dom :as dom]
@@ -269,7 +270,7 @@
{::mf/props :obj
::mf/private true
::mf/memo true}
- [{:keys [font-id variants]}]
+ [{:keys [font-id variants you-viewer?]}]
(let [font (first variants)
menu-open* (mf/use-state false)
@@ -360,15 +361,17 @@
[:div {:class (stl/css :table-field :variants)}
(for [{:keys [id] :as item} variants]
- [:div {:class (stl/css :variant)
+ [:div {:class (stl/css-case :variant true
+ :inhert-variant you-viewer?)
:key (dm/str id)}
[:span {:class (stl/css :label)}
[:& font-variant-display-name {:variant item}]]
- [:span
- {:class (stl/css :icon :close)
- :data-id (dm/str id)
- :on-click on-delete-variant}
- i/add]])]
+ (when-not you-viewer?
+ [:span
+ {:class (stl/css :icon :close)
+ :data-id (dm/str id)
+ :on-click on-delete-variant}
+ i/add])])]
(if ^boolean edition?
[:div {:class (stl/css :table-field :options)}
@@ -382,19 +385,20 @@
:on-click on-cancel}
i/close]]
- [:div {:class (stl/css :table-field :options)}
- [:span {:class (stl/css :icon)
- :on-click on-menu-open}
- i/menu]
+ (when-not you-viewer?
+ [:div {:class (stl/css :table-field :options)}
+ [:span {:class (stl/css :icon)
+ :on-click on-menu-open}
+ i/menu]
- [:& installed-font-context-menu
- {:on-close on-menu-close
- :is-open menu-open?
- :on-delete on-delete-font
- :on-edit on-edit}]])]))
+ [:& installed-font-context-menu
+ {:on-close on-menu-close
+ :is-open menu-open?
+ :on-delete on-delete-font
+ :on-edit on-edit}]]))]))
(mf/defc installed-fonts
- [{:keys [fonts] :as props}]
+ [{:keys [fonts you-viewer?] :as props}]
(let [sterm (mf/use-state "")
matches?
@@ -407,23 +411,24 @@
(reset! sterm (str/lower val)))))]
[:div {:class (stl/css :dashboard-installed-fonts)}
- [:h3 (tr "labels.installed-fonts")]
- [:div {:class (stl/css :installed-fonts-header)}
- [:div {:class (stl/css :table-field :family)} (tr "labels.font-family")]
- [:div {:class (stl/css :table-field :variants)} (tr "labels.font-variants")]
- [:div {:class (stl/css :table-field :search-input)}
- [:input {:placeholder (tr "labels.search-font")
- :default-value ""
- :on-change on-change}]]]
-
(cond
(seq fonts)
- (for [[font-id variants] (->> (vals fonts)
- (filter matches?)
- (group-by :font-id))]
- [:& installed-font {:key (dm/str font-id "-installed")
- :font-id font-id
- :variants variants}])
+ [:*
+ [:h3 (tr "labels.installed-fonts")]
+ [:div {:class (stl/css :installed-fonts-header)}
+ [:div {:class (stl/css :table-field :family)} (tr "labels.font-family")]
+ [:div {:class (stl/css :table-field :variants)} (tr "labels.font-variants")]
+ [:div {:class (stl/css :table-field :search-input)}
+ [:input {:placeholder (tr "labels.search-font")
+ :default-value ""
+ :on-change on-change}]]]
+ (for [[font-id variants] (->> (vals fonts)
+ (filter matches?)
+ (group-by :font-id))]
+ [:& installed-font {:key (dm/str font-id "-installed")
+ :font-id font-id
+ :you-viewer? you-viewer?
+ :variants variants}])]
(nil? fonts)
[:div {:class (stl/css :fonts-placeholder)}
@@ -431,18 +436,24 @@
[:div {:class (stl/css :label)} (tr "dashboard.loading-fonts")]]
:else
- [:div {:class (stl/css :fonts-placeholder)}
- [:div {:class (stl/css :icon)} i/text]
- [:div {:class (stl/css :label)} (tr "dashboard.fonts.empty-placeholder")]])]))
+ (if you-viewer?
+ [:> empty-placeholder* {:title (tr "dashboard.fonts.empty-placeholder-viewer")
+ :subtitle (tr "dashboard.fonts.empty-placeholder-viewer-sub")
+ :type 2}]
+
+ [:div {:class (stl/css :fonts-placeholder)}
+ [:div {:class (stl/css :icon)} i/text]
+ [:div {:class (stl/css :label)} (tr "dashboard.fonts.empty-placeholder")]]))]))
(mf/defc fonts-page
- [{:keys [team] :as props}]
+ [{:keys [team you-viewer?] :as props}]
(let [fonts (mf/deref refs/dashboard-fonts)]
[:*
[:& header {:team team :section :fonts}]
[:section {:class (stl/css :dashboard-container :dashboard-fonts)}
- [:& uploaded-fonts {:team team :installed-fonts fonts}]
- [:& installed-fonts {:team team :fonts fonts}]]]))
+ (when-not you-viewer?
+ [:& uploaded-fonts {:team team :installed-fonts fonts}])
+ [:& installed-fonts {:team team :fonts fonts :you-viewer? you-viewer?}]]]))
(mf/defc font-providers-page
[{:keys [team] :as props}]
diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss
index fd40fc50db..a49a10cbf7 100644
--- a/frontend/src/app/main/ui/dashboard/fonts.scss
+++ b/frontend/src/app/main/ui/dashboard/fonts.scss
@@ -128,6 +128,7 @@
flex-wrap: wrap;
flex-grow: 1;
padding-left: $s-16;
+ gap: $s-6;
.variant {
display: flex;
@@ -135,13 +136,13 @@
align-items: center;
padding: $s-8 $s-12;
cursor: pointer;
-
+ gap: $s-4;
.icon {
display: flex;
+ align-items: center;
+ justify-content: center;
height: $s-16;
width: $s-16;
- margin-left: $s-6;
- align-items: center;
svg {
fill: none;
width: $s-12;
@@ -156,6 +157,9 @@
}
}
}
+ .inhert-variant {
+ cursor: default;
+ }
}
.table-field {
@@ -163,8 +167,6 @@
.variant {
background-color: var(--color-background-quaternary);
border-radius: $br-8;
- margin-right: $s-4;
- padding-right: $s-4;
}
}
diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs
index 15245d39c5..9ad507f684 100644
--- a/frontend/src/app/main/ui/dashboard/grid.cljs
+++ b/frontend/src/app/main/ui/dashboard/grid.cljs
@@ -73,7 +73,7 @@
(mf/defc grid-item-thumbnail
{::mf/wrap-props false}
- [{:keys [file-id revn thumbnail-id background-color]}]
+ [{:keys [file-id revn thumbnail-id background-color you-viewer?]}]
(let [container (mf/use-ref)
visible? (h/use-visible container :once? true)]
@@ -94,10 +94,12 @@
(when visible?
(if thumbnail-id
[:img {:class (stl/css :grid-item-thumbnail-image)
+ :draggable (dm/str (not you-viewer?))
:src (cf/resolve-media thumbnail-id)
:loading "lazy"
:decoding "async"}]
[:> loader* {:class (stl/css :grid-loader)
+ :draggable (dm/str (not you-viewer?))
:overlay true
:title (tr "labels.loading")}]))]))
@@ -231,7 +233,7 @@
(mf/defc grid-item
{:wrap [mf/memo]}
- [{:keys [file origin library-view?] :as props}]
+ [{:keys [file origin library-view? you-viewer?] :as props}]
(let [file-id (:id file)
;; FIXME: this breaks react hooks rule, hooks should never to
@@ -274,33 +276,34 @@
on-drag-start
(mf/use-fn
- (mf/deps selected-files)
+ (mf/deps selected-files you-viewer?)
(fn [event]
(st/emit! (dd/hide-file-menu))
- (let [offset (dom/get-offset-position (.-nativeEvent event))
+ (when-not you-viewer?
+ (let [offset (dom/get-offset-position (.-nativeEvent event))
- select-current? (not (contains? selected-files (:id file)))
+ select-current? (not (contains? selected-files (:id file)))
- item-el (mf/ref-val node-ref)
- counter-el (create-counter-element
- item-el
- (if select-current?
- 1
- (count selected-files)))]
- (when select-current?
- (st/emit! (dd/clear-selected-files))
- (st/emit! (dd/toggle-file-select file)))
+ item-el (mf/ref-val node-ref)
+ counter-el (create-counter-element
+ item-el
+ (if select-current?
+ 1
+ (count selected-files)))]
+ (when select-current?
+ (st/emit! (dd/clear-selected-files))
+ (st/emit! (dd/toggle-file-select file)))
- (dnd/set-data! event "penpot/files" "dummy")
- (dnd/set-allowed-effect! event "move")
+ (dnd/set-data! event "penpot/files" "dummy")
+ (dnd/set-allowed-effect! event "move")
;; set-drag-image requires that the element is rendered and
;; visible to the user at the moment of creating the ghost
;; image (to make a snapshot), but you may remove it right
;; afterwards, in the next render cycle.
- (dom/append-child! item-el counter-el)
- (dnd/set-drag-image! event item-el (:x offset) (:y offset))
- (ts/raf #(.removeChild ^js item-el counter-el)))))
+ (dom/append-child! item-el counter-el)
+ (dnd/set-drag-image! event item-el (:x offset) (:y offset))
+ (ts/raf #(.removeChild ^js item-el counter-el))))))
on-menu-click
(mf/use-fn
@@ -351,13 +354,12 @@
(on-select event)) ;; TODO Fix this
)))]
- [:li
- {:class (stl/css-case :grid-item true :project-th true :library library-view?)}
+ [:li {:class (stl/css-case :grid-item true :project-th true :library library-view?)}
[:button
{:class (stl/css-case :selected selected? :library library-view?)
:ref node-ref
:title (:name file)
- :draggable true
+ :draggable (dm/str (not you-viewer?))
:on-click on-select
:on-key-down handle-key-down
:on-double-click on-navigate
@@ -370,6 +372,7 @@
[:& grid-item-library {:file file}]
[:& grid-item-thumbnail
{:file-id (:id file)
+ :you-viewer? you-viewer?
:revn (:revn file)
:thumbnail-id (:thumbnail-id file)
:background-color (dm/get-in file [:data :options :background])}])
@@ -405,6 +408,7 @@
:show? (:menu-open dashboard-local)
:left (+ 24 (:x (:menu-pos dashboard-local)))
:top (:y (:menu-pos dashboard-local))
+ :you-viewer? you-viewer?
:navigate? true
:on-edit on-edit
:on-menu-close on-menu-close
@@ -412,7 +416,7 @@
:parent-id (str file-id "-action-menu")}]])]]]]]))
(mf/defc grid
- [{:keys [files project origin limit library-view? create-fn] :as props}]
+ [{:keys [files project origin limit library-view? create-fn you-viewer?] :as props}]
(let [dragging? (mf/use-state false)
project-id (:id project)
node-ref (mf/use-var nil)
@@ -429,11 +433,12 @@
on-drag-enter
(mf/use-fn
(fn [e]
- (when (and (not (dnd/has-type? e "penpot/files"))
- (or (dnd/has-type? e "Files")
- (dnd/has-type? e "application/x-moz-file")))
- (dom/prevent-default e)
- (reset! dragging? true))))
+ (when-not you-viewer?
+ (when (and (not (dnd/has-type? e "penpot/files"))
+ (or (dnd/has-type? e "Files")
+ (dnd/has-type? e "application/x-moz-file")))
+ (dom/prevent-default e)
+ (reset! dragging? true)))))
on-drag-over
(mf/use-fn
@@ -459,6 +464,7 @@
(import-files (.-files (.-dataTransfer e))))))]
[:div {:class (stl/css :dashboard-grid)
+ :dragabble (dm/str (not you-viewer?))
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
@@ -480,21 +486,22 @@
:key (:id item)
:navigate? true
:origin origin
+ :you-viewer? you-viewer?
:library-view? library-view?}])])
:else
[:& empty-placeholder
{:limit limit
+ :you-viewer? you-viewer?
:create-fn create-fn
:origin origin}])]))
(mf/defc line-grid-row
- [{:keys [files selected-files dragging? limit] :as props}]
+ [{:keys [files selected-files dragging? limit you-viewer?] :as props}]
(let [elements limit
limit (if dragging? (dec limit) limit)]
- [:ul
- {:class (stl/css :grid-row :no-wrap)
- :style {:grid-template-columns (dm/str "repeat(" elements ", 1fr)")}}
+ [:ul {:class (stl/css :grid-row :no-wrap)
+ :style {:grid-template-columns (dm/str "repeat(" elements ", 1fr)")}}
(when dragging?
[:li {:class (stl/css :grid-item :dragged)}])
@@ -504,11 +511,12 @@
{:id (:id item)
:file item
:selected-files selected-files
+ :you-viewer? you-viewer?
:key (:id item)
:navigate? false}])]))
(mf/defc line-grid
- [{:keys [project team files limit create-fn] :as props}]
+ [{:keys [project team files limit create-fn you-viewer?] :as props}]
(let [dragging? (mf/use-state false)
project-id (:id project)
team-id (:id team)
@@ -527,22 +535,23 @@
on-drag-enter
(mf/use-fn
- (mf/deps selected-project)
+ (mf/deps selected-project you-viewer?)
(fn [e]
- (cond
- (dnd/has-type? e "penpot/files")
- (do
- (dom/prevent-default e)
- (when-not (or (dnd/from-child? e)
- (dnd/broken-event? e))
- (when (not= selected-project project-id)
- (reset! dragging? true))))
+ (when-not you-viewer?
+ (cond
+ (dnd/has-type? e "penpot/files")
+ (do
+ (dom/prevent-default e)
+ (when-not (or (dnd/from-child? e)
+ (dnd/broken-event? e))
+ (when (not= selected-project project-id)
+ (reset! dragging? true))))
- (or (dnd/has-type? e "Files")
- (dnd/has-type? e "application/x-moz-file"))
- (do
- (dom/prevent-default e)
- (reset! dragging? true)))))
+ (or (dnd/has-type? e "Files")
+ (dnd/has-type? e "application/x-moz-file"))
+ (do
+ (dom/prevent-default e)
+ (reset! dragging? true))))))
on-drag-over
(mf/use-fn
@@ -586,6 +595,7 @@
(import-files (.-files (.-dataTransfer e)))))))]
[:div {:class (stl/css :dashboard-grid)
+ :dragabble (dm/str (not you-viewer?))
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
@@ -599,10 +609,12 @@
:team-id team-id
:selected-files selected-files
:dragging? @dragging?
+ :you-viewer? you-viewer?
:limit limit}]
:else
[:& empty-placeholder
{:dragging? @dragging?
:limit limit
+ :you-viewer? you-viewer?
:create-fn create-fn}])]))
diff --git a/frontend/src/app/main/ui/dashboard/libraries.cljs b/frontend/src/app/main/ui/dashboard/libraries.cljs
index 78238721e9..2ef394fa82 100644
--- a/frontend/src/app/main/ui/dashboard/libraries.cljs
+++ b/frontend/src/app/main/ui/dashboard/libraries.cljs
@@ -19,7 +19,7 @@
[rumext.v2 :as mf]))
(mf/defc libraries-page
- [{:keys [team] :as props}]
+ [{:keys [team you-viewer?] :as props}]
(let [files-map (mf/deref refs/dashboard-shared-files)
projects (mf/deref refs/dashboard-projects)
@@ -56,5 +56,6 @@
:project default-project
:origin :libraries
:limit limit
+ :you-viewer? you-viewer?
:library-view? components-v2}]]]))
diff --git a/frontend/src/app/main/ui/dashboard/placeholder.cljs b/frontend/src/app/main/ui/dashboard/placeholder.cljs
index 261fe3c4fe..f49b795ae5 100644
--- a/frontend/src/app/main/ui/dashboard/placeholder.cljs
+++ b/frontend/src/app/main/ui/dashboard/placeholder.cljs
@@ -7,13 +7,14 @@
(ns app.main.ui.dashboard.placeholder
(:require-macros [app.main.style :as stl])
(:require
+ [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.icons :as i]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc empty-placeholder
- [{:keys [dragging? limit origin create-fn]}]
+ [{:keys [dragging? limit origin create-fn you-viewer?]}]
(let [on-click
(mf/use-fn
(mf/deps create-fn)
@@ -27,14 +28,17 @@
[:li {:class (stl/css :grid-item :grid-empty-placeholder :dragged)}]]
(= :libraries origin)
- [:div {:class (stl/css :grid-empty-placeholder :libs)
- :data-testid "empty-placeholder"}
- [:div {:class (stl/css :text)}
- [:> i18n/tr-html* {:content (tr "dashboard.empty-placeholder-drafts")}]]]
+ [:> empty-placeholder* {:title (tr "dashboard.empty-placeholder-libraries-title")
+ :type 2
+ :subtitle (when you-viewer? (tr "dashboard.empty-placeholder-libraries-subtitle-viewer-role"))
+ :class (stl/css :empty-placeholder-libraries)}
+ (when-not you-viewer?
+ [:> i18n/tr-html* {:content (tr "dashboard.empty-placeholder-libraries")
+ :class (stl/css :placeholder-markdown)
+ :tag-name "span"}])]
:else
- [:div
- {:class (stl/css :grid-empty-placeholder)}
+ [:div {:class (stl/css :grid-empty-placeholder)}
[:button {:class (stl/css :create-new)
:on-click on-click}
i/add]])))
diff --git a/frontend/src/app/main/ui/dashboard/placeholder.scss b/frontend/src/app/main/ui/dashboard/placeholder.scss
index a72ebc451d..da06dd8635 100644
--- a/frontend/src/app/main/ui/dashboard/placeholder.scss
+++ b/frontend/src/app/main/ui/dashboard/placeholder.scss
@@ -6,6 +6,7 @@
@use "common/refactor/common-refactor.scss" as *;
@use "./grid.scss" as g;
+@use "../ds/typography.scss" as t;
.grid-empty-placeholder {
border-radius: $br-12;
@@ -89,3 +90,14 @@
font-size: $fs-16;
text-align: center;
}
+
+.placeholder-markdown {
+ @include t.use-typography("body-large");
+ a {
+ color: var(--color-accent-primary);
+ }
+}
+
+.empty-placeholder-libraries {
+ margin: $s-16;
+}
diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs
index a8eb4621db..36293395d1 100644
--- a/frontend/src/app/main/ui/dashboard/project_menu.cljs
+++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs
@@ -118,9 +118,6 @@
:data-testid "project-delete"})]]
[:*
- [:& udi/import-form {:ref file-input
- :project-id (:id project)
- :on-finish-import on-finish-import}]
[:& context-menu-a11y
{:on-close on-menu-close
:show show?
@@ -129,5 +126,8 @@
:top top
:left left
:options options
- :workspace false}]]))
+ :workspace false}]
+ [:& udi/import-form {:ref file-input
+ :project-id (:id project)
+ :on-finish-import on-finish-import}]]))
diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs
index 46e8828e08..34903f35fd 100644
--- a/frontend/src/app/main/ui/dashboard/projects.cljs
+++ b/frontend/src/app/main/ui/dashboard/projects.cljs
@@ -7,6 +7,7 @@
(ns app.main.ui.dashboard.projects
(:require-macros [app.main.style :as stl])
(:require
+ [app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.main.data.dashboard :as dd]
[app.main.data.events :as ev]
@@ -17,6 +18,7 @@
[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]
@@ -44,15 +46,16 @@
(mf/defc header
{::mf/wrap [mf/memo]}
- []
+ [{:keys [you-viewer?]}]
(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")]]
- [:button {:class (stl/css :btn-secondary :btn-small)
- :on-click on-click
- :data-testid "new-project-button"}
- (tr "dashboard.new-project")]]))
+ (when-not you-viewer?
+ [: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]
@@ -98,11 +101,14 @@
(l/derived :builtin-templates st/state))
(mf/defc project-item
- [{:keys [project first? team files] :as props}]
+ [{:keys [project first? team files you-viewer?] :as props}]
(let [locale (mf/deref i18n/locale)
file-count (or (:count project) 0)
project-id (:id project)
+ is-draft-proyect (:is-default project)
team-id (:id team)
+ empty-state-viewer (and you-viewer?
+ (= 0 file-count))
dstate (mf/deref refs/dashboard-local)
edit-id (:project-for-edit dstate)
@@ -198,7 +204,6 @@
(when (kbd/enter? event)
(on-create-click event))))
-
handle-menu-click
(mf/use-callback
(mf/deps on-menu-click)
@@ -220,20 +225,13 @@
:title (if (:is-default project)
(tr "labels.drafts")
(:name project))
- :on-context-menu on-menu-click}
+ :on-context-menu (when-not you-viewer? on-menu-click)}
(if (:is-default project)
(tr "labels.drafts")
(:name project))])
[:div {:class (stl/css :info-wrapper)}
- [:& 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}]
+
;; We group these two spans under a div to avoid having extra space between them.
[:div
@@ -248,29 +246,51 @@
(when-not (:is-default project)
[:> pin-button* {:class (stl/css :pin-button) :is-pinned (:is-pinned project) :on-click toggle-pin :tab-index 0}])
- [: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-not you-viewer?
+ [: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])
- [: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-not you-viewer?
+ [: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-not you-viewer?
+ [:& 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}
- [:& line-grid
- {:project project
- :team team
- :files files
- :create-fn create-file
- :limit limit}]]
+ (if empty-state-viewer
+ [:> empty-placeholder* {:title (if is-draft-proyect
+ (tr "dashboard.empty-placeholder-drafts-title")
+ (tr "dashboard.empty-placeholder-files-title"))
+ :class (stl/css :placeholder-placement)
+ :type 1
+ :subtitle (if is-draft-proyect
+ (tr "dashboard.empty-placeholder-drafts-subtitle")
+ (tr "dashboard.empty-placeholder-files-subtitle"))}]
+
+ [:& line-grid
+ {:project project
+ :team team
+ :files files
+ :create-fn create-file
+ :you-viewer? you-viewer?
+ :limit limit}])]
(when (and (> limit 0)
(> file-count limit))
@@ -293,8 +313,9 @@
(sort-by :modified-at)
(reverse))
recent-map (mf/deref recent-files-ref)
- you-owner? (get-in team [:permissions :is-owner])
- you-admin? (get-in team [:permissions :is-admin])
+ you-owner? (dm/get-in team [:permissions :is-owner])
+ you-admin? (dm/get-in team [:permissions :is-admin])
+ you-viewer? (not (dm/get-in team [:permissions :can-edit]))
can-invite? (or you-owner? you-admin?)
show-team-hero* (mf/use-state #(get storage/global ::show-team-hero true))
@@ -327,7 +348,7 @@
(when (seq projects)
[:*
- [:& header]
+ [:& header {:you-viewer? you-viewer?}]
[:div {:class (stl/css :projects-container)}
[:*
(when (and show-team-hero?
@@ -350,5 +371,6 @@
[:& project-item {:project project
:team team
:files files
+ :you-viewer? you-viewer?
:first? (= project (first projects))
:key id}]))]]]])))
diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss
index e544896eec..a40fce6a3b 100644
--- a/frontend/src/app/main/ui/dashboard/projects.scss
+++ b/frontend/src/app/main/ui/dashboard/projects.scss
@@ -128,6 +128,10 @@
padding: 0 $s-4;
}
+.placeholder-placement {
+ margin: $s-16 $s-32;
+}
+
.show-more {
--show-more-color: var(--button-secondary-foreground-color-rest);
@include buttonStyle;
diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs
index 3770fb568f..391fdccecc 100644
--- a/frontend/src/app/main/ui/dashboard/team.cljs
+++ b/frontend/src/app/main/ui/dashboard/team.cljs
@@ -23,6 +23,7 @@
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.change-owner]
[app.main.ui.dashboard.team-form]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*]]
[app.main.ui.icons :as i]
[app.main.ui.notifications.badge :refer [badge-notification]]
[app.main.ui.notifications.context-notification :refer [context-notification]]
@@ -118,13 +119,10 @@
(defn get-available-roles
[permissions]
- (->> [{:value "editor" :label (tr "labels.editor")}
+ (->> [{:value "viewer" :label (tr "labels.viewer")}
+ {:value "editor" :label (tr "labels.editor")}
(when (:is-admin permissions)
- {:value "admin" :label (tr "labels.admin")})
- ;; Temporarily disabled viewer roles
- ;; https://tree.taiga.io/project/penpot/issue/1083
- ;; {:value "viewer" :label (tr "labels.viewer")}
- ]
+ {:value "admin" :label (tr "labels.admin")})]
(filterv identity)))
(def ^:private schema:invite-member-form
@@ -256,7 +254,7 @@
(mf/defc rol-info
{::mf/wrap-props false}
- [{:keys [member team on-set-admin on-set-editor on-set-owner profile]}]
+ [{:keys [member team on-set-admin on-set-editor on-set-owner on-set-viewer profile]}]
(let [member-is-owner? (:is-owner member)
member-is-admin? (and (:is-admin member) (not member-is-owner?))
member-is-editor? (and (:can-edit member) (and (not member-is-admin?) (not member-is-owner?)))
@@ -294,12 +292,12 @@
[:li {:on-click on-set-editor
:class (stl/css :rol-dropdown-item)}
(tr "labels.editor")]
- ;; Temporarily disabled viewer role
- ;; https://tree.taiga.io/project/penpot/issue/1083
- ;; [:li {:on-click set-viewer} (tr "labels.viewer")]
+ [:li {:on-click on-set-viewer
+ :class (stl/css :rol-dropdown-item)}
+ (tr "labels.viewer")]
(when you-owner?
[:li {:on-click (partial on-set-owner member)
- :class (:stl/css :rol-dropdown-item)}
+ :class (stl/css :rol-dropdown-item)}
(tr "labels.owner")])]]]))
(mf/defc member-actions
@@ -315,22 +313,24 @@
on-show (mf/use-fn #(reset! show? true))
on-hide (mf/use-fn #(reset! show? false))]
- [:*
- (when (or is-you? (and can-delete? (not (and is-owner? (not owner?)))))
+
+ (when (or is-you? (and can-delete? (not (and is-owner? (not owner?)))))
+ [:*
[:button {:class (stl/css :menu-btn)
:on-click on-show}
- menu-icon])
+ menu-icon]
- [:& dropdown {:show @show? :on-close on-hide}
- [:ul {:class (stl/css :actions-dropdown)}
- (when is-you?
- [:li {:on-click on-leave
- :class (stl/css :action-dropdown-item)
- :key "is-you-option"} (tr "dashboard.leave-team")])
- (when (and can-delete? (not is-you?) (not (and is-owner? (not owner?))))
- [:li {:on-click on-delete
- :class (stl/css :action-dropdown-item)
- :key "is-not-you-option"} (tr "labels.remove-member")])]]]))
+
+ [:& dropdown {:show @show? :on-close on-hide}
+ [:ul {:class (stl/css :actions-dropdown)}
+ (when is-you?
+ [:li {:on-click on-leave
+ :class (stl/css :action-dropdown-item)
+ :key "is-you-option"} (tr "dashboard.leave-team")])
+ (when (and can-delete? (not is-you?) (not (and is-owner? (not owner?))))
+ [:li {:on-click on-delete
+ :class (stl/css :action-dropdown-item)
+ :key "is-not-you-option"} (tr "labels.remove-member")])]]])))
(defn- set-role! [member-id role]
(let [params {:member-id member-id :role role}]
@@ -344,6 +344,7 @@
(let [member-id (:id member)
on-set-admin (mf/use-fn (mf/deps member-id) (partial set-role! member-id :admin))
on-set-editor (mf/use-fn (mf/deps member-id) (partial set-role! member-id :editor))
+ on-set-viewer (mf/use-fn (mf/deps member-id) (partial set-role! member-id :viewer))
owner? (dm/get-in team [:permissions :is-owner])
on-set-owner
@@ -459,6 +460,7 @@
:team team
:on-set-admin on-set-admin
:on-set-editor on-set-editor
+ :on-set-viewer on-set-viewer
:on-set-owner on-set-owner
:profile profile}]]
@@ -567,7 +569,11 @@
[:li {:data-role "editor"
:class (stl/css :rol-dropdown-item)
:on-click on-change'}
- (tr "labels.editor")]]]]))
+ (tr "labels.editor")]
+ [:li {:data-role "viewer"
+ :class (stl/css :rol-dropdown-item)
+ :on-click on-change'}
+ (tr "labels.viewer")]]]]))
(mf/defc invitation-actions
{::mf/wrap-props false}
@@ -905,22 +911,25 @@
(mf/defc webhook-actions
{::mf/wrap-props false}
- [{:keys [on-edit on-delete]}]
+ [{:keys [on-edit on-delete can-edit?]}]
(let [show? (mf/use-state false)
on-show (mf/use-fn #(reset! show? true))
on-hide (mf/use-fn #(reset! show? false))]
+ (if can-edit?
+ [:*
+ [:button {:class (stl/css :menu-btn)
+ :on-click on-show}
+ menu-icon]
+ [:& dropdown {:show @show? :on-close on-hide}
+ [:ul {:class (stl/css :webhook-actions-dropdown)}
+ [:li {:on-click on-edit
+ :class (stl/css :webhook-dropdown-item)} (tr "labels.edit")]
+ [:li {:on-click on-delete
+ :class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]]
-
- [:*
- [:button {:class (stl/css :menu-btn)
- :on-click on-show}
- menu-icon]
- [:& dropdown {:show @show? :on-close on-hide}
- [:ul {:class (stl/css :webhook-actions-dropdown)}
- [:li {:on-click on-edit
- :class (stl/css :webhook-dropdown-item)} (tr "labels.edit")]
- [:li {:on-click on-delete
- :class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]]))
+ [:span {:title (tr "dashboard.webhooks.cant-edit")
+ :class (stl/css :menu-disabled)}
+ [:> icon* {:id "menu"}]])))
(mf/defc last-delivery-icon
{::mf/wrap-props false}
@@ -933,10 +942,14 @@
(mf/defc webhook-item
{::mf/wrap [mf/memo]}
- [{:keys [webhook] :as props}]
+ [{:keys [webhook permissions] :as props}]
(let [error-code (:error-code webhook)
id (:id webhook)
-
+ creator-id (:profile-id webhook)
+ profile (mf/deref refs/profile)
+ user-id (:id profile)
+ can-edit? (or (:can-edit permissions)
+ (= creator-id user-id))
on-edit
(mf/use-fn
(mf/deps webhook)
@@ -989,14 +1002,15 @@
[:div {:class (stl/css :table-field :actions)}
[:& webhook-actions
{:on-edit on-edit
+ :can-edit? can-edit?
:on-delete on-delete}]]]))
(mf/defc webhooks-list
{::mf/wrap-props false}
- [{:keys [webhooks]}]
+ [{:keys [webhooks permissions]}]
[:div {:class (stl/css :table-rows :webhook-table)}
(for [webhook webhooks]
- [:& webhook-item {:webhook webhook :key (:id webhook)}])])
+ [:& webhook-item {:webhook webhook :key (:id webhook) :permissions permissions}])])
(mf/defc team-webhooks-page
{::mf/wrap-props false}
@@ -1022,7 +1036,7 @@
[:div {:class (stl/css :webhooks-empty)}
[:div (tr "dashboard.webhooks.empty.no-webhooks")]
[:div (tr "dashboard.webhooks.empty.add-one")]]
- [:& webhooks-list {:webhooks webhooks}])]]]))
+ [:& webhooks-list {:webhooks webhooks :permissions (:permissions team)}])]]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SETTINGS SECTION
diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss
index d914ea773c..f22e4e85ff 100644
--- a/frontend/src/app/main/ui/dashboard/team.scss
+++ b/frontend/src/app/main/ui/dashboard/team.scss
@@ -252,7 +252,7 @@
// MEMBER ACTIONS
.menu-icon {
@extend .button-icon;
- stroke: var(--icon-foreground);
+ stroke: var(--color-foreground-primary);
}
.menu-btn {
@@ -405,6 +405,14 @@
position: relative;
}
+.menu-disabled {
+ color: var(--icon-foreground);
+ width: $s-28;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
.webhook-actions-dropdown {
@extend .menu-dropdown;
right: calc(-1 * $s-16);
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 84a70e0e3f..89f8a49613 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -18,6 +18,7 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.ds.notifications.toast :refer [toast*]]
+ [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.ds.storybook :as sb]
[app.util.i18n :as i18n]))
@@ -32,6 +33,7 @@
:Icon icon*
:IconButton icon-button*
:Input input*
+ :EmptyPlaceholder empty-placeholder*
:Loader loader*
:RawSvg raw-svg*
:Select select*
diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss
index 63ad1f93bd..1011d12851 100644
--- a/frontend/src/app/main/ui/ds/_sizes.scss
+++ b/frontend/src/app/main/ui/ds/_sizes.scss
@@ -10,5 +10,8 @@
$sz-16: px2rem(16);
$sz-32: px2rem(32);
$sz-36: px2rem(36);
+$sz-160: px2rem(160);
+$sz-200: px2rem(200);
$sz-224: px2rem(224);
$sz-400: px2rem(400);
+$sz-964: px2rem(964);
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs
index 2011cf4fa7..c74188edb4 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs
@@ -25,6 +25,10 @@
(def ^:svg-id marketing-layers "marketing-layers")
(def ^:svg-id penpot-logo "penpot-logo")
(def ^:svg-id penpot-logo-icon "penpot-logo-icon")
+(def ^:svg-id empty-placeholder-1-left "empty-placeholder-1-left")
+(def ^:svg-id empty-placeholder-1-right "empty-placeholder-1-right")
+(def ^:svg-id empty-placeholder-2-left "empty-placeholder-2-left")
+(def ^:svg-id empty-placeholder-2-right "empty-placeholder-2-right")
(def raw-svg-list "A collection of all raw SVG assets" (collect-raw-svgs))
diff --git a/frontend/src/app/main/ui/ds/notifications/toast.cljs b/frontend/src/app/main/ui/ds/notifications/toast.cljs
index 82f399f2f9..29968c09d2 100644
--- a/frontend/src/app/main/ui/ds/notifications/toast.cljs
+++ b/frontend/src/app/main/ui/ds/notifications/toast.cljs
@@ -12,8 +12,6 @@
[app.main.ui.ds.foundations.assets.icon :as i]
[rumext.v2 :as mf]))
-(def levels (set '("info" "warning" "error" "success")))
-
(def ^:private icons-by-level
{"info" i/info
"warning" i/msg-neutral
diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs
new file mode 100644
index 0000000000..cbbeb4173c
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.cljs
@@ -0,0 +1,40 @@
+;; 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.ds.product.empty-placeholder
+ (:require-macros
+ [app.common.data.macros :as dm]
+ [app.main.style :as stl])
+ (:require
+ [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
+ [app.main.ui.ds.foundations.typography :as t]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:empty-placeholder
+ [:map
+ [:class {:optional true} :string]
+ [:title :string]
+ [:subtitle {:optional true} [:maybe :string]]
+ [:type {:optional true} [:maybe [:enum 1 2]]]])
+
+(mf/defc empty-placeholder*
+ {::mf/props :obj
+ ::mf/schema schema:empty-placeholder}
+ [{:keys [class title subtitle type children] :rest props}]
+
+ (let [class (dm/str class " " (stl/css :empty-placeholder))
+ props (mf/spread-props props {:class class})
+ type (or type 1)
+ decoration-type (dm/str "empty-placeholder-" (str type))]
+ [:> "div" props
+ [:> raw-svg* {:id (dm/str decoration-type "-left") :class (stl/css :svg-decor)}]
+ [:div {:class (stl/css :text-wrapper)}
+ [:> text* {:as "span" :typography t/title-medium :class (stl/css :placeholder-title)} title]
+ (when subtitle
+ [:> text* {:as "span" :typography t/body-large} subtitle])
+ children]
+ [:> raw-svg* {:id (dm/str decoration-type "-right") :class (stl/css :svg-decor)}]]))
\ No newline at end of file
diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss
new file mode 100644
index 0000000000..cc4654b9e7
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss
@@ -0,0 +1,38 @@
+// 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
+
+@use "../_sizes.scss" as *;
+@use "../_borders.scss" as *;
+
+.empty-placeholder {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ place-content: center;
+ background: none;
+ color: var(--color-foreground-secondary);
+ height: $sz-160;
+ max-width: $sz-964;
+ border-radius: $br-8;
+ border: $b-1 solid var(--color-background-quaternary);
+}
+
+.text-wrapper {
+ display: grid;
+ grid-auto-rows: auto;
+ align-self: center;
+ justify-self: center;
+ max-width: $sz-400;
+}
+
+.placeholder-title {
+ color: var(--color-foreground-primary);
+}
+
+.svg-decor {
+ height: $sz-160;
+ width: $sz-200;
+ color: var(--color-background-quaternary);
+}
diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.stories.jsx b/frontend/src/app/main/ui/ds/product/empty_placeholder.stories.jsx
new file mode 100644
index 0000000000..6e86a16367
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.stories.jsx
@@ -0,0 +1,33 @@
+import * as React from "react";
+import Components from "@target/components";
+
+const { EmptyPlaceholder } = Components;
+
+export default {
+ title: "Product/EmptyPlaceholder",
+ component: EmptyPlaceholder,
+ argTypes: {
+ title: {
+ control: { type: "text" },
+ },
+ type: {
+ control: "radio",
+ options: [1, 2],
+ },
+ },
+ args: {
+ type: 1,
+ title: "Lorem ipsum",
+ subtitle:
+ "dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ },
+ render: ({ ...args }) => ,
+};
+
+export const Default = {};
+
+export const AlternativeDecoration = {
+ args: {
+ type: 2,
+ },
+};
diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs
index 50ee2fba83..4c22e6dcd7 100644
--- a/frontend/src/app/main/ui/onboarding/team_choice.cljs
+++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs
@@ -61,14 +61,16 @@
(defn- get-available-roles
[]
- [{:value "editor" :label (tr "labels.editor")}
+ [{:value "viewer" :label (tr "labels.viewer")}
+ {:value "editor" :label (tr "labels.editor")}
{:value "admin" :label (tr "labels.admin")}])
(mf/defc team-form-step-2
{::mf/props :obj}
[{:keys [name on-back go-to-team?]}]
- (let [initial (mf/with-memo []
- {:role "editor" :name name})
+ (let [initial (mf/use-memo
+ #(do {:role "editor"
+ :name name}))
form (fm/use-form :schema schema:invite-form
:initial initial)
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index a284ec28ea..96ac09b300 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -164,7 +164,7 @@
(let [layout (mf/deref refs/workspace-layout)
wglobal (mf/deref refs/workspace-global)
- read-only? (mf/deref refs/workspace-read-only?)
+
file (mf/deref refs/workspace-file)
project (mf/deref refs/workspace-project)
@@ -172,6 +172,10 @@
team-id (:team-id project)
file-name (:name file)
+ user-viewer? (not (dm/get-in file [:permissions :can-edit]))
+ read-only? (or (mf/deref refs/workspace-read-only?)
+ user-viewer?)
+
file-ready* (mf/with-memo [file-id]
(make-file-ready-ref file-id))
file-ready? (mf/deref file-ready*)
@@ -210,13 +214,14 @@
[:& (mf/provider ctx/current-page-id) {:value page-id}
[:& (mf/provider ctx/components-v2) {:value components-v2?}
[:& (mf/provider ctx/workspace-read-only?) {:value read-only?}
- [:section {:class (stl/css :workspace)
- :style {:background-color background-color
- :touch-action "none"}}
- [:& context-menu]
- (if ^boolean file-ready?
- [:& workspace-page {:page-id page-id
- :file file
- :wglobal wglobal
- :layout layout}]
- [:& workspace-loader])]]]]]]]))
+ [:& (mf/provider ctx/user-viewer?) {:value user-viewer?}
+ [:section {:class (stl/css :workspace)
+ :style {:background-color background-color
+ :touch-action "none"}}
+ [:& context-menu]
+ (if ^boolean file-ready?
+ [:& workspace-page {:page-id page-id
+ :file file
+ :wglobal wglobal
+ :layout layout}]
+ [:& workspace-loader])]]]]]]]]))
diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs
index d64b6589ed..c5c5599f9f 100644
--- a/frontend/src/app/main/ui/workspace/context_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/context_menu.cljs
@@ -31,6 +31,7 @@
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.components.shape-icon :as sic]
+ [app.main.ui.context :as ctx]
[app.main.ui.icons :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.util.dom :as dom]
@@ -534,16 +535,17 @@
:on-click do-duplicate}]]))
(mf/defc viewport-context-menu
- []
+ [{:keys [read-only?]}]
(let [focus (mf/deref refs/workspace-focus-selected)
do-paste #(st/emit! (dw/paste-from-clipboard))
do-hide-ui #(st/emit! (-> (dw/toggle-layout-flag :hide-ui)
(vary-meta assoc ::ev/origin "workspace-context-menu")))
do-toggle-focus-mode #(st/emit! (dw/toggle-focus-mode))]
[:*
- [:& menu-entry {:title (tr "workspace.shape.menu.paste")
- :shortcut (sc/get-tooltip :paste)
- :on-click do-paste}]
+ (when-not read-only?
+ [:& menu-entry {:title (tr "workspace.shape.menu.paste")
+ :shortcut (sc/get-tooltip :paste)
+ :on-click do-paste}])
[:& menu-entry {:title (tr "workspace.shape.menu.hide-ui")
:shortcut (sc/get-tooltip :hide-ui)
:on-click do-hide-ui}]
@@ -643,7 +645,8 @@
(let [mdata (mf/deref menu-ref)
top (- (get-in mdata [:position :y]) 20)
left (get-in mdata [:position :x])
- dropdown-ref (mf/use-ref)]
+ dropdown-ref (mf/use-ref)
+ read-only? (mf/use-ctx ctx/workspace-read-only?)]
(mf/use-effect
(mf/deps mdata)
@@ -666,9 +669,11 @@
:on-context-menu prevent-default}
[:ul {:class (stl/css :context-list)}
- (case (:kind mdata)
- :shape [:& shape-context-menu {:mdata mdata}]
- :page [:& page-item-context-menu {:mdata mdata}]
- :grid-track [:& grid-track-context-menu {:mdata mdata}]
- :grid-cells [:& grid-cells-context-menu {:mdata mdata}]
- [:& viewport-context-menu {:mdata mdata}])]]]))
+ (if read-only?
+ [:& viewport-context-menu {:mdata mdata :read-only? read-only?}]
+ (case (:kind mdata)
+ :shape [:& shape-context-menu {:mdata mdata}]
+ :page [:& page-item-context-menu {:mdata mdata}]
+ :grid-track [:& grid-track-context-menu {:mdata mdata}]
+ :grid-cells [:& grid-cells-context-menu {:mdata mdata}]
+ [:& viewport-context-menu {:mdata mdata}]))]]]))
diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs
index fb2ba7148f..83c4fe53d1 100644
--- a/frontend/src/app/main/ui/workspace/main_menu.cljs
+++ b/frontend/src/app/main/ui/workspace/main_menu.cljs
@@ -35,6 +35,7 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.router :as rt]
+ [beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@@ -413,7 +414,7 @@
(mf/defc edit-menu
{::mf/wrap-props false
::mf/wrap [mf/memo]}
- [{:keys [on-close]}]
+ [{:keys [on-close user-viewer?]}]
(let [select-all (mf/use-fn #(st/emit! (dw/select-all)))
undo (mf/use-fn #(st/emit! dwu/undo))
redo (mf/use-fn #(st/emit! dwu/redo))]
@@ -437,42 +438,44 @@
:key sc}
sc])]]
- [:> dropdown-menu-item* {:class (stl/css :submenu-item)
- :on-click undo
- :on-key-down (fn [event]
- (when (kbd/enter? event)
- (undo event)))
- :id "file-menu-undo"}
- [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")]
- [:span {:class (stl/css :shortcut)}
- (for [sc (scd/split-sc (sc/get-tooltip :undo))]
- [:span {:class (stl/css :shortcut-key)
- :key sc}
- sc])]]
+ (when-not :user-viewer? user-viewer?
+ [:> dropdown-menu-item* {:class (stl/css :submenu-item)
+ :on-click undo
+ :on-key-down (fn [event]
+ (when (kbd/enter? event)
+ (undo event)))
+ :id "file-menu-undo"}
+ [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.undo")]
+ [:span {:class (stl/css :shortcut)}
+ (for [sc (scd/split-sc (sc/get-tooltip :undo))]
+ [:span {:class (stl/css :shortcut-key)
+ :key sc}
+ sc])]])
- [:> dropdown-menu-item* {:class (stl/css :submenu-item)
- :on-click redo
- :on-key-down (fn [event]
- (when (kbd/enter? event)
- (redo event)))
- :id "file-menu-redo"}
- [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")]
- [:span {:class (stl/css :shortcut)}
+ (when-not :user-viewer? user-viewer?
+ [:> dropdown-menu-item* {:class (stl/css :submenu-item)
+ :on-click redo
+ :on-key-down (fn [event]
+ (when (kbd/enter? event)
+ (redo event)))
+ :id "file-menu-redo"}
+ [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.redo")]
+ [:span {:class (stl/css :shortcut)}
- (for [sc (scd/split-sc (sc/get-tooltip :redo))]
- [:span {:class (stl/css :shortcut-key)
- :key sc}
- sc])]]]))
+ (for [sc (scd/split-sc (sc/get-tooltip :redo))]
+ [:span {:class (stl/css :shortcut-key)
+ :key sc}
+ sc])]])]))
(mf/defc file-menu
{::mf/wrap-props false}
- [{:keys [on-close file]}]
- (let [file-id (:id file)
- shared? (:is-shared file)
+ [{:keys [on-close file user-viewer?]}]
+ (let [file-id (:id file)
+ shared? (:is-shared file)
- objects (mf/deref refs/workspace-page-objects)
- frames (->> (cfh/get-immediate-children objects uuid/zero)
- (filterv cfh/frame-shape?))
+ objects (mf/deref refs/workspace-page-objects)
+ frames (->> (cfh/get-immediate-children objects uuid/zero)
+ (filterv cfh/frame-shape?))
on-remove-shared
(mf/use-fn
@@ -565,11 +568,12 @@
:id "file-menu-remove-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.unpublish-shared")]]
- [:> dropdown-menu-item* {:class (stl/css :submenu-item)
- :on-click on-add-shared
- :on-key-down on-add-shared-key-down
- :id "file-menu-add-shared"}
- [:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]])
+ (when-not user-viewer?
+ [:> dropdown-menu-item* {:class (stl/css :submenu-item)
+ :on-click on-add-shared
+ :on-key-down on-add-shared-key-down
+ :id "file-menu-add-shared"}
+ [:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]]))
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-shapes
@@ -657,6 +661,8 @@
sub-menu* (mf/use-state false)
sub-menu (deref sub-menu*)
+ user-viewer? (mf/use-ctx ctx/user-viewer?)
+
open-menu
(mf/use-fn
(fn [event]
@@ -675,6 +681,12 @@
(dom/stop-propagation event)
(reset! sub-menu* nil)))
+ close-all-menus
+ (mf/use-fn
+ (fn []
+ (reset! show-menu* false)
+ (reset! sub-menu* nil)))
+
on-menu-click
(mf/use-fn
(fn [event]
@@ -713,6 +725,12 @@
(ptk/event ::ev/event {::ev/name "open-plugins-manager" ::ev/origin "workspace:menu"})
(modal/show :plugin-management {}))))]
+ (mf/with-effect []
+ (let [disposable (->> st/stream
+ (rx/filter #(= :interrupt %))
+ (rx/subs! close-all-menus))]
+ (partial rx/dispose! disposable)))
+
[:*
[:div {:on-click open-menu
@@ -793,11 +811,13 @@
:file
[:& file-menu
{:file file
- :on-close close-sub-menu}]
+ :on-close close-sub-menu
+ :user-viewer? user-viewer?}]
:edit
[:& edit-menu
- {:on-close close-sub-menu}]
+ {:on-close close-sub-menu
+ :user-viewer? user-viewer?}]
:view
[:& view-menu
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
index 7f7e67d926..06e12e3f07 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs
@@ -184,8 +184,8 @@
on-click
(mf/use-fn
- (mf/deps color-id apply-color on-asset-click)
- (do
+ (mf/deps color-id apply-color on-asset-click read-only?)
+ (when-not read-only?
(dwl/add-recent-color color)
(partial on-asset-click color-id apply-color)))]
diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
index 022bbebc9f..5b8f2a3a8d 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs
@@ -272,9 +272,10 @@
apply-typography
(mf/use-fn
- (mf/deps file-id)
+ (mf/deps file-id read-only?)
(fn [typography _event]
- (st/emit! (dwt/apply-typography typography file-id))))
+ (when-not read-only?
+ (st/emit! (dwt/apply-typography typography file-id)))))
create-group
(mf/use-fn
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs
index abd2aaf7e8..a7505dde31 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs
@@ -13,6 +13,7 @@
[app.common.geom.shapes :as gsh]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace :as udw]
+ [app.main.data.workspace.common :as dwc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@@ -134,6 +135,8 @@
[{:keys [selected shapes shapes-with-children page-id file-id on-change-section on-expand]}]
(let [objects (mf/deref refs/workspace-page-objects)
+ user-viewer? (mf/use-ctx ctx/user-viewer?)
+
selected-shapes (into [] (keep (d/getf objects)) selected)
first-selected-shape (first selected-shapes)
shape-parent-frame (cfh/get-frame objects (:frame-id first-selected-shape))
@@ -145,8 +148,8 @@
(let [options-mode (keyword options-mode)]
(st/emit! (udw/set-options-mode options-mode))
(if (= options-mode :inspect)
- (st/emit! :interrupt (udw/set-workspace-read-only true))
- (st/emit! :interrupt (udw/set-workspace-read-only false)))))
+ (st/emit! :interrupt (dwc/set-workspace-read-only true))
+ (st/emit! :interrupt (dwc/set-workspace-read-only false)))))
design-content
(mf/html [:& design-menu {:selected selected
@@ -173,17 +176,21 @@
tabs
- #js [#js {:label (tr "workspace.options.design")
- :id "design"
- :content design-content}
+ (if user-viewer?
+ #js [#js {:label (tr "workspace.options.inspect")
+ :id "inspect"
+ :content inspect-content}]
+ #js [#js {:label (tr "workspace.options.design")
+ :id "design"
+ :content design-content}
- #js {:label (tr "workspace.options.prototype")
- :id "prototype"
- :content interactions-content}
+ #js {:label (tr "workspace.options.prototype")
+ :id "prototype"
+ :content interactions-content}
- #js {:label (tr "workspace.options.inspect")
- :id "inspect"
- :content inspect-content}]]
+ #js {:label (tr "workspace.options.inspect")
+ :id "inspect"
+ :content inspect-content}])]
[:div {:class (stl/css :tool-window)}
[:> tab-switcher* {:tabs tabs
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs
index e6c904beb3..a24c3053d2 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs
@@ -62,8 +62,6 @@
on-change
(mf/use-fn
(fn [new-color old-color from-picker?]
- (prn "new-color" new-color)
- (prn "old-color" old-color)
(let [old-color (-> old-color
(dissoc :name :path)
(d/without-nils))
diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
index 09a02d2f97..fb2c6bc37f 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
@@ -205,7 +205,9 @@
(fn [event]
(st/emit! (dw/create-page {:file-id file-id :project-id project-id}))
(-> event dom/get-current-target dom/blur!)))
- read-only? (mf/use-ctx ctx/workspace-read-only?)]
+ read-only? (mf/use-ctx ctx/workspace-read-only?)
+ user-viewer? (mf/use-ctx ctx/user-viewer?)]
+
[:div {:class (stl/css :sitemap)
:style #js {"--height" (str size "px")}}
@@ -218,9 +220,10 @@
:class (stl/css :title-spacing-sitemap)}
(if ^boolean read-only?
- [:& badge-notification {:is-focus true
- :size :small
- :content (tr "labels.view-only")}]
+ (when (not ^boolean user-viewer?)
+ [:& badge-notification {:is-focus true
+ :size :small
+ :content (tr "labels.view-only")}])
[:button {:class (stl/css :add-page)
:on-click on-create}
i/add])]
diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs
index 7040c2c1e5..98d4d2f9b0 100644
--- a/frontend/src/app/main/ui/workspace/viewport.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport.cljs
@@ -96,6 +96,7 @@
vbox' (mf/use-debounce 100 vbox)
;; DEREFS
+ user-viewer? (mf/use-ctx ctx/user-viewer?)
drawing (mf/deref refs/workspace-drawing)
focus (mf/deref refs/workspace-focus-selected)
@@ -277,7 +278,8 @@
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
[:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"}
- [:& top-bar/top-bar {:layout layout}]
+ (when-not user-viewer?
+ [:& top-bar/top-bar {:layout layout}])
[:div {:class (stl/css :viewport-overlays)}
;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap
;; inside a foreign object "dummy" so this awkward behaviour is take into account
@@ -286,12 +288,13 @@
[:div {:style {:pointer-events (when-not (dbg/enabled? :html-text) "none")
;; some opacity because to debug auto-width events will fill the screen
:opacity 0.6}}
- [:& stvh/viewport-texts
- {:key (dm/str "texts-" page-id)
- :page-id page-id
- :objects objects
- :modifiers modifiers
- :edition edition}]]]]
+ (when-not workspace-read-only?
+ [:& stvh/viewport-texts
+ {:key (dm/str "texts-" page-id)
+ :page-id page-id
+ :objects objects
+ :modifiers modifiers
+ :edition edition}])]]]
(when show-comments?
[:& comments/comments-layer {:vbox vbox
diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
index 8aaef6be1c..6f104dedff 100644
--- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
@@ -240,20 +240,19 @@
(mf/deps @hover @hover-ids workspace-read-only?)
(fn [event]
(dom/prevent-default event)
- (when-not workspace-read-only?
- (when (or (dom/class? (dom/get-target event) "viewport-controls")
- (dom/child? (dom/get-target event) (dom/query ".grid-layout-editor"))
- (dom/class? (dom/get-target event) "viewport-selrect")
- workspace-read-only?)
- (let [position (dom/get-client-position event)]
+ ;;(when-not workspace-read-only?
+ (when (or (dom/class? (dom/get-target event) "viewport-controls")
+ (dom/child? (dom/get-target event) (dom/query ".grid-layout-editor"))
+ (dom/class? (dom/get-target event) "viewport-selrect"))
+ (let [position (dom/get-client-position event)]
;; Delayed callback because we need to wait to the previous context menu to be closed
- (ts/schedule
- #(st/emit!
- (if (some? @hover)
- (dw/show-shape-context-menu {:position position
- :shape @hover
- :hover-ids @hover-ids})
- (dw/show-context-menu {:position position}))))))))))
+ (ts/schedule
+ #(st/emit!
+ (if (and (not workspace-read-only?) (some? @hover))
+ (dw/show-shape-context-menu {:position position
+ :shape @hover
+ :hover-ids @hover-ids})
+ (dw/show-context-menu {:position position})))))))))
(defn on-menu-selected
[hover hover-ids selected workspace-read-only?]
diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs
index 7c2963ae1b..6767735dbf 100644
--- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs
@@ -10,6 +10,7 @@
[app.common.files.helpers :as cfh]
[app.common.types.shape.layout :as ctl]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.common :as dwc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as ctx]
@@ -26,7 +27,7 @@
(fn []
(st/emit! :interrupt
(dw/set-options-mode :design)
- (dw/set-workspace-read-only false))))]
+ (dwc/set-workspace-read-only false))))]
[:div {:class (stl/css :viewport-actions)}
[:div {:class (stl/css :viewport-actions-container)}
[:div {:class (stl/css :viewport-actions-title)}
diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs
index d127c4bf1a..d758c09d42 100644
--- a/frontend/src/debug.cljs
+++ b/frontend/src/debug.cljs
@@ -23,6 +23,7 @@
[app.main.data.preview :as dp]
[app.main.data.viewer.shortcuts]
[app.main.data.workspace :as dw]
+ [app.main.data.workspace.common :as dwcm]
[app.main.data.workspace.path.shortcuts]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shortcuts]
@@ -370,7 +371,7 @@
(defn ^:export set-workspace-read-only
[read-only?]
- (st/emit! (dw/set-workspace-read-only read-only?)))
+ (st/emit! (dwcm/set-workspace-read-only read-only?)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; REPAIR & VALIDATION
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index b0b931a058..4613596581 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -434,11 +434,53 @@ msgstr "Duplicate %s files"
#: src/app/main/ui/dashboard/placeholder.cljs:33
#, markdown
-msgid "dashboard.empty-placeholder-drafts"
+msgid "dashboard.empty-placeholder-libraries"
msgstr ""
-"Files added to Libraries will appear here. Try sharing your files or add "
+"Libraries added to the project will appear here. Try sharing your files or add "
"from our [Libraries & templates](https://penpot.app/libraries-templates)."
+#: src/app/main/ui/dashboard/placeholder.cljs
+msgid "dashboard.empty-placeholder-libraries-title"
+msgstr "No libraries yet."
+
+#: src/app/main/ui/dashboard/placeholder.cljs
+#, markdown
+msgid "dashboard.empty-placeholder-libraries-subtitle"
+msgstr ""
+"Libraries added to the project will appear here. Try sharing your files or add "
+"from our [Libraries & templates](https://penpot.app/libraries-templates)."
+
+#: src/app/main/ui/dashboard/placeholder.cljs
+msgid "dashboard.empty-placeholder-libraries-subtitle-viewer-role"
+msgstr "Libraries added to the project will appear here."
+
+#: src/app/main/ui/dashboard
+msgid "dashboard.empty-placeholder-drafts-title"
+msgstr "No drafts yet."
+
+#: src/app/main/ui/dashboard
+msgid "dashboard.empty-placeholder-drafts-subtitle"
+msgstr "Once a project member creates a draft, it will be displayed here."
+
+#: src/app/main/ui/dashboard
+msgid "dashboard.empty-placeholder-files-title"
+msgstr "No files yet."
+
+#: src/app/main/ui/dashboard
+msgid "dashboard.empty-placeholder-files-subtitle"
+msgstr "Once a project member creates a file, it will be displayed here."
+
+
+
+
+
+
+
+
+
+
+
+
#: src/app/main/ui/dashboard/file_menu.cljs:249
msgid "dashboard.export-binary-multi"
msgstr "Download %s Penpot files (.penpot)"
@@ -541,6 +583,14 @@ msgstr "Dismiss all"
msgid "dashboard.fonts.empty-placeholder"
msgstr "Custom fonts you upload will appear here."
+#: src/app/main/ui/dashboard/fonts.cljs:436
+msgid "dashboard.fonts.empty-placeholder-viewer"
+msgstr "No custom fonts yet."
+
+#: src/app/main/ui/dashboard/fonts.cljs:436
+msgid "dashboard.fonts.empty-placeholder-viewer-sub"
+msgstr "Once a project member uploads a custom font, it will be displayed here."
+
#: src/app/main/ui/dashboard/fonts.cljs:194
msgid "dashboard.fonts.fonts-added"
msgid_plural "dashboard.fonts.fonts-added"
@@ -695,6 +745,26 @@ msgstr "+ New project"
msgid "dashboard.new-project-prefix"
msgstr "New Project"
+#: src/app/main/data/common.cljs:72
+msgid "dashboard.permissions-change.viewer"
+msgstr "You are now a viewer on this team."
+
+#: src/app/main/data/common.cljs:75
+msgid "dashboard.permissions-change.editor"
+msgstr "You are now an editor on this team."
+
+#: src/app/main/data/common.cljs:78
+msgid "dashboard.permissions-change.admin"
+msgstr "You are now an admin on this team."
+
+#: src/app/main/data/common.cljs:195
+msgid "dashboard.permissions-change.owner"
+msgstr "You are now owner on this team."
+
+#: src/app/main/data/common.cljs:229
+msgid "dashboard.removed-from-team"
+msgstr "You are not part of the team “%s“ anymore."
+
#: src/app/main/ui/dashboard/search.cljs:60
msgid "dashboard.no-matches-for"
msgstr "No matches found for “%s“"
@@ -875,6 +945,10 @@ msgstr "No webhooks created so far."
msgid "dashboard.webhooks.update.success"
msgstr "Webhook updated successfully."
+#: src/app/main/ui/dashboard/team.cljs
+msgid "dashboard.webhooks.cant-edit"
+msgstr "You only can delete or modify webhooks created by you."
+
#: src/app/main/ui/settings.cljs:31
msgid "dashboard.your-account-title"
msgstr "Your account"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 7400fd0b9c..ef9a8e7548 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -436,12 +436,55 @@ msgstr "Duplicar %s archivos"
#: src/app/main/ui/dashboard/placeholder.cljs:33
#, markdown
-msgid "dashboard.empty-placeholder-drafts"
+msgid "dashboard.empty-placeholder-libraries"
msgstr ""
-"Los archivos agregados a las bibliotecas aparecerán aquí. Si quieres probar "
+"Las bibliotecas añadidas al proyecto aparecerán aquí. Si quieres probar "
"con alguna plantilla ve a [Bibliotecas y "
"plantillas](https://penpot.app/libraries-templates)."
+#: src/app/main/ui/dashboard/placeholder.cljs
+msgid "dashboard.empty-placeholder-libraries-title"
+msgstr "Aún no existen librerías compartidas."
+
+#: src/app/main/ui/dashboard/placeholder.cljs
+#, markdown
+msgid "dashboard.empty-placeholder-libraries-subtitle"
+msgstr ""
+"Las bibliotecas añadidas al proyecto aparecerán aquí. Si quieres probar "
+"con alguna plantilla ve a [Bibliotecas y "
+"plantillas](https://penpot.app/libraries-templates)."
+
+#: src/app/main/ui/dashboard/placeholder.cljs
+msgid "dashboard.empty-placeholder-libraries-subtitle-viewer-role"
+msgstr "Las bibliotecas añadidas al proyecto aparecerán aquí."
+
+#: src/app/main/ui/dashboard/files.cljs
+msgid "dashboard.empty-placeholder-drafts-title"
+msgstr "Aún no hay borradores."
+
+#: src/app/main/ui/dashboard/files.cljs
+msgid "dashboard.empty-placeholder-drafts-subtitle"
+msgstr "Cuando un miembro del equipo cree algún borrador, este aparecerá aquí."
+
+#: src/app/main/ui/dashboard/files.cljs
+msgid "dashboard.empty-placeholder-files-title"
+msgstr "Aún no hay archivos."
+
+#: src/app/main/ui/dashboard/files.cljs
+msgid "dashboard.empty-placeholder-files-subtitle"
+msgstr "Cuando un miembro del equipo cree algún archivo, este aparecerá aquí."
+
+
+
+
+
+
+
+
+
+
+
+
#: src/app/main/ui/dashboard/file_menu.cljs:249
msgid "dashboard.export-binary-multi"
msgstr "Descargar %s archivos Penpot (.penpot)"
@@ -544,6 +587,14 @@ msgstr "Ignorar todas"
msgid "dashboard.fonts.empty-placeholder"
msgstr "Las fuentes personalizadas que subas aparecerán aquí."
+#: src/app/main/ui/dashboard/fonts.cljs:436
+msgid "dashboard.fonts.empty-placeholder-viewer"
+msgstr "Aún no hay fuentes personalizadas."
+
+#: src/app/main/ui/dashboard/fonts.cljs:436
+msgid "dashboard.fonts.empty-placeholder-viewer-sub"
+msgstr "Cuando un miembro del equipo suba una fuente personalizada, esta aparecerá aquí."
+
#: src/app/main/ui/dashboard/fonts.cljs:194
msgid "dashboard.fonts.fonts-added"
msgid_plural "dashboard.fonts.fonts-added"
@@ -702,6 +753,26 @@ msgstr "+ Nuevo proyecto"
msgid "dashboard.new-project-prefix"
msgstr "Nuevo Proyecto"
+#: src/app/main/data/common.cljs:72
+msgid "dashboard.permissions-change.viewer"
+msgstr "Ahora eres lector del equipo."
+
+#: src/app/main/data/common.cljs:75
+msgid "dashboard.permissions-change.editor"
+msgstr "Ahora eres editor del equipo."
+
+#: src/app/main/data/common.cljs:78
+msgid "dashboard.permissions-change.admin"
+msgstr "Ahora eres administrador del equipo."
+
+#: src/app/main/data/common.cljs:81
+msgid "dashboard.permissions-change.owner"
+msgstr "Ahora eres el dueño del equipo."
+
+#: src/app/main/data/common.cljs:229
+msgid "dashboard.removed-from-team"
+msgstr "Ya no eres parte del equipo “%s“."
+
#: src/app/main/ui/dashboard/search.cljs:60
msgid "dashboard.no-matches-for"
msgstr "No se encuentra “%s“"
@@ -882,6 +953,10 @@ msgstr "No hay ningún webhook aún."
msgid "dashboard.webhooks.update.success"
msgstr "Webhook modificado con éxito."
+#: src/app/main/ui/dashboard/team.cljs
+msgid "dashboard.webhooks.cant-edit"
+msgstr "Sólo puedes borrar o modificar webhooks creados por ti."
+
#: src/app/main/ui/settings.cljs:31
msgid "dashboard.your-account-title"
msgstr "Tu cuenta"
@@ -2025,7 +2100,7 @@ msgstr "Solo lectura"
#: src/app/main/ui/dashboard/team.cljs:128, src/app/main/ui/dashboard/team.cljs:301, src/app/main/ui/dashboard/team.cljs:540
msgid "labels.viewer"
-msgstr "Visualizador"
+msgstr "Lector"
#: src/app/main/ui/dashboard/sidebar.cljs:523, src/app/main/ui/dashboard/team.cljs:95, src/app/main/ui/dashboard/team.cljs:105, src/app/main/ui/dashboard/team.cljs:901
msgid "labels.webhooks"