diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index aead09110f..d89809f37a 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -9,31 +9,24 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] - [app.common.transit :as t] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.http :as-alias http] [app.http.access-token :as-alias actoken] - [app.http.client :as http.client] [app.loggers.audit.tasks :as-alias tasks] [app.loggers.webhooks :as-alias webhooks] - [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.retry :as rtry] [app.setup :as-alias setup] - [app.tokens :as tokens] [app.util.services :as-alias sv] [app.util.time :as dt] [app.worker :as wrk] [clojure.spec.alpha :as s] [cuerdas.core :as str] [integrant.core :as ig] - [lambdaisland.uri :as u] - [promesa.exec :as px] [ring.request :as rreq])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -195,14 +188,14 @@ :profile-id (::profile-id event) :ip-addr (::ip-addr event) :context (::context event) - :props (::props event)}] + :props (::props event)} + tnow (dt/now)] (when (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. - (let [tnow (dt/now) - params (-> params + (let [params (-> params (assoc :created-at tnow) (assoc :tracked-at tnow) (update :props db/tjson) @@ -211,6 +204,23 @@ (assoc :source "backend"))] (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) (::webhooks/event? event)) (let [batch-key (::webhooks/batch-key event) @@ -249,137 +259,3 @@ (rtry/invoke! cfg db/tx-run! handle-event! event)) (catch Throwable 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))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj new file mode 100644 index 0000000000..046fb8068d --- /dev/null +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -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)))))))) + diff --git a/backend/src/app/loggers/audit/gc_task.clj b/backend/src/app/loggers/audit/gc_task.clj new file mode 100644 index 0000000000..7f94217a49 --- /dev/null +++ b/backend/src/app/loggers/audit/gc_task.clj @@ -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))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index e0177110fa..056c99cc8a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -21,7 +21,6 @@ [app.http.session :as-alias session] [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] - [app.loggers.audit.tasks :as-alias audit.tasks] [app.loggers.webhooks :as-alias webhooks] [app.metrics :as-alias mtx] [app.metrics.definition :as-alias mdef] @@ -346,8 +345,8 @@ :storage-gc-deleted (ig/ref ::sto.gc-deleted/handler) :storage-gc-touched (ig/ref ::sto.gc-touched/handler) :session-gc (ig/ref ::session.tasks/gc) - :audit-log-archive (ig/ref ::audit.tasks/archive) - :audit-log-gc (ig/ref ::audit.tasks/gc) + :audit-log-archive (ig/ref :app.loggers.audit.archive-task/handler) + :audit-log-gc (ig/ref :app.loggers.audit.gc-task/handler) :process-webhook-event (ig/ref ::webhooks/process-event-handler) @@ -411,12 +410,12 @@ ::svgo/optimizer {} - ::audit.tasks/archive + :app.loggers.audit.archive-task/handler {::setup/props (ig/ref ::setup/props) ::db/pool (ig/ref ::db/pool) ::http.client/client (ig/ref ::http.client/client)} - ::audit.tasks/gc + :app.loggers.audit.gc-task/handler {::db/pool (ig/ref ::db/pool)} ::webhooks/process-event-handler diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 87f3d90b99..86f0fa6f5f 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -376,7 +376,10 @@ :fn (mg/resource "app/migrations/sql/0118-mod-task-table.sql")} {: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! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0120-mod-audit-log-table.sql b/backend/src/app/migrations/sql/0120-mod-audit-log-table.sql new file mode 100644 index 0000000000..e9b4b83c51 --- /dev/null +++ b/backend/src/app/migrations/sql/0120-mod-audit-log-table.sql @@ -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(); diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 76bd6e1880..5db758b464 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -19,7 +19,20 @@ [app.rpc.climit :as-alias climit] [app.rpc.doc :as-alias doc] [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] [(uuid/next) @@ -27,24 +40,38 @@ (:source event) (:type event) (:timestamp event) + (:created-at event) (:profile-id event) (db/inet (:ip-addr event)) (db/tjson (:props event)) (db/tjson (d/without-nils (:context event)))]) -(def ^:private event-columns - [:id :name :source :type :tracked-at - :profile-id :ip-addr :props :context]) +(defn- adjust-timestamp + [{:keys [timestamp created-at] :as event}] + (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 [{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}] (let [request (-> params meta ::http/request) ip-addr (audit/parse-client-ip request) + tnow (dt/now) xform (comp - (map #(assoc % :profile-id profile-id)) - (map #(assoc % :ip-addr ip-addr)) - (map #(assoc % :source "frontend")) + (map (fn [event] + (-> event + (assoc :created-at tnow) + (assoc :profile-id profile-id) + (assoc :ip-addr ip-addr) + (assoc :source "frontend")))) (filter :profile-id) + (map adjust-timestamp) (map event->row)) events (sequence xform events)] (when (seq events) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index b2a97c6cc8..601907e105 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -868,7 +868,8 @@ (db/delete! conn :file-library-rel {:library-file-id id}) (db/update! conn :file - {:is-shared false} + {:is-shared false + :modified-at (dt/now)} {:id id}) file) @@ -876,7 +877,8 @@ (true? (:is-shared params))) (let [file (assoc file :is-shared true)] (db/update! conn :file - {:is-shared true} + {:is-shared true + :modified-at (dt/now)} {:id id}) file) diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index 43c0b26f99..ec07c67b3b 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -22,13 +22,182 @@ [promesa.exec :as px])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; TASK ENTRY POINT +;; IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(declare get-stats) -(declare send!) -(declare get-subscriptions-newsletter-updates) -(declare get-subscriptions-newsletter-news) +(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- 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 [_] (s/keys :req [::http/client @@ -48,6 +217,10 @@ data {:subscriptions subs :version (:full cf/version) :instance-id (:instance-id props)}] + + (when enabled? + (clean-counters-data! pool)) + (cond ;; If we have telemetry enabled, then proceed the normal ;; operation. @@ -63,7 +236,8 @@ ;; onboarding dialog or the profile section, then proceed to ;; send a limited telemetry data, that consists in the list of ;; subscribed emails and the running penpot version. - (seq subs) + (or (seq (:newsletter-updates subs)) + (seq (:newsletter-news subs))) (do (when send? (px/sleep (rand-int 10000)) @@ -72,151 +246,3 @@ :else 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)))) - diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 987b553042..27544c4fae 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -112,7 +112,7 @@ ;; "alter table task set unlogged;\n" ;; "alter table task_default 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 server_error_report set unlogged;\n" "alter table server_prop set unlogged;\n" diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index a5ce2e1da5..17c0f130ce 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -69,6 +69,11 @@ (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 [code hint shape file page & {:as args}] (let [error {:code code @@ -218,12 +223,11 @@ "Shape not expected to be main instance" shape file page)) - (let [library-exists? (or (= (:component-file shape) (:id file)) - (contains? libraries (:component-file shape))) - component (when library-exists? + (let [library-exists (library-exists? file libraries shape) + component (when library-exists (ctf/resolve-component shape file libraries {:include-deleted? true}))] (if (nil? component) - (when library-exists? + (when library-exists (report-error :component-not-found (str/ffmt "Component % not found in file %" (:component-id shape) (:component-file shape)) shape file page)) @@ -265,11 +269,10 @@ (defn- check-component-ref "Validate that the referenced shape exists in the near component." [shape file page libraries] - (let [library-exists? (or (= (:component-file shape) (:id file)) - (contains? libraries (:component-file shape))) - ref-shape (when library-exists? + (let [library-exists (library-exists? file libraries shape) + ref-shape (when library-exists (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 (str/ffmt "Referenced shape % not found in near component" (:shape-ref shape)) shape file page)))) @@ -313,20 +316,25 @@ - :component-root - :shape-ref" [shape file page libraries] - (check-component-not-main-head shape file page libraries) - (check-component-root shape file page) - (check-component-ref shape file page libraries) - (run! #(check-shape % file page libraries :context :copy-top) (:shapes shape))) + ;; We propagate have to propagate to nested shapes if library is valid or not + (let [library-exists (library-exists? file libraries shape)] + (check-component-not-main-head shape file page libraries) + (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 "Root shape of a nested copy instance - :component-id - :component-file - :shape-ref" - [shape file page libraries] + [shape file page libraries library-exists] (check-component-not-main-head shape file page libraries) (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))) (defn- check-shape-main-not-root @@ -367,7 +375,7 @@ - :main-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)] (when (some? shape) (check-geometry shape file page) @@ -406,7 +414,7 @@ (report-error :nested-copy-not-allowed "Nested copy component only allowed inside other component" 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-not (#{:copy-top :copy-nested :copy-any} context) diff --git a/docker/devenv/files/bashrc b/docker/devenv/files/bashrc index bb53eb472c..745e3f901a 100644 --- a/docker/devenv/files/bashrc +++ b/docker/devenv/files/bashrc @@ -1,7 +1,7 @@ #!/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 JAVA_OPTS="-Xmx900m -Xms50m" +export JAVA_OPTS="-Xmx1000m -Xms50m" alias l='ls --color -GFlh' alias rm='rm -r' diff --git a/docker/devenv/files/start-tmux.sh b/docker/devenv/files/start-tmux.sh index cb3048ddc5..eb7bb39f46 100755 --- a/docker/devenv/files/start-tmux.sh +++ b/docker/devenv/files/start-tmux.sh @@ -19,12 +19,12 @@ popd 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 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 send-keys -t penpot 'cd penpot/frontend' enter C-l tmux send-keys -t penpot 'clojure -M:dev:shadow-cljs watch main' enter diff --git a/frontend/package.json b/frontend/package.json index 7ed94d2147..b17f98bded 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,21 +19,17 @@ "scripts": { "fmt:clj:check": "cljfmt check --parallel=false 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:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w", "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:watch": "clojure -M:dev:shadow-cljs watch test", "test": "yarn run test:compile && yarn run test:run", - "gulp:watch": "gulp watch", - "watch": "shadow-cljs watch main", - "validate-translations": "node ./scripts/validate-translations.js", - "find-unused-translations": "node ./scripts/find-unused-translations.js", - "build:clean": "gulp clean:output && gulp clean:dist", - "build:styles": "gulp build:styles", - "build:assets": "gulp build:assets", - "build:copy": "gulp build:copy", + "translations:validate": "node ./scripts/validate-translations.js", + "translations:find-unused": "node ./scripts/find-unused-translations.js", + "compile": "node ./scripts/compile.js", + "watch": "node ./scripts/watch.js", "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:build": "npm run storybook:compile && storybook build" @@ -67,19 +63,26 @@ "map-stream": "0.0.7", "marked": "^12.0.0", "mkdirp": "^3.0.1", + "mustache": "^4.2.0", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", + "p-limit": "^5.0.0", "postcss": "^8.4.35", "postcss-clean": "^1.2.2", "prettier": "^3.2.5", + "pretty-time": "^1.1.0", "prop-types": "^15.8.1", "rimraf": "^5.0.5", "sass": "^1.71.1", + "sass-embedded": "^1.71.1", "shadow-cljs": "2.27.4", "storybook": "^7.6.17", + "svg-sprite": "^2.0.2", "typescript": "^5.3.3", "vite": "^5.1.4", - "vitest": "^1.3.1" + "vitest": "^1.3.1", + "watcher": "^2.3.0", + "workerpool": "^9.1.0" }, "dependencies": { "date-fns": "^3.3.1", diff --git a/frontend/resources/images/icons/brand-github.svg b/frontend/resources/images/icons/brand-github.svg index 91ce17a609..cfb34953e0 100644 --- a/frontend/resources/images/icons/brand-github.svg +++ b/frontend/resources/images/icons/brand-github.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/resources/images/icons/brand-gitlab.svg b/frontend/resources/images/icons/brand-gitlab.svg index 04993577bd..591427ec6e 100644 --- a/frontend/resources/images/icons/brand-gitlab.svg +++ b/frontend/resources/images/icons/brand-gitlab.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/resources/images/icons/brand-google.svg b/frontend/resources/images/icons/brand-google.svg index dba95de0fe..eb61aab348 100644 --- a/frontend/resources/images/icons/brand-google.svg +++ b/frontend/resources/images/icons/brand-google.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/frontend/resources/images/icons/brand-openid.svg b/frontend/resources/images/icons/brand-openid.svg index a335b66642..28dd05ed85 100644 --- a/frontend/resources/images/icons/brand-openid.svg +++ b/frontend/resources/images/icons/brand-openid.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/resources/templates/index.mustache b/frontend/resources/templates/index.mustache index 77475d7e30..ffaaa9be81 100644 --- a/frontend/resources/templates/index.mustache +++ b/frontend/resources/templates/index.mustache @@ -26,7 +26,6 @@ @@ -39,8 +38,8 @@ - {{>../public/images/sprites/symbol/icons.svg}} - {{>../public/images/sprites/symbol/cursors.svg}} + {{> ../public/images/sprites/symbol/icons.svg }} + {{> ../public/images/sprites/symbol/cursors.svg }}
{{# manifest}} diff --git a/frontend/resources/templates/render.mustache b/frontend/resources/templates/render.mustache index 5221030ae1..cbaad75147 100644 --- a/frontend/resources/templates/render.mustache +++ b/frontend/resources/templates/render.mustache @@ -7,7 +7,6 @@ diff --git a/frontend/scripts/_helpers.js b/frontend/scripts/_helpers.js new file mode 100644 index 0000000000..b086ce5ec1 --- /dev/null +++ b/frontend/scripts/_helpers.js @@ -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 `${text}`; + }, +}; + +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)})`); +} + diff --git a/frontend/scripts/_worker.js b/frontend/scripts/_worker.js new file mode 100644 index 0000000000..eab272fbf0 --- /dev/null +++ b/frontend/scripts/_worker.js @@ -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(); + } +}); diff --git a/frontend/scripts/build b/frontend/scripts/build index ccb9236b78..4254b5e223 100755 --- a/frontend/scripts/build +++ b/frontend/scripts/build @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# NOTE: this script should be called from the parent directory to +# properly work. set -ex @@ -12,13 +14,13 @@ export EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS; export NODE_ENV=production; yarn install || exit 1; -yarn run build:clean || exit 1; -yarn run build:styles || exit 1; +rm -rf resources/public; +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 build:copy || exit 1; +yarn run compile || exit 1; +rsync -avr resources/public/ target/dist/ sed -i -re "s/\%version\%/$CURRENT_VERSION/g" ./target/dist/index.html; sed -i -re "s/\%buildDate\%/$BUILD_DATE/g" ./target/dist/index.html; diff --git a/frontend/scripts/compile.js b/frontend/scripts/compile.js new file mode 100644 index 0000000000..e04d070013 --- /dev/null +++ b/frontend/scripts/compile.js @@ -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(); diff --git a/frontend/scripts/compress-png b/frontend/scripts/compress-png deleted file mode 100755 index b18a64b96a..0000000000 --- a/frontend/scripts/compress-png +++ /dev/null @@ -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 diff --git a/frontend/scripts/jvm-repl b/frontend/scripts/jvm-repl deleted file mode 100755 index b59aaaca89..0000000000 --- a/frontend/scripts/jvm-repl +++ /dev/null @@ -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; diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js new file mode 100644 index 0000000000..80dda26b52 --- /dev/null +++ b/frontend/scripts/watch.js @@ -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(); diff --git a/frontend/src/app/main/ui/auth.cljs b/frontend/src/app/main/ui/auth.cljs index 61fe0271b2..636be2cd6b 100644 --- a/frontend/src/app/main/ui/auth.cljs +++ b/frontend/src/app/main/ui/auth.cljs @@ -30,7 +30,7 @@ [:a {:href cf/terms-of-service-uri :target "_blank"} (tr "auth.terms-of-service")]) (when show-all? - [:span (tr "labels.and")]) + [:span (dm/str " " (tr "labels.and") " ")]) (when show-privacy? [:a {:href cf/privacy-policy-uri :target "_blank"} (tr "auth.privacy-policy")])]))) @@ -45,11 +45,12 @@ (dom/set-html-title (tr "title.default"))) [:main {:class (stl/css :auth-section)} + [:a {:href "#/" :class (stl/css :logo-btn)} i/logo] [:div {:class (stl/css :login-illustration)} i/login-illustration] [:section {:class (stl/css :auth-content)} - [:a {:href "#/" :class (stl/css :logo-btn)} i/logo] + (case section :auth-register [:& register-page {:params params}] @@ -69,6 +70,5 @@ :auth-recovery [:& recovery-page {:params params}]) - (when (or (= section :auth-login) - (= section :auth-register)) + (when (= section :auth-register) [:& terms-login])]])) diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index f993d5b7f8..3c81a7c43c 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -7,6 +7,7 @@ @use "common/refactor/common-refactor.scss" as *; .auth-section { + position: relative; align-items: center; background: var(--panel-background-color); display: grid; @@ -43,8 +44,9 @@ .auth-content { grid-column: 4 / 6; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: 1fr auto; + gap: $s-24; height: fit-content; max-width: $s-412; padding-bottom: $s-8; @@ -53,6 +55,9 @@ } .logo-btn { + position: absolute; + top: $s-20; + left: $s-20; display: flex; justify-content: flex-start; margin-bottom: $s-52; @@ -68,8 +73,6 @@ .terms-login { font-size: $fs-11; - position: absolute; - bottom: 0; width: 100%; display: flex; gap: $s-4; @@ -77,7 +80,10 @@ a { font-weight: $fw700; - color: $df-secondary; + color: $da-primary; + &:hover { + text-decoration: underline; + } } span { border-bottom: $s-1 solid transparent; diff --git a/frontend/src/app/main/ui/auth/common.scss b/frontend/src/app/main/ui/auth/common.scss index 23e2ba11ca..0075c7f144 100644 --- a/frontend/src/app/main/ui/auth/common.scss +++ b/frontend/src/app/main/ui/auth/common.scss @@ -6,36 +6,37 @@ @use "common/refactor/common-refactor.scss" as *; -.auth-form { +.auth-form-wrapper { width: 100%; - padding-block-end: $s-16; - + padding-block-end: 0; + display: grid; + gap: $s-24; form { display: flex; flex-direction: column; gap: $s-12; - margin-block-end: $s-24; } } .separator { - border-color: $db-quaternary; - margin: $s-24 0; -} - -.error-wrapper { - padding-block-end: $s-8; + border-color: var(--modal-separator-backogrund-color); + margin: 0; } .auth-title { @include bigTitleTipography; - color: $df-primary; + color: var(--title-foreground-color-hover); } .auth-subtitle { - margin-top: $s-24; - font-size: $fs-14; - color: $df-secondary; + @include smallTitleTipography; + color: var(--title-foreground-color); +} + +.auth-tagline { + @include smallTitleTipography; + margin: 0; + color: var(--title-foreground-color); } .form-field { @@ -45,77 +46,102 @@ } .buttons-stack { - display: flex; - flex-direction: column; + display: grid; 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; flex-direction: column; gap: $s-12; - padding: $s-24 0; - border-top: $s-1 solid $db-quaternary; + padding: 0; + border-block-start: none; +} - span { - text-align: center; - font-size: $fs-14; - color: $df-secondary; - } - a { - @extend .button-secondary; - height: $s-40; - text-transform: uppercase; - font-size: $fs-11; - } - &.register a { - @extend .button-primary; +.demo-account-link, +.go-back-link { + @extend .button-secondary; + @include uppercaseTitleTipography; + height: $s-40; +} + +.links { + display: grid; + gap: $s-24; +} + +.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 { display: flex; justify-content: flex-end; - a { - font-size: $fs-14; - color: $df-secondary; - font-weight: $fw400; - } } .submit-btn, .register-btn, .recover-btn { @extend .button-primary; - font-size: $fs-11; + @include uppercaseTitleTipography; height: $s-40; - text-transform: uppercase; width: 100%; } .login-btn { - border-radius: $br-8; - font-size: $fs-14; + @include smallTitleTipography; display: flex; align-items: center; gap: $s-6; width: 100%; - + border-radius: $br-8; + background-color: var(--button-secondary-background-color-rest); + color: var(--button-foreground-color-focus); span { - padding-top: $s-2; + padding-block-start: $s-2; } &: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; 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; -} diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index cfd9d1d11f..79a8c599a5 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -38,10 +38,9 @@ (mf/defc demo-warning {::mf/props :obj} [] - [:div {:class (stl/css :banner)} - [:& context-notification - {:type :warning - :content (tr "auth.demo-warning")}]]) + [:& context-notification + {:type :warning + :content (tr "auth.demo-warning")}]) (defn- login-with-oidc [event provider params] @@ -166,14 +165,15 @@ [:* (when-let [message @error] - [:div {:class (stl/css :error-wrapper)} - [:& context-notification - {:type :warning - :content message - :data-test "login-banner" - :role "alert"}]]) + [:& context-notification + {:type :warning + :content message + :data-test "login-banner" + :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)} [:& fm/input {:name :email @@ -193,6 +193,7 @@ (contains? cf/flags :login-with-password))) [:div {:class (stl/css :fields-row :forgot-password)} [:& lk/link {:action on-recovery-request + :class (stl/css :forgot-pass-link) :data-test "forgot-password"} (tr "auth.forgot-password")]]) @@ -207,6 +208,7 @@ (when (contains? cf/flags :login-with-ldap) [:> fm/submit-button* {:label (tr "auth.login-with-ldap-submit") + :class (stl/css :login-ldap-button) :on-click on-submit-ldap}])]]])) (mf/defc login-buttons @@ -255,11 +257,11 @@ (when (k/enter? event) (login-oidc event))))] (when (contains? cf/flags :login-with-oidc) - [:div {:class (stl/css :link-entry :link-oidc)} - [:a {:tab-index "0" - :on-key-down handle-key-down - :on-click login-oidc} - (tr "auth.login-with-oidc-submit")]]))) + [:button {:tab-index "0" + :class (stl/css :link-entry :link-oidc) + :on-key-down handle-key-down + :on-click login-oidc} + (tr "auth.login-with-oidc-submit")]))) (mf/defc login-methods [{:keys [params on-success-callback origin] :as props}] @@ -282,35 +284,29 @@ [{:keys [params] :as props}] (let [go-register (mf/use-fn - #(st/emit! (rt/nav :auth-register {} params))) + #(st/emit! (rt/nav :auth-register {} params)))] - on-create-demo-profile - (mf/use-fn - #(st/emit! (du/create-demo-profile)))] - - [:div {:class (stl/css :auth-form)} + [:div {:class (stl/css :auth-form-wrapper)} [: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) [:& demo-warning]) - [:hr {:class (stl/css :separator)}] - [:& login-methods {:params params}] + [:hr {:class (stl/css :separator)}] + [:div {:class (stl/css :links)} (when (contains? cf/flags :registration) - [:div {:class (stl/css :link-entry :register)} - [:span (tr "auth.register") " "] + [:div {:class (stl/css :register)} + [:span {:class (stl/css :register-text)} + (tr "auth.register") " "] [:& lk/link {:action go-register + :class (stl/css :register-link) :data-test "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")]])])) + (tr "auth.register-submit")]])]])) diff --git a/frontend/src/app/main/ui/auth/recovery.cljs b/frontend/src/app/main/ui/auth/recovery.cljs index 1425d9d1e6..85657eef6b 100644 --- a/frontend/src/app/main/ui/auth/recovery.cljs +++ b/frontend/src/app/main/ui/auth/recovery.cljs @@ -61,7 +61,9 @@ (fm/validate-not-empty :password-1 (tr "auth.password-not-empty")) (fm/validate-not-empty :password-2 (tr "auth.password-not-empty"))] :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)} [:& fm/input {:type "password" :name :password-1 @@ -84,13 +86,14 @@ (mf/defc recovery-page [{: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?"] [:div {:class (stl/css :auth-subtitle)} "Please enter your new password"] [:hr {:class (stl/css :separator)}] [:& recovery-form {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry)} - [:a {:on-click #(st/emit! (rt/nav :auth-login))} + [:div {:class (stl/css :go-back)} + [:a {:on-click #(st/emit! (rt/nav :auth-login)) + :class (stl/css :go-back-link)} (tr "profile.recovery.go-to-login")]]]]) diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index d1d72ed2dc..b2d116daf6 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -76,6 +76,7 @@ (st/emit! (du/request-profile-recovery params)))))] [:& fm/form {:on-submit on-submit + :class (stl/css :recovery-request-form) :form form} [:div {:class (stl/css :fields-row)} [:& fm/input {:name :email @@ -95,14 +96,15 @@ [{:keys [params on-success-callback go-back-callback] :as props}] (let [default-go-back #(st/emit! (rt/nav :auth-login)) 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")] [:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")] [:hr {:class (stl/css :separator)}] [:& recovery-form {:params params :on-success-callback on-success-callback}] - - [:div {:class (stl/css :link-entry)} + [:hr {:class (stl/css :separator)}] + [:div {:class (stl/css :go-back)} [:& lk/link {:action go-back + :class (stl/css :go-back-link) :data-test "go-back-link"} (tr "labels.go-back")]]])) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 1143d59281..633ac1177c 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -132,19 +132,18 @@ [{:keys [params on-success-callback]}] [:* (when login/show-alt-login-buttons? - [:* - [:hr {:class (stl/css :separator)}] - [:& login/login-buttons {:params params}]]) + [:& login/login-buttons {:params params}]) [:hr {:class (stl/css :separator)}] [:& register-form {:params params :on-success-callback on-success-callback}]]) (mf/defc register-page {::mf/props :obj} [{:keys [params]}] - [:div {:class (stl/css :auth-form)} + [:div {:class (stl/css :auth-form-wrapper)} [:h1 {:class (stl/css :auth-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) [:& login/demo-warning]) @@ -152,18 +151,20 @@ [:& register-methods {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry :account)} - [:span (tr "auth.already-have-account") " "] - + [:div {:class (stl/css :account)} + [:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "] [:& lk/link {:action #(st/emit! (rt/nav :auth-login {} params)) + :class (stl/css :account-link) :data-test "login-here-link"} (tr "auth.login-here")]] (when (contains? cf/flags :demo-users) - [:div {:class (stl/css :link-entry :demo-users)} - [:span (tr "auth.create-demo-profile") " "] - [:& lk/link {:action #(st/emit! (du/create-demo-profile))} - (tr "auth.create-demo-account")]])]]) + [:* + [:hr {:class (stl/css :separator)}] + [:div {:class (stl/css :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 @@ -228,7 +229,8 @@ (rx/subs! on-success (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)} [:& fm/input {:name :fullname :label (tr "auth.fullname") @@ -258,7 +260,7 @@ (mf/defc register-validate-page [{:keys [params]}] - [:div {:class (stl/css :auth-form)} + [:div {:class (stl/css :auth-form-wrapper)} [:h1 {:class (stl/css :auth-title) :data-test "register-title"} (tr "auth.register-title")] [:div {:class (stl/css :auth-subtitle)} (tr "auth.register-subtitle")] @@ -268,13 +270,14 @@ [:& register-validate-form {:params params}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry :go-back)} - [:& lk/link {:action #(st/emit! (rt/nav :auth-register {} {}))} + [:div {:class (stl/css :go-back)} + [:& lk/link {:action #(st/emit! (rt/nav :auth-register {} {})) + :class (stl/css :go-back-link)} (tr "labels.go-back")]]]]) (mf/defc register-success-page [{: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-text)} (tr "auth.verification-email-sent")] [:div {:class (stl/css :notification-text-email)} (:email params "")] diff --git a/frontend/src/app/main/ui/components/forms.scss b/frontend/src/app/main/ui/components/forms.scss index 53db074934..d29571542a 100644 --- a/frontend/src/app/main/ui/components/forms.scss +++ b/frontend/src/app/main/ui/components/forms.scss @@ -50,7 +50,6 @@ cursor: pointer; color: var(--modal-title-foreground-color); text-transform: uppercase; - margin-bottom: $s-8; input { @extend .input-element; color: var(--input-foreground-color-active); @@ -144,8 +143,9 @@ .hint { @include bodySmallTypography; - color: var(--modal-text-foreground-color); width: 99%; + margin-block-start: $s-8; + color: var(--modal-text-foreground-color); } .checkbox { diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index 2545950f8e..d94cb4c285 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -37,6 +37,7 @@ .group-name-input { @extend .input-element-label; + margin-bottom: $s-8; label { @include flexColumn; @include bodySmallTypography; diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index c225e3ae65..1496215ebc 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.scss @@ -127,6 +127,7 @@ height: $s-32; width: calc(100% - $s-24); margin-inline-start: $s-24; + margin-block-end: $s-8; } // STEP-4 diff --git a/frontend/src/app/main/ui/viewer/inspect/left_sidebar.cljs b/frontend/src/app/main/ui/viewer/inspect/left_sidebar.cljs index f546a9352e..f6c234bd47 100644 --- a/frontend/src/app/main/ui/viewer/inspect/left_sidebar.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/left_sidebar.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.types.component :as ctk] [app.main.data.viewer :as dv] [app.main.store :as st] [app.main.ui.workspace.sidebar.layer-item :refer [layer-item-inner]] @@ -30,7 +31,7 @@ item-ref (mf/use-ref nil) 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 (mf/use-memo diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs index a4f2c2f650..1a1e692dcf 100644 --- a/frontend/src/app/main/ui/viewer/login.cljs +++ b/frontend/src/app/main/ui/viewer/login.cljs @@ -78,19 +78,21 @@ :on-click close} i/close]] [:div {:class (stl/css :modal-content)} - (case current-section :login [:div {:class (stl/css :form-container)} [:& login-methods {:on-success-callback success-login :origin :viewer}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry)} + [:div {:class (stl/css :recovery-request)} [:a {:on-click set-section + :class (stl/css :recovery-link) :data-value "recovery-request"} (tr "auth.forgot-password")]] - [:div {:class (stl/css :link-entry)} - [:span (tr "auth.register") " "] + [:div {:class (stl/css :register)} + [:span {:class (stl/css :register-text)} + (tr "auth.register") " "] [:a {:on-click set-section + :class (stl/css :register-link) :data-value "register"} (tr "auth.register-submit")]]]] @@ -98,7 +100,7 @@ [:div {:class (stl/css :form-container)} [:& register-methods {:on-success-callback success-register}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry)} + [:div {:class (stl/css :account)} [:span (tr "auth.already-have-account") " "] [:a {:on-click set-section :data-value "login"} @@ -109,7 +111,7 @@ [:& register-validate-form {:params {:token @register-token} :on-success-callback success-email-sent}] [:div {:class (stl/css :links)} - [:div {:class (stl/css :link-entry)} + [:div {:class (stl/css :register)} [:a {:on-click set-section :data-value "register"} (tr "labels.go-back")]]]] diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index aeafdfecad..1756829e34 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -49,6 +49,7 @@ } .input-wrapper { @extend .input-with-label; + margin-bottom: $s-8; } .action-buttons { @extend .modal-action-btns; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index be6a1fffb4..a2e008fa92 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] @@ -61,7 +62,7 @@ :class (stl/css-case :layer-row true :highlight highlighted? - :component (some? (:component-id item)) + :component (ctk/instance-head? item) :masked (:masked-group item) :selected selected? :type-frame (cfh/frame-shape? item) @@ -321,7 +322,7 @@ ref (mf/use-ref) 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)) disable-drag (mf/use-fn #(reset! drag-disabled* true))] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b94cd8fb49..dd5e69fea6 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -58,8 +58,12 @@ msgid "auth.login-submit" msgstr "Login" #: src/app/main/ui/auth/login.cljs -msgid "auth.login-title" -msgstr "Great to see you again!" +msgid "auth.login-account-title" +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 msgid "auth.login-with-github-submit" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index cec823c043..601bd792f4 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -61,8 +61,12 @@ msgid "auth.login-submit" msgstr "Entrar" #: src/app/main/ui/auth/login.cljs -msgid "auth.login-title" -msgstr "¡Un placer verte de nuevo!" +msgid "auth.login-account-title" +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 msgid "auth.login-with-github-submit" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 00770f86da..d4531a5c9f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1471,6 +1471,13 @@ __metadata: languageName: node 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": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -5333,6 +5340,13 @@ __metadata: languageName: node 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": version: 0.2.13 resolution: "buffer-crc32@npm:0.2.13" @@ -6601,6 +6615,13 @@ __metadata: languageName: node 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": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -7939,13 +7960,16 @@ __metadata: marked: "npm:^12.0.0" mkdirp: "npm:^3.0.1" mousetrap: "npm:^1.6.5" + mustache: "npm:^4.2.0" nodemon: "npm:^3.1.0" npm-run-all: "npm:^4.1.5" opentype.js: "npm:^1.3.4" + p-limit: "npm:^5.0.0" postcss: "npm:^8.4.35" postcss-clean: "npm:^1.2.2" postcss-modules: "npm:^6.0.0" prettier: "npm:^3.2.5" + pretty-time: "npm:^1.1.0" prop-types: "npm:^15.8.1" randomcolor: "npm:^0.6.2" react: "npm:^18.2.0" @@ -7954,15 +7978,19 @@ __metadata: rimraf: "npm:^5.0.5" rxjs: "npm:8.0.0-alpha.14" sass: "npm:^1.71.1" + sass-embedded: "npm:^1.71.1" sax: "npm:^1.3.0" shadow-cljs: "npm:2.27.4" source-map-support: "npm:^0.5.21" storybook: "npm:^7.6.17" + svg-sprite: "npm:^2.0.2" tdigest: "npm:^0.1.2" typescript: "npm:^5.3.3" ua-parser-js: "npm:^1.0.37" vite: "npm:^5.1.4" vitest: "npm:^1.3.1" + watcher: "npm:^2.3.0" + workerpool: "npm:^9.1.0" xregexp: "npm:^5.1.1" languageName: unknown linkType: soft @@ -12087,6 +12115,13 @@ __metadata: languageName: node 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": version: 2.0.0 resolution: "prettysize@npm:2.0.0" @@ -12122,6 +12157,13 @@ __metadata: languageName: node 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": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -13126,7 +13168,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.8.1": +"rxjs@npm:^7.4.0, rxjs@npm:^7.8.1": version: 7.8.1 resolution: "rxjs@npm:7.8.1" dependencies: @@ -13195,6 +13237,205 @@ __metadata: languageName: node 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": version: 1.71.1 resolution: "sass@npm:1.71.1" @@ -14038,6 +14279,13 @@ __metadata: languageName: node 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": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -14316,6 +14564,15 @@ __metadata: languageName: node 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": version: 2.5.1 resolution: "tinybench@npm:2.5.1" @@ -15071,6 +15328,13 @@ __metadata: languageName: node 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": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -15311,6 +15575,17 @@ __metadata: languageName: node 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": version: 2.4.0 resolution: "watchpack@npm:2.4.0" @@ -15521,6 +15796,13 @@ __metadata: languageName: node 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": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0"