Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2024-03-26 09:44:10 +01:00
commit 1f5658ad1b
44 changed files with 1539 additions and 605 deletions

View file

@ -9,31 +9,24 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.transit :as t]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http :as-alias http] [app.http :as-alias http]
[app.http.access-token :as-alias actoken] [app.http.access-token :as-alias actoken]
[app.http.client :as http.client]
[app.loggers.audit.tasks :as-alias tasks] [app.loggers.audit.tasks :as-alias tasks]
[app.loggers.webhooks :as-alias webhooks] [app.loggers.webhooks :as-alias webhooks]
[app.main :as-alias main]
[app.rpc :as-alias rpc] [app.rpc :as-alias rpc]
[app.rpc.retry :as rtry] [app.rpc.retry :as rtry]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.services :as-alias sv] [app.util.services :as-alias sv]
[app.util.time :as dt] [app.util.time :as dt]
[app.worker :as wrk] [app.worker :as wrk]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[integrant.core :as ig] [integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.exec :as px]
[ring.request :as rreq])) [ring.request :as rreq]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -195,14 +188,14 @@
:profile-id (::profile-id event) :profile-id (::profile-id event)
:ip-addr (::ip-addr event) :ip-addr (::ip-addr event)
:context (::context event) :context (::context event)
:props (::props event)}] :props (::props event)}
tnow (dt/now)]
(when (contains? cf/flags :audit-log) (when (contains? cf/flags :audit-log)
;; NOTE: this operation may cause primary key conflicts on inserts ;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in ;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation. ;; this case we just retry the operation.
(let [tnow (dt/now) (let [params (-> params
params (-> params
(assoc :created-at tnow) (assoc :created-at tnow)
(assoc :tracked-at tnow) (assoc :tracked-at tnow)
(update :props db/tjson) (update :props db/tjson)
@ -211,6 +204,23 @@
(assoc :source "backend"))] (assoc :source "backend"))]
(db/insert! cfg :audit-log params))) (db/insert! cfg :audit-log params)))
(when (and (or (contains? cf/flags :telemetry)
(cf/get :telemetry-enabled))
(not (contains? cf/flags :audit-log)))
;; NOTE: this operation may cause primary key conflicts on inserts
;; because of the timestamp precission (two concurrent requests), in
;; this case we just retry the operation.
;;
;; NOTE: this is only executed when general audit log is disabled
(let [params (-> params
(assoc :created-at tnow)
(assoc :tracked-at tnow)
(assoc :props (db/tjson {}))
(assoc :context (db/tjson {}))
(assoc :ip-addr (db/inet "0.0.0.0"))
(assoc :source "backend"))]
(db/insert! cfg :audit-log params)))
(when (and (contains? cf/flags :webhooks) (when (and (contains? cf/flags :webhooks)
(::webhooks/event? event)) (::webhooks/event? event))
(let [batch-key (::webhooks/batch-key event) (let [batch-key (::webhooks/batch-key event)
@ -249,137 +259,3 @@
(rtry/invoke! cfg db/tx-run! handle-event! event)) (rtry/invoke! cfg db/tx-run! handle-event! event))
(catch Throwable cause (catch Throwable cause
(l/error :hint "unexpected error processing event" :cause cause)))) (l/error :hint "unexpected error processing event" :cause cause))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK: ARCHIVE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; This is a task responsible to send the accumulated events to
;; external service for archival.
(declare archive-events)
(s/def ::tasks/uri ::us/string)
(defmethod ig/pre-init-spec ::tasks/archive-task [_]
(s/keys :req [::db/pool ::setup/props ::http.client/client]))
(defmethod ig/init-key ::tasks/archive
[_ cfg]
(fn [params]
;; NOTE: this let allows overwrite default configured values from
;; the repl, when manually invoking the task.
(let [enabled (or (contains? cf/flags :audit-log-archive)
(:enabled params false))
uri (cf/get :audit-log-archive-uri)
uri (or uri (:uri params))
cfg (assoc cfg ::uri uri)]
(when (and enabled (not uri))
(ex/raise :type :internal
:code :task-not-configured
:hint "archive task not configured, missing uri"))
(when enabled
(loop [total 0]
(let [n (archive-events cfg)]
(if n
(do
(px/sleep 100)
(recur (+ total ^long n)))
(when (pos? total)
(l/dbg :hint "events archived" :total total)))))))))
(def ^:private sql:retrieve-batch-of-audit-log
"select *
from audit_log
where archived_at is null
order by created_at asc
limit 128
for update skip locked;")
(defn archive-events
[{:keys [::db/pool ::uri] :as cfg}]
(letfn [(decode-row [{:keys [props ip-addr context] :as row}]
(cond-> row
(db/pgobject? props)
(assoc :props (db/decode-transit-pgobject props))
(db/pgobject? context)
(assoc :context (db/decode-transit-pgobject context))
(db/pgobject? ip-addr "inet")
(assoc :ip-addr (db/decode-inet ip-addr))))
(row->event [row]
(select-keys row [:type
:name
:source
:created-at
:tracked-at
:profile-id
:ip-addr
:props
:context]))
(send [events]
(let [token (tokens/generate (::setup/props cfg)
{:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000
:method :post
:headers headers
:body body}
resp (http.client/req! cfg params)]
(if (= (:status resp) 204)
true
(do
(l/error :hint "unable to archive events"
:resp-status (:status resp)
:resp-body (:body resp))
false))))
(mark-as-archived [conn rows]
(db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)"
(->> (map :id rows)
(db/create-array conn "uuid"))]))]
(db/with-atomic [conn pool]
(let [rows (db/exec! conn [sql:retrieve-batch-of-audit-log])
xform (comp (map decode-row)
(map row->event))
events (into [] xform rows)]
(when-not (empty? events)
(l/trc :hint "archive events chunk" :uri uri :events (count events))
(when (send events)
(mark-as-archived conn rows)
(count events)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GC Task
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:clean-archived
"delete from audit_log
where archived_at is not null")
(defn- clean-archived
[{:keys [::db/pool]}]
(let [result (db/exec-one! pool [sql:clean-archived])
result (:next.jdbc/update-count result)]
(l/debug :hint "delete archived audit log entries" :deleted result)
result))
(defmethod ig/pre-init-spec ::tasks/gc [_]
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::tasks/gc
[_ cfg]
(fn [_]
(clean-archived cfg)))

View file

@ -0,0 +1,140 @@
;; 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.loggers.audit.archive-task
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.http.client :as http]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.exec :as px]))
;; This is a task responsible to send the accumulated events to
;; external service for archival.
(defn- decode-row
[{:keys [props ip-addr context] :as row}]
(cond-> row
(db/pgobject? props)
(assoc :props (db/decode-transit-pgobject props))
(db/pgobject? context)
(assoc :context (db/decode-transit-pgobject context))
(db/pgobject? ip-addr "inet")
(assoc :ip-addr (db/decode-inet ip-addr))))
(def ^:private event-keys
[:type
:name
:source
:created-at
:tracked-at
:profile-id
:ip-addr
:props
:context])
(defn- row->event
[row]
(select-keys row event-keys))
(defn- send!
[{:keys [::uri] :as cfg} events]
(let [token (tokens/generate (::setup/props cfg)
{:iss "authentication"
:iat (dt/now)
:uid uuid/zero})
body (t/encode {:events events})
headers {"content-type" "application/transit+json"
"origin" (cf/get :public-uri)
"cookie" (u/map->query-string {:auth-token token})}
params {:uri uri
:timeout 12000
:method :post
:headers headers
:body body}
resp (http/req! cfg params)]
(if (= (:status resp) 204)
true
(do
(l/error :hint "unable to archive events"
:resp-status (:status resp)
:resp-body (:body resp))
false))))
(defn- mark-archived!
[{:keys [::db/conn]} rows]
(let [ids (db/create-array conn "uuid" (map :id rows))]
(db/exec-one! conn ["update audit_log set archived_at=now() where id = ANY(?)" ids])))
(def ^:private xf:create-event
(comp (map decode-row)
(map row->event)))
(def ^:private sql:get-audit-log-chunk
"SELECT *
FROM audit_log
WHERE archived_at is null
ORDER BY created_at ASC
LIMIT 128
FOR UPDATE
SKIP LOCKED")
(defn- get-event-rows
[{:keys [::db/conn] :as cfg}]
(->> (db/exec! conn [sql:get-audit-log-chunk])
(not-empty)))
(defn- archive-events!
[{:keys [::uri] :as cfg}]
(db/tx-run! cfg (fn [cfg]
(when-let [rows (get-event-rows cfg)]
(let [events (into [] xf:create-event rows)]
(l/trc :hint "archive events chunk" :uri uri :events (count events))
(when (send! cfg events)
(mark-archived! cfg rows)
(count events)))))))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::db/pool ::setup/props ::http/client]))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [params]
;; NOTE: this let allows overwrite default configured values from
;; the repl, when manually invoking the task.
(let [enabled (or (contains? cf/flags :audit-log-archive)
(:enabled params false))
uri (cf/get :audit-log-archive-uri)
uri (or uri (:uri params))
cfg (assoc cfg ::uri uri)]
(when (and enabled (not uri))
(ex/raise :type :internal
:code :task-not-configured
:hint "archive task not configured, missing uri"))
(when enabled
(loop [total 0]
(if-let [n (archive-events! cfg)]
(do
(px/sleep 100)
(recur (+ total ^long n)))
(when (pos? total)
(l/dbg :hint "events archived" :total total))))))))

View file

@ -0,0 +1,31 @@
;; 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.loggers.audit.gc-task
(:require
[app.common.logging :as l]
[app.db :as db]
[clojure.spec.alpha :as s]
[integrant.core :as ig]))
(def ^:private sql:clean-archived
"DELETE FROM audit_log
WHERE archived_at IS NOT NULL")
(defn- clean-archived!
[{:keys [::db/pool]}]
(let [result (db/exec-one! pool [sql:clean-archived])
result (db/get-update-count result)]
(l/debug :hint "delete archived audit log entries" :deleted result)
result))
(defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::db/pool]))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [_]
(clean-archived! cfg)))

View file

@ -21,7 +21,6 @@
[app.http.session :as-alias session] [app.http.session :as-alias session]
[app.http.session.tasks :as-alias session.tasks] [app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws] [app.http.websocket :as http.ws]
[app.loggers.audit.tasks :as-alias audit.tasks]
[app.loggers.webhooks :as-alias webhooks] [app.loggers.webhooks :as-alias webhooks]
[app.metrics :as-alias mtx] [app.metrics :as-alias mtx]
[app.metrics.definition :as-alias mdef] [app.metrics.definition :as-alias mdef]
@ -346,8 +345,8 @@
:storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler)
:storage-gc-touched (ig/ref ::sto.gc-touched/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler)
:session-gc (ig/ref ::session.tasks/gc) :session-gc (ig/ref ::session.tasks/gc)
:audit-log-archive (ig/ref ::audit.tasks/archive) :audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler)
:audit-log-gc (ig/ref ::audit.tasks/gc) :audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler)
:process-webhook-event :process-webhook-event
(ig/ref ::webhooks/process-event-handler) (ig/ref ::webhooks/process-event-handler)
@ -411,12 +410,12 @@
::svgo/optimizer ::svgo/optimizer
{} {}
::audit.tasks/archive :app.loggers.audit.archive-task/handler
{::setup/props (ig/ref ::setup/props) {::setup/props (ig/ref ::setup/props)
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::http.client/client (ig/ref ::http.client/client)} ::http.client/client (ig/ref ::http.client/client)}
::audit.tasks/gc :app.loggers.audit.gc-task/handler
{::db/pool (ig/ref ::db/pool)} {::db/pool (ig/ref ::db/pool)}
::webhooks/process-event-handler ::webhooks/process-event-handler

View file

@ -376,7 +376,10 @@
:fn (mg/resource "app/migrations/sql/0118-mod-task-table.sql")} :fn (mg/resource "app/migrations/sql/0118-mod-task-table.sql")}
{:name "0119-mod-file-table" {:name "0119-mod-file-table"
:fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")}]) :fn (mg/resource "app/migrations/sql/0119-mod-file-table.sql")}
{:name "0120-mod-audit-log-table"
:fn (mg/resource "app/migrations/sql/0120-mod-audit-log-table.sql")}])
(defn apply-migrations! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View file

@ -0,0 +1,11 @@
CREATE TABLE new_audit_log (LIKE audit_log INCLUDING ALL);
INSERT INTO new_audit_log SELECT * FROM audit_log;
ALTER TABLE audit_log RENAME TO old_audit_log;
ALTER TABLE new_audit_log RENAME TO audit_log;
DROP TABLE old_audit_log;
DROP INDEX new_audit_log_id_archived_at_idx;
ALTER TABLE audit_log DROP CONSTRAINT new_audit_log_pkey;
ALTER TABLE audit_log ADD PRIMARY KEY (id);
ALTER TABLE audit_log ALTER COLUMN created_at SET DEFAULT now();
ALTER TABLE audit_log ALTER COLUMN tracked_at SET DEFAULT now();

View file

@ -19,7 +19,20 @@
[app.rpc.climit :as-alias climit] [app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph] [app.rpc.helpers :as rph]
[app.util.services :as sv])) [app.util.services :as sv]
[app.util.time :as dt]))
(def ^:private event-columns
[:id
:name
:source
:type
:tracked-at
:created-at
:profile-id
:ip-addr
:props
:context])
(defn- event->row [event] (defn- event->row [event]
[(uuid/next) [(uuid/next)
@ -27,24 +40,38 @@
(:source event) (:source event)
(:type event) (:type event)
(:timestamp event) (:timestamp event)
(:created-at event)
(:profile-id event) (:profile-id event)
(db/inet (:ip-addr event)) (db/inet (:ip-addr event))
(db/tjson (:props event)) (db/tjson (:props event))
(db/tjson (d/without-nils (:context event)))]) (db/tjson (d/without-nils (:context event)))])
(def ^:private event-columns (defn- adjust-timestamp
[:id :name :source :type :tracked-at [{:keys [timestamp created-at] :as event}]
:profile-id :ip-addr :props :context]) (let [margin (inst-ms (dt/diff timestamp created-at))]
(if (or (neg? margin)
(> margin 3600000))
;; If event is in future or lags more than 1 hour, we reasign
;; timestamp to the server creation date
(-> event
(assoc :timestamp created-at)
(update :context assoc :original-timestamp timestamp))
event)))
(defn- handle-events (defn- handle-events
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}] [{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}]
(let [request (-> params meta ::http/request) (let [request (-> params meta ::http/request)
ip-addr (audit/parse-client-ip request) ip-addr (audit/parse-client-ip request)
tnow (dt/now)
xform (comp xform (comp
(map #(assoc % :profile-id profile-id)) (map (fn [event]
(map #(assoc % :ip-addr ip-addr)) (-> event
(map #(assoc % :source "frontend")) (assoc :created-at tnow)
(assoc :profile-id profile-id)
(assoc :ip-addr ip-addr)
(assoc :source "frontend"))))
(filter :profile-id) (filter :profile-id)
(map adjust-timestamp)
(map event->row)) (map event->row))
events (sequence xform events)] events (sequence xform events)]
(when (seq events) (when (seq events)

View file

@ -868,7 +868,8 @@
(db/delete! conn :file-library-rel {:library-file-id id}) (db/delete! conn :file-library-rel {:library-file-id id})
(db/update! conn :file (db/update! conn :file
{:is-shared false} {:is-shared false
:modified-at (dt/now)}
{:id id}) {:id id})
file) file)
@ -876,7 +877,8 @@
(true? (:is-shared params))) (true? (:is-shared params)))
(let [file (assoc file :is-shared true)] (let [file (assoc file :is-shared true)]
(db/update! conn :file (db/update! conn :file
{:is-shared true} {:is-shared true
:modified-at (dt/now)}
{:id id}) {:id id})
file) file)

View file

@ -22,13 +22,182 @@
[promesa.exec :as px])) [promesa.exec :as px]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK ENTRY POINT ;; IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare get-stats) (defn- send!
(declare send!) [cfg data]
(declare get-subscriptions-newsletter-updates) (let [request {:method :post
(declare get-subscriptions-newsletter-news) :uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
:body (json/encode-str data)}
response (http/req! cfg request)]
(when (> (:status response) 206)
(ex/raise :type :internal
:code :invalid-response
:response-status (:status response)
:response-body (:body response)))))
(defn- get-subscriptions-newsletter-updates
[conn]
(let [sql "SELECT email FROM profile where props->>'~:newsletter-updates' = 'true'"]
(->> (db/exec! conn [sql])
(mapv :email))))
(defn- get-subscriptions-newsletter-news
[conn]
(let [sql "SELECT email FROM profile where props->>'~:newsletter-news' = 'true'"]
(->> (db/exec! conn [sql])
(mapv :email))))
(defn- get-num-teams
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM team"]) :count))
(defn- get-num-projects
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM project"]) :count))
(defn- get-num-files
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM file"]) :count))
(defn- get-num-file-changes
[conn]
(let [sql (str "SELECT count(*) AS count "
" FROM file_change "
" where date_trunc('day', created_at) = date_trunc('day', now())")]
(-> (db/exec-one! conn [sql]) :count)))
(defn- get-num-touched-files
[conn]
(let [sql (str "SELECT count(distinct file_id) AS count "
" FROM file_change "
" where date_trunc('day', created_at) = date_trunc('day', now())")]
(-> (db/exec-one! conn [sql]) :count)))
(defn- get-num-users
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM profile"]) :count))
(defn- get-num-fonts
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM team_font_variant"]) :count))
(defn- get-num-comments
[conn]
(-> (db/exec-one! conn ["SELECT count(*) AS count FROM comment"]) :count))
(def sql:team-averages
"with projects_by_team AS (
SELECT t.id, count(p.id) AS num_projects
FROM team AS t
LEFT JOIN project AS p ON (p.team_id = t.id)
GROUP BY 1
), files_by_project AS (
SELECT p.id, count(f.id) AS num_files
FROM project AS p
LEFT JOIN file AS f ON (f.project_id = p.id)
GROUP BY 1
), comment_threads_by_file AS (
SELECT f.id, count(ct.id) AS num_comment_threads
FROM file AS f
LEFT JOIN comment_thread AS ct ON (ct.file_id = f.id)
GROUP BY 1
), users_by_team AS (
SELECT t.id, count(tp.profile_id) AS num_users
FROM team AS t
LEFT JOIN team_profile_rel AS tp ON(tp.team_id = t.id)
GROUP BY 1
)
SELECT (SELECT avg(num_projects)::integer FROM projects_by_team) AS avg_projects_on_team,
(SELECT max(num_projects)::integer FROM projects_by_team) AS max_projects_on_team,
(SELECT avg(num_files)::integer FROM files_by_project) AS avg_files_on_project,
(SELECT max(num_files)::integer FROM files_by_project) AS max_files_on_project,
(SELECT avg(num_comment_threads)::integer FROM comment_threads_by_file) AS avg_comment_threads_on_file,
(SELECT max(num_comment_threads)::integer FROM comment_threads_by_file) AS max_comment_threads_on_file,
(SELECT avg(num_users)::integer FROM users_by_team) AS avg_users_on_team,
(SELECT max(num_users)::integer FROM users_by_team) AS max_users_on_team")
(defn- get-team-averages
[conn]
(->> [sql:team-averages]
(db/exec-one! conn)))
(defn- get-enabled-auth-providers
[conn]
(let [sql (str "SELECT auth_backend AS backend, count(*) AS total "
" FROM profile GROUP BY 1")
rows (db/exec! conn [sql])]
(->> rows
(map (fn [{:keys [backend total]}]
(let [backend (or backend "penpot")]
[(keyword (str "auth-backend-" backend))
total])))
(into {}))))
(defn- get-jvm-stats
[]
(let [^Runtime runtime (Runtime/getRuntime)]
{:jvm-heap-current (.totalMemory runtime)
:jvm-heap-max (.maxMemory runtime)
:jvm-cpus (.availableProcessors runtime)
:os-arch (System/getProperty "os.arch")
:os-name (System/getProperty "os.name")
:os-version (System/getProperty "os.version")
:user-tz (System/getProperty "user.timezone")}))
(def ^:private sql:get-counters
"SELECT name, count(*) AS count
FROM audit_log
WHERE source = 'backend'
AND tracked_at >= date_trunc('day', now())
GROUP BY 1
ORDER BY 2 DESC")
(defn- get-action-counters
[conn]
(let [counters (->> (db/exec! conn [sql:get-counters])
(d/index-by (comp keyword :name) :count))
total (reduce + 0 (vals counters))]
{:total-accomulated-events total
:event-counters counters}))
(def ^:private sql:clean-counters
"DELETE FROM audit_log
WHERE ip_addr = '0.0.0.0'::inet -- we know this is from telemetry
AND tracked_at < (date_trunc('day', now()) - '1 day'::interval)")
(defn- clean-counters-data!
[conn]
(when-not (contains? cf/flags :audit-log)
(db/exec-one! conn [sql:clean-counters])))
(defn- get-stats
[conn]
(let [referer (if (cf/get :telemetry-with-taiga)
"taiga"
(cf/get :telemetry-referer))]
(-> {:referer referer
:public-uri (cf/get :public-uri)
:total-teams (get-num-teams conn)
:total-projects (get-num-projects conn)
:total-files (get-num-files conn)
:total-users (get-num-users conn)
:total-fonts (get-num-fonts conn)
:total-comments (get-num-comments conn)
:total-file-changes (get-num-file-changes conn)
:total-touched-files (get-num-touched-files conn)}
(merge
(get-team-averages conn)
(get-jvm-stats)
(get-enabled-auth-providers conn)
(get-action-counters conn))
(d/without-nils))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TASK ENTRY POINT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/pre-init-spec ::handler [_] (defmethod ig/pre-init-spec ::handler [_]
(s/keys :req [::http/client (s/keys :req [::http/client
@ -48,6 +217,10 @@
data {:subscriptions subs data {:subscriptions subs
:version (:full cf/version) :version (:full cf/version)
:instance-id (:instance-id props)}] :instance-id (:instance-id props)}]
(when enabled?
(clean-counters-data! pool))
(cond (cond
;; If we have telemetry enabled, then proceed the normal ;; If we have telemetry enabled, then proceed the normal
;; operation. ;; operation.
@ -63,7 +236,8 @@
;; onboarding dialog or the profile section, then proceed to ;; onboarding dialog or the profile section, then proceed to
;; send a limited telemetry data, that consists in the list of ;; send a limited telemetry data, that consists in the list of
;; subscribed emails and the running penpot version. ;; subscribed emails and the running penpot version.
(seq subs) (or (seq (:newsletter-updates subs))
(seq (:newsletter-news subs)))
(do (do
(when send? (when send?
(px/sleep (rand-int 10000)) (px/sleep (rand-int 10000))
@ -72,151 +246,3 @@
:else :else
data)))) data))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- send!
[cfg data]
(let [request {:method :post
:uri (cf/get :telemetry-uri)
:headers {"content-type" "application/json"}
:body (json/encode-str data)}
response (http/req! cfg request)]
(when (> (:status response) 206)
(ex/raise :type :internal
:code :invalid-response
:response-status (:status response)
:response-body (:body response)))))
(defn- get-subscriptions-newsletter-updates
[conn]
(let [sql "select email from profile where props->>'~:newsletter-updates' = 'true'"]
(->> (db/exec! conn [sql])
(mapv :email))))
(defn- get-subscriptions-newsletter-news
[conn]
(let [sql "select email from profile where props->>'~:newsletter-news' = 'true'"]
(->> (db/exec! conn [sql])
(mapv :email))))
(defn- retrieve-num-teams
[conn]
(-> (db/exec-one! conn ["select count(*) as count from team;"]) :count))
(defn- retrieve-num-projects
[conn]
(-> (db/exec-one! conn ["select count(*) as count from project;"]) :count))
(defn- retrieve-num-files
[conn]
(-> (db/exec-one! conn ["select count(*) as count from file;"]) :count))
(defn- retrieve-num-file-changes
[conn]
(let [sql (str "select count(*) as count "
" from file_change "
" where date_trunc('day', created_at) = date_trunc('day', now())")]
(-> (db/exec-one! conn [sql]) :count)))
(defn- retrieve-num-touched-files
[conn]
(let [sql (str "select count(distinct file_id) as count "
" from file_change "
" where date_trunc('day', created_at) = date_trunc('day', now())")]
(-> (db/exec-one! conn [sql]) :count)))
(defn- retrieve-num-users
[conn]
(-> (db/exec-one! conn ["select count(*) as count from profile;"]) :count))
(defn- retrieve-num-fonts
[conn]
(-> (db/exec-one! conn ["select count(*) as count from team_font_variant;"]) :count))
(defn- retrieve-num-comments
[conn]
(-> (db/exec-one! conn ["select count(*) as count from comment;"]) :count))
(def sql:team-averages
"with projects_by_team as (
select t.id, count(p.id) as num_projects
from team as t
left join project as p on (p.team_id = t.id)
group by 1
), files_by_project as (
select p.id, count(f.id) as num_files
from project as p
left join file as f on (f.project_id = p.id)
group by 1
), comment_threads_by_file as (
select f.id, count(ct.id) as num_comment_threads
from file as f
left join comment_thread as ct on (ct.file_id = f.id)
group by 1
), users_by_team as (
select t.id, count(tp.profile_id) as num_users
from team as t
left join team_profile_rel as tp on(tp.team_id = t.id)
group by 1
)
select (select avg(num_projects)::integer from projects_by_team) as avg_projects_on_team,
(select max(num_projects)::integer from projects_by_team) as max_projects_on_team,
(select avg(num_files)::integer from files_by_project) as avg_files_on_project,
(select max(num_files)::integer from files_by_project) as max_files_on_project,
(select avg(num_comment_threads)::integer from comment_threads_by_file) as avg_comment_threads_on_file,
(select max(num_comment_threads)::integer from comment_threads_by_file) as max_comment_threads_on_file,
(select avg(num_users)::integer from users_by_team) as avg_users_on_team,
(select max(num_users)::integer from users_by_team) as max_users_on_team;")
(defn- retrieve-team-averages
[conn]
(->> [sql:team-averages]
(db/exec-one! conn)))
(defn- retrieve-enabled-auth-providers
[conn]
(let [sql (str "select auth_backend as backend, count(*) as total "
" from profile group by 1")
rows (db/exec! conn [sql])]
(->> rows
(map (fn [{:keys [backend total]}]
(let [backend (or backend "penpot")]
[(keyword (str "auth-backend-" backend))
total])))
(into {}))))
(defn- retrieve-jvm-stats
[]
(let [^Runtime runtime (Runtime/getRuntime)]
{:jvm-heap-current (.totalMemory runtime)
:jvm-heap-max (.maxMemory runtime)
:jvm-cpus (.availableProcessors runtime)
:os-arch (System/getProperty "os.arch")
:os-name (System/getProperty "os.name")
:os-version (System/getProperty "os.version")
:user-tz (System/getProperty "user.timezone")}))
(defn get-stats
[conn]
(let [referer (if (cf/get :telemetry-with-taiga)
"taiga"
(cf/get :telemetry-referer))]
(-> {:referer referer
:public-uri (cf/get :public-uri)
:total-teams (retrieve-num-teams conn)
:total-projects (retrieve-num-projects conn)
:total-files (retrieve-num-files conn)
:total-users (retrieve-num-users conn)
:total-fonts (retrieve-num-fonts conn)
:total-comments (retrieve-num-comments conn)
:total-file-changes (retrieve-num-file-changes conn)
:total-touched-files (retrieve-num-touched-files conn)}
(d/merge
(retrieve-team-averages conn)
(retrieve-jvm-stats)
(retrieve-enabled-auth-providers conn))
(d/without-nils))))

View file

@ -112,7 +112,7 @@
;; "alter table task set unlogged;\n" ;; "alter table task set unlogged;\n"
;; "alter table task_default set unlogged;\n" ;; "alter table task_default set unlogged;\n"
;; "alter table task_completed set unlogged;\n" ;; "alter table task_completed set unlogged;\n"
"alter table audit_log_default set unlogged ;\n" "alter table audit_log set unlogged ;\n"
"alter table storage_object set unlogged;\n" "alter table storage_object set unlogged;\n"
"alter table server_error_report set unlogged;\n" "alter table server_error_report set unlogged;\n"
"alter table server_prop set unlogged;\n" "alter table server_prop set unlogged;\n"

View file

@ -69,6 +69,11 @@
(def ^:dynamic ^:private *errors* nil) (def ^:dynamic ^:private *errors* nil)
(defn- library-exists?
[file libraries shape]
(or (= (:component-file shape) (:id file))
(contains? libraries (:component-file shape))))
(defn- report-error (defn- report-error
[code hint shape file page & {:as args}] [code hint shape file page & {:as args}]
(let [error {:code code (let [error {:code code
@ -218,12 +223,11 @@
"Shape not expected to be main instance" "Shape not expected to be main instance"
shape file page)) shape file page))
(let [library-exists? (or (= (:component-file shape) (:id file)) (let [library-exists (library-exists? file libraries shape)
(contains? libraries (:component-file shape))) component (when library-exists
component (when library-exists?
(ctf/resolve-component shape file libraries {:include-deleted? true}))] (ctf/resolve-component shape file libraries {:include-deleted? true}))]
(if (nil? component) (if (nil? component)
(when library-exists? (when library-exists
(report-error :component-not-found (report-error :component-not-found
(str/ffmt "Component % not found in file %" (:component-id shape) (:component-file shape)) (str/ffmt "Component % not found in file %" (:component-id shape) (:component-file shape))
shape file page)) shape file page))
@ -265,11 +269,10 @@
(defn- check-component-ref (defn- check-component-ref
"Validate that the referenced shape exists in the near component." "Validate that the referenced shape exists in the near component."
[shape file page libraries] [shape file page libraries]
(let [library-exists? (or (= (:component-file shape) (:id file)) (let [library-exists (library-exists? file libraries shape)
(contains? libraries (:component-file shape))) ref-shape (when library-exists
ref-shape (when library-exists?
(ctf/find-ref-shape file page libraries shape :include-deleted? true))] (ctf/find-ref-shape file page libraries shape :include-deleted? true))]
(when (and library-exists? (nil? ref-shape)) (when (and library-exists (nil? ref-shape))
(report-error :ref-shape-not-found (report-error :ref-shape-not-found
(str/ffmt "Referenced shape % not found in near component" (:shape-ref shape)) (str/ffmt "Referenced shape % not found in near component" (:shape-ref shape))
shape file page)))) shape file page))))
@ -313,20 +316,25 @@
- :component-root - :component-root
- :shape-ref" - :shape-ref"
[shape file page libraries] [shape file page libraries]
(check-component-not-main-head shape file page libraries) ;; We propagate have to propagate to nested shapes if library is valid or not
(check-component-root shape file page) (let [library-exists (library-exists? file libraries shape)]
(check-component-ref shape file page libraries) (check-component-not-main-head shape file page libraries)
(run! #(check-shape % file page libraries :context :copy-top) (:shapes shape))) (check-component-root shape file page)
(check-component-ref shape file page libraries)
(run! #(check-shape % file page libraries :context :copy-top :library-exists library-exists) (:shapes shape))))
(defn- check-shape-copy-root-nested (defn- check-shape-copy-root-nested
"Root shape of a nested copy instance "Root shape of a nested copy instance
- :component-id - :component-id
- :component-file - :component-file
- :shape-ref" - :shape-ref"
[shape file page libraries] [shape file page libraries library-exists]
(check-component-not-main-head shape file page libraries) (check-component-not-main-head shape file page libraries)
(check-component-not-root shape file page) (check-component-not-root shape file page)
(check-component-ref shape file page libraries) ;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached
;; so we only validate the shape-ref if the ancestor is from a valid library
(when library-exists
(check-component-ref shape file page libraries))
(run! #(check-shape % file page libraries :context :copy-nested) (:shapes shape))) (run! #(check-shape % file page libraries :context :copy-nested) (:shapes shape)))
(defn- check-shape-main-not-root (defn- check-shape-main-not-root
@ -367,7 +375,7 @@
- :main-any - :main-any
- :copy-any - :copy-any
" "
[shape-id file page libraries & {:keys [context] :or {context :not-component}}] [shape-id file page libraries & {:keys [context library-exists] :or {context :not-component library-exists false}}]
(let [shape (ctst/get-shape page shape-id)] (let [shape (ctst/get-shape page shape-id)]
(when (some? shape) (when (some? shape)
(check-geometry shape file page) (check-geometry shape file page)
@ -406,7 +414,7 @@
(report-error :nested-copy-not-allowed (report-error :nested-copy-not-allowed
"Nested copy component only allowed inside other component" "Nested copy component only allowed inside other component"
shape file page) shape file page)
(check-shape-copy-root-nested shape file page libraries))))) (check-shape-copy-root-nested shape file page libraries library-exists)))))
(if (ctk/in-component-copy? shape) (if (ctk/in-component-copy? shape)
(if-not (#{:copy-top :copy-nested :copy-any} context) (if-not (#{:copy-top :copy-nested :copy-any} context)

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PATH=/usr/lib/jvm/openjdk/bin:/usr/local/nodejs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin export PATH=/usr/lib/jvm/openjdk/bin:/usr/local/nodejs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
export JAVA_OPTS="-Xmx900m -Xms50m" export JAVA_OPTS="-Xmx1000m -Xms50m"
alias l='ls --color -GFlh' alias l='ls --color -GFlh'
alias rm='rm -r' alias rm='rm -r'

View file

@ -19,12 +19,12 @@ popd
tmux -2 new-session -d -s penpot tmux -2 new-session -d -s penpot
tmux rename-window -t penpot:0 'gulp' tmux rename-window -t penpot:0 'frontend watch'
tmux select-window -t penpot:0 tmux select-window -t penpot:0
tmux send-keys -t penpot 'cd penpot/frontend' enter C-l tmux send-keys -t penpot 'cd penpot/frontend' enter C-l
tmux send-keys -t penpot 'npx gulp watch' enter tmux send-keys -t penpot 'yarn run watch' enter
tmux new-window -t penpot:1 -n 'shadow watch' tmux new-window -t penpot:1 -n 'frontend shadow'
tmux select-window -t penpot:1 tmux select-window -t penpot:1
tmux send-keys -t penpot 'cd penpot/frontend' enter C-l tmux send-keys -t penpot 'cd penpot/frontend' enter C-l
tmux send-keys -t penpot 'clojure -M:dev:shadow-cljs watch main' enter tmux send-keys -t penpot 'clojure -M:dev:shadow-cljs watch main' enter

View file

@ -19,21 +19,17 @@
"scripts": { "scripts": {
"fmt:clj:check": "cljfmt check --parallel=false src/ test/", "fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/", "fmt:clj": "cljfmt fix --parallel=true src/ test/",
"test:compile": "clojure -M:dev:shadow-cljs compile test --config-merge '{:autorun false}'",
"lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss", "lint:scss": "yarn run prettier -c resources/styles -c src/**/*.scss",
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w", "lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "clj-kondo --parallel --lint src/", "lint:clj": "clj-kondo --parallel --lint src/",
"test:compile": "clojure -M:dev:shadow-cljs compile test --config-merge '{:autorun false}'",
"test:run": "node target/tests.cjs", "test:run": "node target/tests.cjs",
"test:watch": "clojure -M:dev:shadow-cljs watch test", "test:watch": "clojure -M:dev:shadow-cljs watch test",
"test": "yarn run test:compile && yarn run test:run", "test": "yarn run test:compile && yarn run test:run",
"gulp:watch": "gulp watch", "translations:validate": "node ./scripts/validate-translations.js",
"watch": "shadow-cljs watch main", "translations:find-unused": "node ./scripts/find-unused-translations.js",
"validate-translations": "node ./scripts/validate-translations.js", "compile": "node ./scripts/compile.js",
"find-unused-translations": "node ./scripts/find-unused-translations.js", "watch": "node ./scripts/watch.js",
"build:clean": "gulp clean:output && gulp clean:dist",
"build:styles": "gulp build:styles",
"build:assets": "gulp build:assets",
"build:copy": "gulp build:copy",
"storybook:compile": "gulp template:storybook && clojure -M:dev:shadow-cljs compile storybook", "storybook:compile": "gulp template:storybook && clojure -M:dev:shadow-cljs compile storybook",
"storybook:watch": "npm run storybook:compile && concurrently \"clojure -M:dev:shadow-cljs watch storybook\" \"storybook dev -p 6006\"", "storybook:watch": "npm run storybook:compile && concurrently \"clojure -M:dev:shadow-cljs watch storybook\" \"storybook dev -p 6006\"",
"storybook:build": "npm run storybook:compile && storybook build" "storybook:build": "npm run storybook:compile && storybook build"
@ -67,19 +63,26 @@
"map-stream": "0.0.7", "map-stream": "0.0.7",
"marked": "^12.0.0", "marked": "^12.0.0",
"mkdirp": "^3.0.1", "mkdirp": "^3.0.1",
"mustache": "^4.2.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"p-limit": "^5.0.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"postcss-clean": "^1.2.2", "postcss-clean": "^1.2.2",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"pretty-time": "^1.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"sass": "^1.71.1", "sass": "^1.71.1",
"sass-embedded": "^1.71.1",
"shadow-cljs": "2.27.4", "shadow-cljs": "2.27.4",
"storybook": "^7.6.17", "storybook": "^7.6.17",
"svg-sprite": "^2.0.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.1.4", "vite": "^5.1.4",
"vitest": "^1.3.1" "vitest": "^1.3.1",
"watcher": "^2.3.0",
"workerpool": "^9.1.0"
}, },
"dependencies": { "dependencies": {
"date-fns": "^3.3.1", "date-fns": "^3.3.1",

View file

@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 15.674"><path fill="#fff" fill-rule="evenodd" d="M7.976 0C3.566 0 0 3.592 0 8.035a8.03 8.03 0 0 0 5.454 7.623c.396.08.541-.173.541-.385 0-.187-.013-.825-.013-1.49-2.219.479-2.681-.958-2.681-.958-.356-.932-.885-1.171-.885-1.171-.726-.492.053-.492.053-.492.806.053 1.229.825 1.229.825.713 1.223 1.862.878 2.324.665.066-.519.277-.878.502-1.078-1.77-.186-3.632-.878-3.632-3.964 0-.878.317-1.597.819-2.155-.079-.2-.357-1.025.079-2.129 0 0 .674-.213 2.192.825a7.633 7.633 0 0 1 3.988 0c1.519-1.038 2.192-.825 2.192-.825.436 1.104.159 1.929.079 2.129.516.558.819 1.277.819 2.155 0 3.086-1.862 3.765-3.644 3.964.29.253.541.732.541 1.49 0 1.078-.013 1.943-.013 2.208 0 .213.145.466.541.386a8.028 8.028 0 0 0 5.454-7.623C15.952 3.592 12.374 0 7.976 0Z" class="fills" clip-rule="evenodd"/></svg>
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 687 B

After

Width:  |  Height:  |  Size: 852 B

Before After
Before After

View file

@ -1 +1 @@
<svg viewBox="3658.551 302.026 20 17.949" width="20" height="17.949" xmlns="http://www.w3.org/2000/svg" style="-webkit-print-color-adjust:exact"><path d="m3668.55 319.974 3.685-11.043h-7.364l3.68 11.043ZM3659.71 308.932l-1.122 3.355a.733.733 0 0 0 .277.83l9.685 6.857-8.84-11.042ZM3659.71 308.931h5.16l-2.22-6.65c-.114-.34-.61-.34-.727 0l-2.213 6.65Z" style="fill:#fff"/><path d="m3677.396 308.932 1.118 3.355a.733.733 0 0 1-.276.83l-9.688 6.857 8.846-11.042ZM3677.396 308.931h-5.16l2.216-6.65c.114-.34.61-.34.727 0l2.217 6.65ZM3668.55 319.974l3.685-11.042h5.16l-8.845 11.042ZM3668.55 319.974l-8.84-11.042h5.16l3.68 11.042Z" style="fill:#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" id="screenshot-43864c00-8517-80fc-8004-14a71e4f14d1" fill="none" version="1.1" viewBox="0 0 16 16"><g id="shape-43864c00-8517-80fc-8004-14a71e4f14d1" fill="#000"><defs id="shape-43864c00-8517-80fc-8004-14a71e4f65d8" fill="#000"><style id="shape-43864c00-8517-80fc-8004-14a71e4f65da">.cls-2{fill:#fc6d26}</style></defs><g id="shape-43864c00-8517-80fc-8004-14a71e4f65d9" fill="#000"><g id="shape-43864c00-8517-80fc-8004-14a71e4f65dc"><path id="fills-43864c00-8517-80fc-8004-14a71e4f65dc" fill="#e24329" d="M15.733 6.099Zl-2.2-5.741a.561.561 0 0 0-.224-.269.583.583 0 0 0-.666.035.587.587 0 0 0-.194.294l-1.47 4.498H5.025L3.555.418a.57.57 0 0 0-.194-.294.581.581 0 0 0-.666-.036.57.57 0 0 0-.224.27L.289 6.038l-.022.058a4.043 4.043 0 0 0 1.342 4.673l.007.006.02.014 3.317 2.485 1.642 1.242.999.754a.67.67 0 0 0 .813 0l1-.754 1.641-1.242 3.337-2.5.009-.006a4.045 4.045 0 0 0 1.339-4.669Z" class="fills"/></g><g id="shape-43864c00-8517-80fc-8004-14a71e4f65dd"><g id="fills-43864c00-8517-80fc-8004-14a71e4f65dd" class="fills"><path d="M15.733 6.099Zc-1.083.16-2.083.61-2.95 1.259L8 10.974l3.047 2.303 3.337-2.499.008-.007a4.045 4.045 0 0 0 1.341-4.672Z" class="cls-2"/></g></g><g id="shape-43864c00-8517-80fc-8004-14a71e4f65de"><path id="fills-43864c00-8517-80fc-8004-14a71e4f65de" fill="#fca326" d="m4.953 13.277 1.642 1.242.999.755a.674.674 0 0 0 .813 0l1-.755 1.641-1.242S9.629 12.203 8 10.974c-1.629 1.229-3.047 2.303-3.047 2.303Z" class="fills"/></g><g id="shape-43864c00-8517-80fc-8004-14a71e4f65df"><g id="fills-43864c00-8517-80fc-8004-14a71e4f65df" class="fills"><path d="M3.217 7.358a7.364 7.364 0 0 0-2.928-1.32l-.022.058a4.043 4.043 0 0 0 1.342 4.673l.007.006.02.014 3.317 2.485L8 10.971Z" class="cls-2"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5345 -1143 500 500"><path fill="#fff" fill-rule="evenodd" d="M5845-887c0-18-1-35-4-51h-240v96h137c-6 32-24 58-51 76v63h82c49-44 76-108 76-184z" clip-rule="evenodd"/><path fill="#fff" fill-rule="evenodd" d="M5601-643c68 0 126-22 168-60l-82-63a156 156 0 0 1-229-79h-85v64c42 82 128 138 228 138z" clip-rule="evenodd"/><path fill="#fff" fill-rule="evenodd" d="M5458-845a148 148 0 0 1 0-95v-65h-85a246 246 0 0 0 0 224z" clip-rule="evenodd"/><path fill="#fff" fill-rule="evenodd" d="M5601-1043c37 0 71 12 97 37l73-72a256 256 0 0 0-399 73l86 65c20-59 76-103 143-103z" clip-rule="evenodd"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/><path fill="none" d="M1 1h22v22H1z"/></svg>

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 719 B

Before After
Before After

View file

@ -1 +1 @@
<svg viewBox="7437 302 20.011 18.182" width="20.011" height="18.182" xmlns="http://www.w3.org/2000/svg" style="-webkit-print-color-adjust:exact"><path d="M7455.039 309.1c-1.9-1.183-4.555-1.918-7.46-1.918-5.845 0-10.579 2.922-10.579 6.526 0 3.3 3.945 6.007 9.055 6.473v-1.9c-3.442-.43-6.024-2.313-6.024-4.573 0-2.564 3.37-4.662 7.549-4.662 2.08 0 3.962.52 5.325 1.363l-1.937 1.202h6.043v-3.73l-1.972 1.22Zm-8.984-5.146v16.227l3.03-1.9V302l-3.03 1.954Z" style="fill:#fff;fill-opacity:1"/></svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><g fill="#000"><path fill="#b2b2b2" d="M7.339 13.094v.207c-2.477-.311-4.354-1.669-4.354-3.305 0-1.229 1.058-2.3 2.64-2.891l-.062-1.48C2.778 6.328.813 8.019.813 9.996c0 2.384 2.839 4.348 6.529 4.678h.001l-.004-1.58Z" class="fills"/><path fill="#f7931e" d="M9.524 13.647ZL9.521 1.326 7.339 2.445v4.482h.004v7.747Z" class="fills"/><path fill="#b2b2b2" d="m15.187 9.123-.295-3.128-1.123.635c-.798-.485-1.816-1.083-3.144-1.091v1.372c.11.031.216.064.322.098.442.144.849.324 1.208.535l-1.181.664 4.213.915Z" class="fills"/></g></svg>

Before

Width:  |  Height:  |  Size: 492 B

After

Width:  |  Height:  |  Size: 598 B

Before After
Before After

View file

@ -26,7 +26,6 @@
<script> <script>
window.penpotTranslations = JSON.parse({{& translations}}); window.penpotTranslations = JSON.parse({{& translations}});
window.penpotThemes = {{& themes}};
window.penpotVersion = "%version%"; window.penpotVersion = "%version%";
window.penpotBuildDate = "%buildDate%"; window.penpotBuildDate = "%buildDate%";
</script> </script>
@ -39,8 +38,8 @@
</head> </head>
<body> <body>
{{>../public/images/sprites/symbol/icons.svg}} {{> ../public/images/sprites/symbol/icons.svg }}
{{>../public/images/sprites/symbol/cursors.svg}} {{> ../public/images/sprites/symbol/cursors.svg }}
<div id="app"></div> <div id="app"></div>
<section id="modal"></section> <section id="modal"></section>
{{# manifest}} {{# manifest}}

View file

@ -7,7 +7,6 @@
<link rel="icon" href="images/favicon.png" /> <link rel="icon" href="images/favicon.png" />
<script> <script>
window.penpotThemes = {{& themes}};
window.penpotVersion = "%version%"; window.penpotVersion = "%version%";
</script> </script>

View file

@ -0,0 +1,405 @@
import proc from "node:child_process";
import fs from "node:fs/promises";
import ph from "node:path";
import os from "node:os";
import url from "node:url";
import * as marked from "marked";
import SVGSpriter from "svg-sprite";
import Watcher from "watcher";
import gettext from "gettext-parser";
import l from "lodash";
import log from "fancy-log";
import mustache from "mustache";
import pLimit from "p-limit";
import ppt from "pretty-time";
import wpool from "workerpool";
function getCoreCount() {
return os.cpus().length;
}
// const __filename = url.fileURLToPath(import.meta.url);
export const dirname = url.fileURLToPath(new URL(".", import.meta.url));
export function startWorker() {
return wpool.pool(dirname + "/_worker.js", {
maxWorkers: getCoreCount()
});
}
async function findFiles(basePath, predicate, options={}) {
predicate = predicate ?? function() { return true; }
let files = await fs.readdir(basePath, {recursive: options.recursive ?? false})
files = files.filter((path) => path.endsWith(".svg"));
files = files.map((path) => ph.join(basePath, path));
return files;
}
function syncDirs(originPath, destPath) {
const command = `rsync -ar --delete ${originPath} ${destPath}`;
return new Promise((resolve, reject) => {
proc.exec(command, (cause, stdout) => {
if (cause) { reject(cause); }
else { resolve(); }
});
});
}
export function isSassFile(path) {
return path.endsWith(".scss");
}
export function isSvgFile(path) {
return path.endsWith(".scss");
}
export async function compileSass(worker, path, options) {
path = ph.resolve(path);
log.info("compile:", path);
return worker.exec("compileSass", [path, options]);
}
export async function compileSassAll(worker) {
const limitFn = pLimit(4);
const sourceDir = "src";
let files = await fs.readdir(sourceDir, { recursive: true })
files = files.filter((path) => path.endsWith(".scss"));
files = files.map((path) => ph.join(sourceDir, path));
// files = files.slice(0, 10);
const procs = [
compileSass(worker, "resources/styles/main-default.scss", {}),
compileSass(worker, "resources/styles/debug.scss", {})
];
for (let path of files) {
const proc = limitFn(() => compileSass(worker, path, {modules: true}));
procs.push(proc);
}
const result = await Promise.all(procs);
return result.reduce((acc, item, index) => {
acc.index[item.outputPath] = item.css;
acc.items.push(item.outputPath);
return acc;
}, {index:{}, items: []});
}
function compare(a, b) {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
}
export function concatSass(data) {
const output = []
for (let path of data.items) {
output.push(data.index[path]);
}
return output.join("\n");
}
export async function watch(baseDir, predicate, callback) {
predicate = predicate ?? (() => true);
const watcher = new Watcher(baseDir, {
persistent: true,
recursive: true
});
watcher.on("change", (path) => {
if (predicate(path)) {
callback(path);
}
});
}
async function readShadowManifest() {
try {
const manifestPath = "resources/public/js/manifest.json"
let content = await fs.readFile(manifestPath, { encoding: "utf8" });
content = JSON.parse(content);
const index = {
config: "js/config.js?ts=" + Date.now(),
polyfills: "js/polyfills.js?ts=" + Date.now(),
};
for (let item of content) {
index[item.name] = "js/" + item["output-name"];
}
return index;
} catch (cause) {
// log.error("error on reading manifest (using default)", cause);
return {
config: "js/config.js",
polyfills: "js/polyfills.js",
main: "js/main.js",
shared: "js/shared.js",
worker: "js/worker.js",
rasterizer: "js/rasterizer.js",
};
}
}
async function renderTemplate(path, context={}, partials={}) {
const content = await fs.readFile(path, {encoding: "utf-8"});
const ts = Math.floor(new Date());
context = Object.assign({}, context, {
ts: ts,
isDebug: process.env.NODE_ENV !== "production"
});
return mustache.render(content, context, partials);
}
const renderer = {
link(href, title, text) {
return `<a href="${href}" target="_blank">${text}</a>`;
},
};
marked.use({ renderer });
async function readTranslations() {
const langs = [
"ar",
"ca",
"de",
"el",
"en",
"eu",
"it",
"es",
"fa",
"fr",
"he",
"nb_NO",
"pl",
"pt_BR",
"ro",
"id",
"ru",
"tr",
"zh_CN",
"zh_Hant",
"hr",
"gl",
"pt_PT",
"cs",
"fo",
"ko",
"lv",
"nl",
// this happens when file does not matches correct
// iso code for the language.
["ja_jp", "jpn_JP"],
// ["fi", "fin_FI"],
["uk", "ukr_UA"],
"ha"
];
const result = {};
for (let lang of langs) {
let filename = `${lang}.po`;
if (l.isArray(lang)) {
filename = `${lang[1]}.po`;
lang = lang[0];
}
const content = await fs.readFile(`./translations/${filename}`, { encoding: "utf-8" });
lang = lang.toLowerCase();
const data = gettext.po.parse(content, "utf-8");
const trdata = data.translations[""];
for (let key of Object.keys(trdata)) {
if (key === "") continue;
const comments = trdata[key].comments || {};
if (l.isNil(result[key])) {
result[key] = {};
}
const isMarkdown = l.includes(comments.flag, "markdown");
const msgs = trdata[key].msgstr;
if (msgs.length === 1) {
let message = msgs[0];
if (isMarkdown) {
message = marked.parseInline(message);
}
result[key][lang] = message;
} else {
result[key][lang] = msgs.map((item) => {
if (isMarkdown) {
return marked.parseInline(item);
} else {
return item;
}
});
}
// if (key === "modals.delete-font.title") {
// console.dir(trdata[key], {depth:10});
// console.dir(result[key], {depth:10});
// }
}
}
return JSON.stringify(result);
}
async function generateSvgSprite(files, prefix) {
const spriter = new SVGSpriter({
mode: {
symbol: { inline: true }
}
});
for (let path of files) {
const name = `${prefix}${ph.basename(path)}`
const content = await fs.readFile(path, {encoding: "utf-8"});
spriter.add(name, name, content);
}
const { result } = await spriter.compileAsync();
const resource = result.symbol.sprite;
return resource.contents;
}
async function generateSvgSprites() {
await fs.mkdir("resources/public/images/sprites/symbol/", { recursive: true });
const icons = await findFiles("resources/images/icons/", isSvgFile);
const iconsSprite = await generateSvgSprite(icons, "icon-");
await fs.writeFile("resources/public/images/sprites/symbol/icons.svg", iconsSprite);
const cursors = await findFiles("resources/images/cursors/", isSvgFile);
const cursorsSprite = await generateSvgSprite(icons, "cursor-");
await fs.writeFile("resources/public/images/sprites/symbol/cursors.svg", cursorsSprite);
}
async function generateTemplates() {
await fs.mkdir("./resources/public/", { recursive: true });
const translations = await readTranslations();
const manifest = await readShadowManifest();
let content;
const iconsSprite = await fs.readFile("resources/public/images/sprites/symbol/icons.svg", "utf8");
const cursorsSprite = await fs.readFile("resources/public/images/sprites/symbol/cursors.svg", "utf8");
const partials = {
"../public/images/sprites/symbol/icons.svg": iconsSprite,
"../public/images/sprites/symbol/cursors.svg": cursorsSprite,
};
content = await renderTemplate("resources/templates/index.mustache", {
manifest: manifest,
translations: JSON.stringify(translations),
}, partials);
await fs.writeFile("./resources/public/index.html", content);
content = await renderTemplate("resources/templates/preview-body.mustache", {
manifest: manifest,
translations: JSON.stringify(translations),
});
await fs.writeFile("./.storybook/preview-body.html", content);
content = await renderTemplate("resources/templates/render.mustache", {
manifest: manifest,
translations: JSON.stringify(translations),
});
await fs.writeFile("./resources/public/render.html", content);
content = await renderTemplate("resources/templates/rasterizer.mustache", {
manifest: manifest,
translations: JSON.stringify(translations),
});
await fs.writeFile("./resources/public/rasterizer.html", content);
}
export async function compileStyles() {
const worker = startWorker();
const start = process.hrtime();
log.info("init: compile styles")
let result = await compileSassAll(worker);
result = concatSass(result);
await fs.mkdir("./resources/public/css", { recursive: true });
await fs.writeFile("./resources/public/css/main.css", result);
const end = process.hrtime(start);
log.info("done: compile styles", `(${ppt(end)})`);
worker.terminate();
}
export async function compileSvgSprites() {
const start = process.hrtime();
log.info("init: compile svgsprite")
await generateSvgSprites();
const end = process.hrtime(start);
log.info("done: compile svgsprite", `(${ppt(end)})`);
}
export async function compileTemplates() {
const start = process.hrtime();
log.info("init: compile templates")
await generateTemplates();
const end = process.hrtime(start);
log.info("done: compile templates", `(${ppt(end)})`);
}
export async function compilePolyfills() {
const start = process.hrtime();
log.info("init: compile polyfills")
const files = await findFiles("resources/polyfills/");
let result = [];
for (let path of files) {
const content = await fs.readFile(path, {encoding:"utf-8"});
result.push(content);
}
await fs.mkdir("./resources/public/js", { recursive: true });
fs.writeFile("resources/public/js/polyfills.js", result.join("\n"));
const end = process.hrtime(start);
log.info("done: compile polyfills", `(${ppt(end)})`);
}
export async function copyAssets() {
const start = process.hrtime();
log.info("init: copy assets")
await syncDirs("resources/images/", "resources/public/images/");
await syncDirs("resources/fonts/", "resources/public/fonts/");
const end = process.hrtime(start);
log.info("done: copy assets", `(${ppt(end)})`);
}

View file

@ -0,0 +1,97 @@
import proc from "node:child_process";
import fs from "node:fs/promises";
import ph from "node:path";
import url from "node:url";
import * as sass from "sass-embedded";
import log from "fancy-log";
import wpool from "workerpool";
import postcss from "postcss";
import modulesProcessor from "postcss-modules";
import autoprefixerProcessor from "autoprefixer";
const compiler = await sass.initAsyncCompiler();
async function compileFile(path) {
const dir = ph.dirname(path);
const name = ph.basename(path, ".scss");
const dest = `${dir}${ph.sep}${name}.css`;
return new Promise(async (resolve, reject) => {
try {
const result = await compiler.compileAsync(path, {
loadPaths: ["node_modules/animate.css", "resources/styles/common/", "resources/styles"],
sourceMap: false
});
// console.dir(result);
resolve({
inputPath: path,
outputPath: dest,
css: result.css
});
} catch (cause) {
// console.error(cause);
reject(cause);
}
});
}
function configureModulesProcessor(options) {
const ROOT_NAME = "app";
return modulesProcessor({
getJSON: (cssFileName, json, outputFileName) => {
// We do nothing because we don't want the generated JSON files
},
// Calculates the whole css-module selector name.
// Should be the same as the one in the file `/src/app/main/style.clj`
generateScopedName: (selector, filename, css) => {
const dir = ph.dirname(filename);
const name = ph.basename(filename, ".css");
const parts = dir.split("/");
const rootIdx = parts.findIndex((s) => s === ROOT_NAME);
return parts.slice(rootIdx + 1).join("_") + "_" + name + "__" + selector;
},
});
}
function configureProcessor(options={}) {
const processors = [];
if (options.modules) {
processors.push(configureModulesProcessor(options));
}
processors.push(autoprefixerProcessor);
return postcss(processors);
}
async function postProcessFile(data, options) {
const proc = configureProcessor(options);
// We compile to the same path (all in memory)
const result = await proc.process(data.css, {
from: data.outputPath,
to: data.outputPath,
map: false,
});
return Object.assign(data, {
css: result.css
});
}
async function compile(path, options) {
let result = await compileFile(path);
return await postProcessFile(result, options);
}
wpool.worker({
compileSass: compile
}, {
onTerminate: async (code) => {
// log.info("worker: terminate");
await compiler.dispose();
}
});

View file

@ -1,4 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# NOTE: this script should be called from the parent directory to
# properly work.
set -ex set -ex
@ -12,13 +14,13 @@ export EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS;
export NODE_ENV=production; export NODE_ENV=production;
yarn install || exit 1; yarn install || exit 1;
yarn run build:clean || exit 1; rm -rf resources/public;
yarn run build:styles || exit 1; rm -rf target/dist;
clojure -J-Xms100M -J-Xmx1000M -J-XX:+UseSerialGC -M:dev:shadow-cljs release main --config-merge "{:release-version \"${CURRENT_HASH}\"}" $EXTRA_PARAMS || exit 1 clojure -M:dev:shadow-cljs release main --config-merge "{:release-version \"${CURRENT_HASH}\"}" $EXTRA_PARAMS || exit 1
yarn run build:assets || exit 1; yarn run compile || exit 1;
yarn run build:copy || exit 1; rsync -avr resources/public/ target/dist/
sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./target/dist/index.html; sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./target/dist/index.html;
sed -i -re "s/\%buildDate\%/$BUILD_DATE/g" ./target/dist/index.html; sed -i -re "s/\%buildDate\%/$BUILD_DATE/g" ./target/dist/index.html;

View file

@ -0,0 +1,10 @@
import fs from "node:fs/promises";
import ppt from "pretty-time";
import log from "fancy-log";
import * as h from "./_helpers.js";
await h.compileStyles();
await h.copyAssets()
await h.compileSvgSprites()
await h.compileTemplates();
await h.compilePolyfills();

View file

@ -1,62 +0,0 @@
#!/usr/bin/env bash
# This script automates compressing PNG images using the lossless Zopfli
# Compression Algorithm. The process is slow but can produce significantly
# better compression and, thus, smaller file sizes.
#
# This script is meant to be run manually, for example, before making a new
# release.
#
# Requirements
#
# zopflipng - https://github.com/google/zopfli
# Debian/Ubuntu: sudo apt install zopfli
# Fedora: sudo dnf install zopfli
# macOS: brew install zopfli
#
# Usage
#
# This script takes a single positional argument which is the path where to
# search for PNG files. By default, the target path is the current working
# directory. Run from the root of the repository to compress all PNG images. Run
# from the `frontend` subdirectory to compress all PNG images within that
# directory. Alternatively, run from any directory and pass an explicit path to
# `compress-png` to limit the script to that path/directory.
set -o errexit
set -o nounset
set -o pipefail
readonly TARGET="${1:-.}"
readonly ABS_TARGET="$(command -v realpath &>/dev/null && realpath "$TARGET")"
function png_total_size() {
find "$TARGET" -type f -iname '*.png' -exec du -ch {} + | tail -1
}
echo "Compressing PNGs in ${ABS_TARGET:-$TARGET}"
echo "Before"
png_total_size
readonly opts=(
# More iterations means slower, potentially better compression.
#--iterations=500
-m
# Try all filter strategies (slow).
#--filters=01234mepb
# According to docs, remove colors behind alpha channel 0. No visual
# difference, removes hidden information.
--lossy_transparent
# Avoid information loss that could affect how images are rendered, see
# https://github.com/penpot/penpot/issues/1533#issuecomment-1030005203
# https://github.com/google/zopfli/issues/113
--keepchunks=cHRM,gAMA,pHYs,iCCP,sRGB,oFFs,sTER
# Since we have git behind our back, overwrite PNG files in-place (only
# when result is smaller).
-y
)
time find "$TARGET" -type f -iname '*.png' -exec zopflipng "${opts[@]}" {} {} \;
echo "After"
png_total_size

View file

@ -1,11 +0,0 @@
#!/usr/bin/env bash
# A repl useful for debug macros.
export OPTIONS="\
-J-XX:-OmitStackTraceInFastThrow \
-J-Xms50m -J-Xmx512m \
-M:dev:jvm-repl";
set -ex;
exec clojure $OPTIONS;

74
frontend/scripts/watch.js Normal file
View file

@ -0,0 +1,74 @@
import proc from "node:child_process";
import fs from "node:fs/promises";
import ph from "node:path";
import log from "fancy-log";
import * as h from "./_helpers.js";
import ppt from "pretty-time";
const worker = h.startWorker();
let sass = null;
async function compileSassAll() {
const start = process.hrtime();
log.info("init: compile styles")
sass = await h.compileSassAll(worker);
let output = await h.concatSass(sass);
await fs.writeFile("./resources/public/css/main.css", output);
const end = process.hrtime(start);
log.info("done: compile styles", `(${ppt(end)})`);
}
async function compileSass(path) {
const start = process.hrtime();
log.info("changed:", path);
const result = await h.compileSass(worker, path, {modules:true});
sass.index[result.outputPath] = result.css;
const output = h.concatSass(sass);
await fs.writeFile("./resources/public/css/main.css", output);
const end = process.hrtime(start);
log.info("done:", `(${ppt(end)})`);
}
await compileSassAll();
await h.copyAssets()
await h.compileSvgSprites()
await h.compileTemplates();
await h.compilePolyfills();
log.info("watch: scss src (~)")
h.watch("src", h.isSassFile, async function (path) {
if (path.includes("common")) {
await compileSassAll(path);
} else {
await compileSass(path);
}
});
log.info("watch: scss: resources (~)")
h.watch("resources/styles", h.isSassFile, async function (path) {
log.info("changed:", path);
await compileSassAll()
});
log.info("watch: templates (~)")
h.watch("resources/templates", null, async function (path) {
log.info("changed:", path);
await h.compileTemplates();
});
log.info("watch: assets (~)")
h.watch(["resources/images", "resources/fonts"], null, async function (path) {
log.info("changed:", path);
await h.compileSvgSprites();
await h.copyAssets();
await h.compileTemplates();
});
worker.terminate();

View file

@ -30,7 +30,7 @@
[:a {:href cf/terms-of-service-uri :target "_blank"} (tr "auth.terms-of-service")]) [:a {:href cf/terms-of-service-uri :target "_blank"} (tr "auth.terms-of-service")])
(when show-all? (when show-all?
[:span (tr "labels.and")]) [:span (dm/str " " (tr "labels.and") " ")])
(when show-privacy? (when show-privacy?
[:a {:href cf/privacy-policy-uri :target "_blank"} (tr "auth.privacy-policy")])]))) [:a {:href cf/privacy-policy-uri :target "_blank"} (tr "auth.privacy-policy")])])))
@ -45,11 +45,12 @@
(dom/set-html-title (tr "title.default"))) (dom/set-html-title (tr "title.default")))
[:main {:class (stl/css :auth-section)} [:main {:class (stl/css :auth-section)}
[:a {:href "#/" :class (stl/css :logo-btn)} i/logo]
[:div {:class (stl/css :login-illustration)} [:div {:class (stl/css :login-illustration)}
i/login-illustration] i/login-illustration]
[:section {:class (stl/css :auth-content)} [:section {:class (stl/css :auth-content)}
[:a {:href "#/" :class (stl/css :logo-btn)} i/logo]
(case section (case section
:auth-register :auth-register
[:& register-page {:params params}] [:& register-page {:params params}]
@ -69,6 +70,5 @@
:auth-recovery :auth-recovery
[:& recovery-page {:params params}]) [:& recovery-page {:params params}])
(when (or (= section :auth-login) (when (= section :auth-register)
(= section :auth-register))
[:& terms-login])]])) [:& terms-login])]]))

View file

@ -7,6 +7,7 @@
@use "common/refactor/common-refactor.scss" as *; @use "common/refactor/common-refactor.scss" as *;
.auth-section { .auth-section {
position: relative;
align-items: center; align-items: center;
background: var(--panel-background-color); background: var(--panel-background-color);
display: grid; display: grid;
@ -43,8 +44,9 @@
.auth-content { .auth-content {
grid-column: 4 / 6; grid-column: 4 / 6;
display: flex; display: grid;
flex-direction: column; grid-template-rows: 1fr auto;
gap: $s-24;
height: fit-content; height: fit-content;
max-width: $s-412; max-width: $s-412;
padding-bottom: $s-8; padding-bottom: $s-8;
@ -53,6 +55,9 @@
} }
.logo-btn { .logo-btn {
position: absolute;
top: $s-20;
left: $s-20;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
margin-bottom: $s-52; margin-bottom: $s-52;
@ -68,8 +73,6 @@
.terms-login { .terms-login {
font-size: $fs-11; font-size: $fs-11;
position: absolute;
bottom: 0;
width: 100%; width: 100%;
display: flex; display: flex;
gap: $s-4; gap: $s-4;
@ -77,7 +80,10 @@
a { a {
font-weight: $fw700; font-weight: $fw700;
color: $df-secondary; color: $da-primary;
&:hover {
text-decoration: underline;
}
} }
span { span {
border-bottom: $s-1 solid transparent; border-bottom: $s-1 solid transparent;

View file

@ -6,36 +6,37 @@
@use "common/refactor/common-refactor.scss" as *; @use "common/refactor/common-refactor.scss" as *;
.auth-form { .auth-form-wrapper {
width: 100%; width: 100%;
padding-block-end: $s-16; padding-block-end: 0;
display: grid;
gap: $s-24;
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $s-12; gap: $s-12;
margin-block-end: $s-24;
} }
} }
.separator { .separator {
border-color: $db-quaternary; border-color: var(--modal-separator-backogrund-color);
margin: $s-24 0; margin: 0;
}
.error-wrapper {
padding-block-end: $s-8;
} }
.auth-title { .auth-title {
@include bigTitleTipography; @include bigTitleTipography;
color: $df-primary; color: var(--title-foreground-color-hover);
} }
.auth-subtitle { .auth-subtitle {
margin-top: $s-24; @include smallTitleTipography;
font-size: $fs-14; color: var(--title-foreground-color);
color: $df-secondary; }
.auth-tagline {
@include smallTitleTipography;
margin: 0;
color: var(--title-foreground-color);
} }
.form-field { .form-field {
@ -45,77 +46,102 @@
} }
.buttons-stack { .buttons-stack {
display: flex; display: grid;
flex-direction: column;
gap: $s-8; gap: $s-8;
button,
:global(.btn-primary) {
@extend .button-primary;
font-size: $fs-11;
height: $s-40;
text-transform: uppercase;
width: 100%;
}
} }
.link-entry { .login-button,
.login-ldap-button {
@extend .button-primary;
@include uppercaseTitleTipography;
height: $s-40;
width: 100%;
}
.demo-account,
.go-back {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $s-12; gap: $s-12;
padding: $s-24 0; padding: 0;
border-top: $s-1 solid $db-quaternary; border-block-start: none;
}
span { .demo-account-link,
text-align: center; .go-back-link {
font-size: $fs-14; @extend .button-secondary;
color: $df-secondary; @include uppercaseTitleTipography;
} height: $s-40;
a { }
@extend .button-secondary;
height: $s-40; .links {
text-transform: uppercase; display: grid;
font-size: $fs-11; gap: $s-24;
} }
&.register a {
@extend .button-primary; .register,
.account,
.recovery-request {
display: flex;
justify-content: center;
gap: $s-8;
padding: 0;
}
.register-text,
.account-text,
.recovery-text {
@include smallTitleTipography;
text-align: right;
color: var(--title-foreground-color);
}
.register-link,
.account-link,
.recovery-link,
.forgot-pass-link {
@include smallTitleTipography;
text-align: left;
background-color: transparent;
border: none;
display: inline;
color: var(--link-foreground-color);
&:hover {
text-decoration: underline;
} }
} }
.forgot-password { .forgot-password {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
a {
font-size: $fs-14;
color: $df-secondary;
font-weight: $fw400;
}
} }
.submit-btn, .submit-btn,
.register-btn, .register-btn,
.recover-btn { .recover-btn {
@extend .button-primary; @extend .button-primary;
font-size: $fs-11; @include uppercaseTitleTipography;
height: $s-40; height: $s-40;
text-transform: uppercase;
width: 100%; width: 100%;
} }
.login-btn { .login-btn {
border-radius: $br-8; @include smallTitleTipography;
font-size: $fs-14;
display: flex; display: flex;
align-items: center; align-items: center;
gap: $s-6; gap: $s-6;
width: 100%; width: 100%;
border-radius: $br-8;
background-color: var(--button-secondary-background-color-rest);
color: var(--button-foreground-color-focus);
span { span {
padding-top: $s-2; padding-block-start: $s-2;
} }
&:hover { &:hover {
color: var(--app-white); color: var(--button-foreground-color-focus);
background-color: var(--button-secondary-background-color-hover);
} }
} }
@ -123,39 +149,3 @@
display: flex; display: flex;
gap: $s-8; gap: $s-8;
} }
.btn-google-auth {
color: var(--google-login-foreground);
background-color: var(--google-login-background);
&:hover {
background: var(--google-login-background-hover);
}
}
.btn-github-auth {
color: var(--github-login-foreground);
background: var(--github-login-background);
&:hover {
background: var(--github-login-background-hover);
}
}
.btn-oidc-auth {
color: var(--oidc-login-foreground);
background: var(--oidc-login-background);
&:hover {
background: var(--oidc-login-background-hover);
}
}
.btn-gitlab-auth {
color: var(--gitlab-login-foreground);
background: var(--gitlab-login-background);
&:hover {
background: var(--gitlab-login-background-hover);
}
}
.banner {
margin: $s-16 0;
}

View file

@ -38,10 +38,9 @@
(mf/defc demo-warning (mf/defc demo-warning
{::mf/props :obj} {::mf/props :obj}
[] []
[:div {:class (stl/css :banner)} [:& context-notification
[:& context-notification {:type :warning
{:type :warning :content (tr "auth.demo-warning")}])
:content (tr "auth.demo-warning")}]])
(defn- login-with-oidc (defn- login-with-oidc
[event provider params] [event provider params]
@ -166,14 +165,15 @@
[:* [:*
(when-let [message @error] (when-let [message @error]
[:div {:class (stl/css :error-wrapper)} [:& context-notification
[:& context-notification {:type :warning
{:type :warning :content message
:content message :data-test "login-banner"
:data-test "login-banner" :role "alert"}])
:role "alert"}]])
[:& fm/form {:on-submit on-submit :form form} [:& fm/form {:on-submit on-submit
:class (stl/css :login-form)
:form form}
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input [:& fm/input
{:name :email {:name :email
@ -193,6 +193,7 @@
(contains? cf/flags :login-with-password))) (contains? cf/flags :login-with-password)))
[:div {:class (stl/css :fields-row :forgot-password)} [:div {:class (stl/css :fields-row :forgot-password)}
[:& lk/link {:action on-recovery-request [:& lk/link {:action on-recovery-request
:class (stl/css :forgot-pass-link)
:data-test "forgot-password"} :data-test "forgot-password"}
(tr "auth.forgot-password")]]) (tr "auth.forgot-password")]])
@ -207,6 +208,7 @@
(when (contains? cf/flags :login-with-ldap) (when (contains? cf/flags :login-with-ldap)
[:> fm/submit-button* [:> fm/submit-button*
{:label (tr "auth.login-with-ldap-submit") {:label (tr "auth.login-with-ldap-submit")
:class (stl/css :login-ldap-button)
:on-click on-submit-ldap}])]]])) :on-click on-submit-ldap}])]]]))
(mf/defc login-buttons (mf/defc login-buttons
@ -255,11 +257,11 @@
(when (k/enter? event) (when (k/enter? event)
(login-oidc event))))] (login-oidc event))))]
(when (contains? cf/flags :login-with-oidc) (when (contains? cf/flags :login-with-oidc)
[:div {:class (stl/css :link-entry :link-oidc)} [:button {:tab-index "0"
[:a {:tab-index "0" :class (stl/css :link-entry :link-oidc)
:on-key-down handle-key-down :on-key-down handle-key-down
:on-click login-oidc} :on-click login-oidc}
(tr "auth.login-with-oidc-submit")]]))) (tr "auth.login-with-oidc-submit")])))
(mf/defc login-methods (mf/defc login-methods
[{:keys [params on-success-callback origin] :as props}] [{:keys [params on-success-callback origin] :as props}]
@ -282,35 +284,29 @@
[{:keys [params] :as props}] [{:keys [params] :as props}]
(let [go-register (let [go-register
(mf/use-fn (mf/use-fn
#(st/emit! (rt/nav :auth-register {} params))) #(st/emit! (rt/nav :auth-register {} params)))]
on-create-demo-profile [:div {:class (stl/css :auth-form-wrapper)}
(mf/use-fn
#(st/emit! (du/create-demo-profile)))]
[:div {:class (stl/css :auth-form)}
[:h1 {:class (stl/css :auth-title) [:h1 {:class (stl/css :auth-title)
:data-test "login-title"} (tr "auth.login-title")] :data-test "login-title"} (tr "auth.login-account-title")]
[:p {:class (stl/css :auth-tagline)}
(tr "auth.login-tagline")]
(when (contains? cf/flags :demo-warning) (when (contains? cf/flags :demo-warning)
[:& demo-warning]) [:& demo-warning])
[:hr {:class (stl/css :separator)}]
[:& login-methods {:params params}] [:& login-methods {:params params}]
[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
(when (contains? cf/flags :registration) (when (contains? cf/flags :registration)
[:div {:class (stl/css :link-entry :register)} [:div {:class (stl/css :register)}
[:span (tr "auth.register") " "] [:span {:class (stl/css :register-text)}
(tr "auth.register") " "]
[:& lk/link {:action go-register [:& lk/link {:action go-register
:class (stl/css :register-link)
:data-test "register-submit"} :data-test "register-submit"}
(tr "auth.register-submit")]])] (tr "auth.register-submit")]])]]))
(when (contains? cf/flags :demo-users)
[:div {:class (stl/css :link-entry :demo-account)}
[:span (tr "auth.create-demo-profile") " "]
[:& lk/link {:action on-create-demo-profile
:data-test "demo-account-link"}
(tr "auth.create-demo-account")]])]))

View file

@ -61,7 +61,9 @@
(fm/validate-not-empty :password-1 (tr "auth.password-not-empty")) (fm/validate-not-empty :password-1 (tr "auth.password-not-empty"))
(fm/validate-not-empty :password-2 (tr "auth.password-not-empty"))] (fm/validate-not-empty :password-2 (tr "auth.password-not-empty"))]
:initial params)] :initial params)]
[:& fm/form {:on-submit on-submit :form form} [:& fm/form {:on-submit on-submit
:class (stl/css :recovery-form)
:form form}
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input {:type "password" [:& fm/input {:type "password"
:name :password-1 :name :password-1
@ -84,13 +86,14 @@
(mf/defc recovery-page (mf/defc recovery-page
[{:keys [params] :as props}] [{:keys [params] :as props}]
[:div {:class (stl/css :auth-form)} [:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)} "Forgot your password?"] [:h1 {:class (stl/css :auth-title)} "Forgot your password?"]
[:div {:class (stl/css :auth-subtitle)} "Please enter your new password"] [:div {:class (stl/css :auth-subtitle)} "Please enter your new password"]
[:hr {:class (stl/css :separator)}] [:hr {:class (stl/css :separator)}]
[:& recovery-form {:params params}] [:& recovery-form {:params params}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :go-back)}
[:a {:on-click #(st/emit! (rt/nav :auth-login))} [:a {:on-click #(st/emit! (rt/nav :auth-login))
:class (stl/css :go-back-link)}
(tr "profile.recovery.go-to-login")]]]]) (tr "profile.recovery.go-to-login")]]]])

View file

@ -76,6 +76,7 @@
(st/emit! (du/request-profile-recovery params)))))] (st/emit! (du/request-profile-recovery params)))))]
[:& fm/form {:on-submit on-submit [:& fm/form {:on-submit on-submit
:class (stl/css :recovery-request-form)
:form form} :form form}
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input {:name :email [:& fm/input {:name :email
@ -95,14 +96,15 @@
[{:keys [params on-success-callback go-back-callback] :as props}] [{:keys [params on-success-callback go-back-callback] :as props}]
(let [default-go-back #(st/emit! (rt/nav :auth-login)) (let [default-go-back #(st/emit! (rt/nav :auth-login))
go-back (or go-back-callback default-go-back)] go-back (or go-back-callback default-go-back)]
[:div {:class (stl/css :auth-form)} [:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)} (tr "auth.recovery-request-title")] [:h1 {:class (stl/css :auth-title)} (tr "auth.recovery-request-title")]
[:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")] [:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")]
[:hr {:class (stl/css :separator)}] [:hr {:class (stl/css :separator)}]
[:& recovery-form {:params params :on-success-callback on-success-callback}] [:& recovery-form {:params params :on-success-callback on-success-callback}]
[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :go-back)}
[:& lk/link {:action go-back [:& lk/link {:action go-back
:class (stl/css :go-back-link)
:data-test "go-back-link"} :data-test "go-back-link"}
(tr "labels.go-back")]]])) (tr "labels.go-back")]]]))

View file

@ -132,19 +132,18 @@
[{:keys [params on-success-callback]}] [{:keys [params on-success-callback]}]
[:* [:*
(when login/show-alt-login-buttons? (when login/show-alt-login-buttons?
[:* [:& login/login-buttons {:params params}])
[:hr {:class (stl/css :separator)}]
[:& login/login-buttons {:params params}]])
[:hr {:class (stl/css :separator)}] [:hr {:class (stl/css :separator)}]
[:& register-form {:params params :on-success-callback on-success-callback}]]) [:& register-form {:params params :on-success-callback on-success-callback}]])
(mf/defc register-page (mf/defc register-page
{::mf/props :obj} {::mf/props :obj}
[{:keys [params]}] [{:keys [params]}]
[:div {:class (stl/css :auth-form)} [:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title) [:h1 {:class (stl/css :auth-title)
:data-test "registration-title"} (tr "auth.register-title")] :data-test "registration-title"} (tr "auth.register-title")]
[:div {:class (stl/css :auth-subtitle)} (tr "auth.register-subtitle")] [:p {:class (stl/css :auth-tagline)}
(tr "auth.login-tagline")]
(when (contains? cf/flags :demo-warning) (when (contains? cf/flags :demo-warning)
[:& login/demo-warning]) [:& login/demo-warning])
@ -152,18 +151,20 @@
[:& register-methods {:params params}] [:& register-methods {:params params}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry :account)} [:div {:class (stl/css :account)}
[:span (tr "auth.already-have-account") " "] [:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "]
[:& lk/link {:action #(st/emit! (rt/nav :auth-login {} params)) [:& lk/link {:action #(st/emit! (rt/nav :auth-login {} params))
:class (stl/css :account-link)
:data-test "login-here-link"} :data-test "login-here-link"}
(tr "auth.login-here")]] (tr "auth.login-here")]]
(when (contains? cf/flags :demo-users) (when (contains? cf/flags :demo-users)
[:div {:class (stl/css :link-entry :demo-users)} [:*
[:span (tr "auth.create-demo-profile") " "] [:hr {:class (stl/css :separator)}]
[:& lk/link {:action #(st/emit! (du/create-demo-profile))} [:div {:class (stl/css :demo-account)}
(tr "auth.create-demo-account")]])]]) [:& lk/link {:action #(st/emit! (du/create-demo-profile))
:class (stl/css :demo-account-link)}
(tr "auth.create-demo-account")]]])]])
;; --- PAGE: register validation ;; --- PAGE: register validation
@ -228,7 +229,8 @@
(rx/subs! on-success (rx/subs! on-success
(partial handle-register-error form))))))] (partial handle-register-error form))))))]
[:& fm/form {:on-submit on-submit :form form} [:& fm/form {:on-submit on-submit :form form
:class (stl/css :register-validate-form)}
[:div {:class (stl/css :fields-row)} [:div {:class (stl/css :fields-row)}
[:& fm/input {:name :fullname [:& fm/input {:name :fullname
:label (tr "auth.fullname") :label (tr "auth.fullname")
@ -258,7 +260,7 @@
(mf/defc register-validate-page (mf/defc register-validate-page
[{:keys [params]}] [{:keys [params]}]
[:div {:class (stl/css :auth-form)} [:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title) [:h1 {:class (stl/css :auth-title)
:data-test "register-title"} (tr "auth.register-title")] :data-test "register-title"} (tr "auth.register-title")]
[:div {:class (stl/css :auth-subtitle)} (tr "auth.register-subtitle")] [:div {:class (stl/css :auth-subtitle)} (tr "auth.register-subtitle")]
@ -268,13 +270,14 @@
[:& register-validate-form {:params params}] [:& register-validate-form {:params params}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry :go-back)} [:div {:class (stl/css :go-back)}
[:& lk/link {:action #(st/emit! (rt/nav :auth-register {} {}))} [:& lk/link {:action #(st/emit! (rt/nav :auth-register {} {}))
:class (stl/css :go-back-link)}
(tr "labels.go-back")]]]]) (tr "labels.go-back")]]]])
(mf/defc register-success-page (mf/defc register-success-page
[{:keys [params]}] [{:keys [params]}]
[:div {:class (stl/css :auth-form :register-success)} [:div {:class (stl/css :auth-form-wrapper :register-success)}
[:div {:class (stl/css :notification-icon)} i/icon-verify] [:div {:class (stl/css :notification-icon)} i/icon-verify]
[:div {:class (stl/css :notification-text)} (tr "auth.verification-email-sent")] [:div {:class (stl/css :notification-text)} (tr "auth.verification-email-sent")]
[:div {:class (stl/css :notification-text-email)} (:email params "")] [:div {:class (stl/css :notification-text-email)} (:email params "")]

View file

@ -50,7 +50,6 @@
cursor: pointer; cursor: pointer;
color: var(--modal-title-foreground-color); color: var(--modal-title-foreground-color);
text-transform: uppercase; text-transform: uppercase;
margin-bottom: $s-8;
input { input {
@extend .input-element; @extend .input-element;
color: var(--input-foreground-color-active); color: var(--input-foreground-color-active);
@ -144,8 +143,9 @@
.hint { .hint {
@include bodySmallTypography; @include bodySmallTypography;
color: var(--modal-text-foreground-color);
width: 99%; width: 99%;
margin-block-start: $s-8;
color: var(--modal-text-foreground-color);
} }
.checkbox { .checkbox {

View file

@ -37,6 +37,7 @@
.group-name-input { .group-name-input {
@extend .input-element-label; @extend .input-element-label;
margin-bottom: $s-8;
label { label {
@include flexColumn; @include flexColumn;
@include bodySmallTypography; @include bodySmallTypography;

View file

@ -127,6 +127,7 @@
height: $s-32; height: $s-32;
width: calc(100% - $s-24); width: calc(100% - $s-24);
margin-inline-start: $s-24; margin-inline-start: $s-24;
margin-block-end: $s-8;
} }
// STEP-4 // STEP-4

View file

@ -9,6 +9,7 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.types.component :as ctk]
[app.main.data.viewer :as dv] [app.main.data.viewer :as dv]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]] [app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]]
@ -30,7 +31,7 @@
item-ref (mf/use-ref nil) item-ref (mf/use-ref nil)
depth (+ depth 1) depth (+ depth 1)
component-tree? (or component-child? (:component-root item)) component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
collapsed-iref collapsed-iref
(mf/use-memo (mf/use-memo

View file

@ -78,19 +78,21 @@
:on-click close} i/close]] :on-click close} i/close]]
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
(case current-section (case current-section
:login :login
[:div {:class (stl/css :form-container)} [:div {:class (stl/css :form-container)}
[:& login-methods {:on-success-callback success-login :origin :viewer}] [:& login-methods {:on-success-callback success-login :origin :viewer}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :recovery-request)}
[:a {:on-click set-section [:a {:on-click set-section
:class (stl/css :recovery-link)
:data-value "recovery-request"} :data-value "recovery-request"}
(tr "auth.forgot-password")]] (tr "auth.forgot-password")]]
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :register)}
[:span (tr "auth.register") " "] [:span {:class (stl/css :register-text)}
(tr "auth.register") " "]
[:a {:on-click set-section [:a {:on-click set-section
:class (stl/css :register-link)
:data-value "register"} :data-value "register"}
(tr "auth.register-submit")]]]] (tr "auth.register-submit")]]]]
@ -98,7 +100,7 @@
[:div {:class (stl/css :form-container)} [:div {:class (stl/css :form-container)}
[:& register-methods {:on-success-callback success-register}] [:& register-methods {:on-success-callback success-register}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :account)}
[:span (tr "auth.already-have-account") " "] [:span (tr "auth.already-have-account") " "]
[:a {:on-click set-section [:a {:on-click set-section
:data-value "login"} :data-value "login"}
@ -109,7 +111,7 @@
[:& register-validate-form {:params {:token @register-token} [:& register-validate-form {:params {:token @register-token}
:on-success-callback success-email-sent}] :on-success-callback success-email-sent}]
[:div {:class (stl/css :links)} [:div {:class (stl/css :links)}
[:div {:class (stl/css :link-entry)} [:div {:class (stl/css :register)}
[:a {:on-click set-section [:a {:on-click set-section
:data-value "register"} :data-value "register"}
(tr "labels.go-back")]]]] (tr "labels.go-back")]]]]

View file

@ -49,6 +49,7 @@
} }
.input-wrapper { .input-wrapper {
@extend .input-with-label; @extend .input-with-label;
margin-bottom: $s-8;
} }
.action-buttons { .action-buttons {
@extend .modal-action-btns; @extend .modal-action-btns;

View file

@ -10,6 +10,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@ -61,7 +62,7 @@
:class (stl/css-case :class (stl/css-case
:layer-row true :layer-row true
:highlight highlighted? :highlight highlighted?
:component (some? (:component-id item)) :component (ctk/instance-head? item)
:masked (:masked-group item) :masked (:masked-group item)
:selected selected? :selected selected?
:type-frame (cfh/frame-shape? item) :type-frame (cfh/frame-shape? item)
@ -321,7 +322,7 @@
ref (mf/use-ref) ref (mf/use-ref)
depth (+ depth 1) depth (+ depth 1)
component-tree? (or component-child? (:component-root item)) component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item))
enable-drag (mf/use-fn #(reset! drag-disabled* false)) enable-drag (mf/use-fn #(reset! drag-disabled* false))
disable-drag (mf/use-fn #(reset! drag-disabled* true))] disable-drag (mf/use-fn #(reset! drag-disabled* true))]

View file

@ -58,8 +58,12 @@ msgid "auth.login-submit"
msgstr "Login" msgstr "Login"
#: src/app/main/ui/auth/login.cljs #: src/app/main/ui/auth/login.cljs
msgid "auth.login-title" msgid "auth.login-account-title"
msgstr "Great to see you again!" msgstr "Log into my account"
#: src/app/main/ui/auth/login.cljs
msgid "auth.login-tagline"
msgstr "Penpot is the free open-source design tool for Design and Code collaboration"
#: src/app/main/ui/auth/login.cljs #: src/app/main/ui/auth/login.cljs
msgid "auth.login-with-github-submit" msgid "auth.login-with-github-submit"

View file

@ -61,8 +61,12 @@ msgid "auth.login-submit"
msgstr "Entrar" msgstr "Entrar"
#: src/app/main/ui/auth/login.cljs #: src/app/main/ui/auth/login.cljs
msgid "auth.login-title" msgid "auth.login-account-title"
msgstr "¡Un placer verte de nuevo!" msgstr "Entrar en mi cuenta"
#: src/app/main/ui/auth/login.cljs
msgid "auth.login-tagline"
msgstr "Penpot es la herramienta de diseño libre y open-source para la colaboración entre Diseño y Código"
#: src/app/main/ui/auth/login.cljs #: src/app/main/ui/auth/login.cljs
msgid "auth.login-with-github-submit" msgid "auth.login-with-github-submit"

View file

@ -1471,6 +1471,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@bufbuild/protobuf@npm:^1.0.0":
version: 1.7.2
resolution: "@bufbuild/protobuf@npm:1.7.2"
checksum: 37a968b7d314c1f2e2b996bb287c72dbeaacd5bc0d92e2f706437a51c4e483ff85b97994428e252d6acf99bd7b16435471413ae3af1bd9b416d72ab3f0decd22
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0": "@colors/colors@npm:1.5.0":
version: 1.5.0 version: 1.5.0
resolution: "@colors/colors@npm:1.5.0" resolution: "@colors/colors@npm:1.5.0"
@ -5333,6 +5340,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"buffer-builder@npm:^0.2.0":
version: 0.2.0
resolution: "buffer-builder@npm:0.2.0"
checksum: e50c3a379f4acaea75ade1ee3e8c07ed6d7c5dfc3f98adbcf0159bfe1a4ce8ca1fe3689e861fcdb3fcef0012ebd4345a6112a5b8a1185295452bb66d7b6dc8a1
languageName: node
linkType: hard
"buffer-crc32@npm:~0.2.3": "buffer-crc32@npm:~0.2.3":
version: 0.2.13 version: 0.2.13
resolution: "buffer-crc32@npm:0.2.13" resolution: "buffer-crc32@npm:0.2.13"
@ -6601,6 +6615,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"dettle@npm:^1.0.1":
version: 1.0.1
resolution: "dettle@npm:1.0.1"
checksum: 116a101aff93b2e1d5e505adbe53c4b898d924bc16f12f5ac629055ed8a8a19c86f916b834b178b7bfb352dd601bbfe01e49ccd56144a5a2f780f4bd374ef112
languageName: node
linkType: hard
"diff-sequences@npm:^29.6.3": "diff-sequences@npm:^29.6.3":
version: 29.6.3 version: 29.6.3
resolution: "diff-sequences@npm:29.6.3" resolution: "diff-sequences@npm:29.6.3"
@ -7939,13 +7960,16 @@ __metadata:
marked: "npm:^12.0.0" marked: "npm:^12.0.0"
mkdirp: "npm:^3.0.1" mkdirp: "npm:^3.0.1"
mousetrap: "npm:^1.6.5" mousetrap: "npm:^1.6.5"
mustache: "npm:^4.2.0"
nodemon: "npm:^3.1.0" nodemon: "npm:^3.1.0"
npm-run-all: "npm:^4.1.5" npm-run-all: "npm:^4.1.5"
opentype.js: "npm:^1.3.4" opentype.js: "npm:^1.3.4"
p-limit: "npm:^5.0.0"
postcss: "npm:^8.4.35" postcss: "npm:^8.4.35"
postcss-clean: "npm:^1.2.2" postcss-clean: "npm:^1.2.2"
postcss-modules: "npm:^6.0.0" postcss-modules: "npm:^6.0.0"
prettier: "npm:^3.2.5" prettier: "npm:^3.2.5"
pretty-time: "npm:^1.1.0"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
randomcolor: "npm:^0.6.2" randomcolor: "npm:^0.6.2"
react: "npm:^18.2.0" react: "npm:^18.2.0"
@ -7954,15 +7978,19 @@ __metadata:
rimraf: "npm:^5.0.5" rimraf: "npm:^5.0.5"
rxjs: "npm:8.0.0-alpha.14" rxjs: "npm:8.0.0-alpha.14"
sass: "npm:^1.71.1" sass: "npm:^1.71.1"
sass-embedded: "npm:^1.71.1"
sax: "npm:^1.3.0" sax: "npm:^1.3.0"
shadow-cljs: "npm:2.27.4" shadow-cljs: "npm:2.27.4"
source-map-support: "npm:^0.5.21" source-map-support: "npm:^0.5.21"
storybook: "npm:^7.6.17" storybook: "npm:^7.6.17"
svg-sprite: "npm:^2.0.2"
tdigest: "npm:^0.1.2" tdigest: "npm:^0.1.2"
typescript: "npm:^5.3.3" typescript: "npm:^5.3.3"
ua-parser-js: "npm:^1.0.37" ua-parser-js: "npm:^1.0.37"
vite: "npm:^5.1.4" vite: "npm:^5.1.4"
vitest: "npm:^1.3.1" vitest: "npm:^1.3.1"
watcher: "npm:^2.3.0"
workerpool: "npm:^9.1.0"
xregexp: "npm:^5.1.1" xregexp: "npm:^5.1.1"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -12087,6 +12115,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"pretty-time@npm:^1.1.0":
version: 1.1.0
resolution: "pretty-time@npm:1.1.0"
checksum: ba9d7af19cd43838fb2b147654990949575e400dc2cc24bf71ec4a6c4033a38ba8172b1014b597680c6d4d3c075e94648b2c13a7206c5f0c90b711c7388726f3
languageName: node
linkType: hard
"prettysize@npm:^2.0.0": "prettysize@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "prettysize@npm:2.0.0" resolution: "prettysize@npm:2.0.0"
@ -12122,6 +12157,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"promise-make-naked@npm:^2.1.1":
version: 2.1.1
resolution: "promise-make-naked@npm:2.1.1"
checksum: 97bc0a3eeae59f75e8716d5f511edb4ed7558fa304f93407a7c9de3645a19135abfc87d4bca0b570619d3314fa87db67ea3463c4a5068c4bbe7f8889c6883f1d
languageName: node
linkType: hard
"promise-retry@npm:^2.0.1": "promise-retry@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "promise-retry@npm:2.0.1" resolution: "promise-retry@npm:2.0.1"
@ -13126,7 +13168,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"rxjs@npm:^7.8.1": "rxjs@npm:^7.4.0, rxjs@npm:^7.8.1":
version: 7.8.1 version: 7.8.1
resolution: "rxjs@npm:7.8.1" resolution: "rxjs@npm:7.8.1"
dependencies: dependencies:
@ -13195,6 +13237,205 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sass-embedded-android-arm64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-android-arm64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"sass-embedded-android-arm@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-android-arm@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"sass-embedded-android-ia32@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-android-ia32@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=android & cpu=ia32
languageName: node
linkType: hard
"sass-embedded-android-x64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-android-x64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"sass-embedded-darwin-arm64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-darwin-arm64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"sass-embedded-darwin-x64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-darwin-x64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"sass-embedded-linux-arm64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-arm64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"sass-embedded-linux-arm@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-arm@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"sass-embedded-linux-ia32@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-ia32@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"sass-embedded-linux-musl-arm64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-musl-arm64@npm:1.71.1"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"sass-embedded-linux-musl-arm@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-musl-arm@npm:1.71.1"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"sass-embedded-linux-musl-ia32@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-musl-ia32@npm:1.71.1"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"sass-embedded-linux-musl-x64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-musl-x64@npm:1.71.1"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"sass-embedded-linux-x64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-linux-x64@npm:1.71.1"
bin:
sass: dart-sass/sass
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"sass-embedded-win32-ia32@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-win32-ia32@npm:1.71.1"
bin:
sass: dart-sass/sass.bat
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"sass-embedded-win32-x64@npm:1.71.1":
version: 1.71.1
resolution: "sass-embedded-win32-x64@npm:1.71.1"
bin:
sass: dart-sass/sass.bat
conditions: os=win32 & (cpu=arm64 | cpu=x64)
languageName: node
linkType: hard
"sass-embedded@npm:^1.71.1":
version: 1.71.1
resolution: "sass-embedded@npm:1.71.1"
dependencies:
"@bufbuild/protobuf": "npm:^1.0.0"
buffer-builder: "npm:^0.2.0"
immutable: "npm:^4.0.0"
rxjs: "npm:^7.4.0"
sass-embedded-android-arm: "npm:1.71.1"
sass-embedded-android-arm64: "npm:1.71.1"
sass-embedded-android-ia32: "npm:1.71.1"
sass-embedded-android-x64: "npm:1.71.1"
sass-embedded-darwin-arm64: "npm:1.71.1"
sass-embedded-darwin-x64: "npm:1.71.1"
sass-embedded-linux-arm: "npm:1.71.1"
sass-embedded-linux-arm64: "npm:1.71.1"
sass-embedded-linux-ia32: "npm:1.71.1"
sass-embedded-linux-musl-arm: "npm:1.71.1"
sass-embedded-linux-musl-arm64: "npm:1.71.1"
sass-embedded-linux-musl-ia32: "npm:1.71.1"
sass-embedded-linux-musl-x64: "npm:1.71.1"
sass-embedded-linux-x64: "npm:1.71.1"
sass-embedded-win32-ia32: "npm:1.71.1"
sass-embedded-win32-x64: "npm:1.71.1"
supports-color: "npm:^8.1.1"
varint: "npm:^6.0.0"
dependenciesMeta:
sass-embedded-android-arm:
optional: true
sass-embedded-android-arm64:
optional: true
sass-embedded-android-ia32:
optional: true
sass-embedded-android-x64:
optional: true
sass-embedded-darwin-arm64:
optional: true
sass-embedded-darwin-x64:
optional: true
sass-embedded-linux-arm:
optional: true
sass-embedded-linux-arm64:
optional: true
sass-embedded-linux-ia32:
optional: true
sass-embedded-linux-musl-arm:
optional: true
sass-embedded-linux-musl-arm64:
optional: true
sass-embedded-linux-musl-ia32:
optional: true
sass-embedded-linux-musl-x64:
optional: true
sass-embedded-linux-x64:
optional: true
sass-embedded-win32-ia32:
optional: true
sass-embedded-win32-x64:
optional: true
checksum: 637b00398b92b88db6b6dc8906d1c6e42c6907cd26afbda05ff3cdc19360eb2efeeaa8591c995f14e05aa8a08314bf7af219a4cbe1172a95365ca6b442b799d5
languageName: node
linkType: hard
"sass@npm:^1.71.1": "sass@npm:^1.71.1":
version: 1.71.1 version: 1.71.1
resolution: "sass@npm:1.71.1" resolution: "sass@npm:1.71.1"
@ -14038,6 +14279,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"stubborn-fs@npm:^1.2.5":
version: 1.2.5
resolution: "stubborn-fs@npm:1.2.5"
checksum: 0676befd9901d4dd4e162700fa0396f11d523998589cd6b61b06d1021db811dc4c1e6713869748c6cfa49d58beb9b6f0dc5b6aca6b075811b949e1602ce1e26f
languageName: node
linkType: hard
"supports-color@npm:^5.3.0, supports-color@npm:^5.4.0, supports-color@npm:^5.5.0": "supports-color@npm:^5.3.0, supports-color@npm:^5.4.0, supports-color@npm:^5.5.0":
version: 5.5.0 version: 5.5.0
resolution: "supports-color@npm:5.5.0" resolution: "supports-color@npm:5.5.0"
@ -14316,6 +14564,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tiny-readdir@npm:^2.2.0":
version: 2.4.0
resolution: "tiny-readdir@npm:2.4.0"
dependencies:
promise-make-naked: "npm:^2.1.1"
checksum: 0fd05eb677a9bf25f6ace33ad2eeaeb8555303321e18cd22c7a96391f099c1dd900d745738a1c6ba276540b1dc117f72fbbf60cc47bf1c7a73840745e3ea42f8
languageName: node
linkType: hard
"tinybench@npm:^2.5.1": "tinybench@npm:^2.5.1":
version: 2.5.1 version: 2.5.1
resolution: "tinybench@npm:2.5.1" resolution: "tinybench@npm:2.5.1"
@ -15071,6 +15328,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"varint@npm:^6.0.0":
version: 6.0.0
resolution: "varint@npm:6.0.0"
checksum: 737fc37088a62ed3bd21466e318d21ca7ac4991d0f25546f518f017703be4ed0f9df1c5559f1dd533dddba4435a1b758fd9230e4772c1a930ef72b42f5c750fd
languageName: node
linkType: hard
"vary@npm:~1.1.2": "vary@npm:~1.1.2":
version: 1.1.2 version: 1.1.2
resolution: "vary@npm:1.1.2" resolution: "vary@npm:1.1.2"
@ -15311,6 +15575,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"watcher@npm:^2.3.0":
version: 2.3.0
resolution: "watcher@npm:2.3.0"
dependencies:
dettle: "npm:^1.0.1"
stubborn-fs: "npm:^1.2.5"
tiny-readdir: "npm:^2.2.0"
checksum: 7b1e47321ddf96882ebee6f619211b085f98bc0c3bceb94a58938e8d8d209f83283b30b645bdae148e063c3bc165eeafd73e3a14bdb7c3bfe519bd7536172257
languageName: node
linkType: hard
"watchpack@npm:^2.2.0": "watchpack@npm:^2.2.0":
version: 2.4.0 version: 2.4.0
resolution: "watchpack@npm:2.4.0" resolution: "watchpack@npm:2.4.0"
@ -15521,6 +15796,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"workerpool@npm:^9.1.0":
version: 9.1.0
resolution: "workerpool@npm:9.1.0"
checksum: 32d0807962be58a98ec22f5630be4a90f779f5faab06d5b4f000d32c11c8d5feb66be9bc5c73fdc49c91519e391db55c9e2e63392854b3df945744b2436a7efd
languageName: node
linkType: hard
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
version: 7.0.0 version: 7.0.0
resolution: "wrap-ansi@npm:7.0.0" resolution: "wrap-ansi@npm:7.0.0"