penpot/frontend/src/app/main/ui/static.cljs

594 lines
22 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.static
(:require-macros [app.main.style :as stl])
(:require
["rxjs" :as rxjs]
[app.common.data :as d]
[app.common.pprint :as pp]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page recovery-sent-page]]
[app.main.ui.auth.register :as register]
[app.main.ui.dashboard.sidebar :refer [sidebar*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.viewer.header :as viewer.header]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; FIXME: this is a workaround until we export this class on beicon library
(def TimeoutError rxjs/TimeoutError)
(mf/defc error-container*
{::mf/props :obj}
[{:keys [children]}]
(let [profile-id (:profile-id @st/state)
on-nav-root (mf/use-fn #(st/emit! (rt/nav-root)))]
[:section {:class (stl/css :exception-layout)}
[:button
{:class (stl/css :exception-header)
:on-click on-nav-root}
[:> raw-svg* {:id "penpot-logo-icon" :class (stl/css :penpot-logo)}]
(when profile-id
[:div {:class (stl/css :go-back-wrapper)}
[:> icon* {:icon-id i/arrow :class (stl/css :back-arrow)}] [:span (tr "not-found.no-permission.go-dashboard")]])]
[:div {:class (stl/css :deco-before)} deprecated-icon/logo-error-screen]
(when-not profile-id
[:button {:class (stl/css :login-header)
:on-click on-nav-root}
(tr "labels.login")])
[:div {:class (stl/css :exception-content)}
[:div {:class (stl/css :container)} children]]
[:div {:class (stl/css :deco-after2)}
[:span (tr "labels.copyright-period")]
deprecated-icon/logo-error-screen
[:span (tr "not-found.made-with-love")]]]))
(mf/defc invalid-token
[]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")]
[:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]])
(mf/defc login-modal*
{::mf/private true}
[]
(let [current-section (mf/use-state :login)
user-email (mf/use-state "")
register-token (mf/use-state "")
set-section
(mf/use-fn
(fn [event]
(let [section (-> (dom/get-current-target event)
(dom/get-data "section")
(keyword))]
(reset! current-section section))))
set-section-recovery
(mf/use-fn
#(reset! current-section :recovery-request))
set-section-login
(mf/use-fn
#(reset! current-section :login))
success-login
(mf/use-fn
#(st/emit! (rt/reload true)))
success-register
(mf/use-fn
(fn [data]
(reset! register-token (:token data))
(reset! current-section :register-validate)))
register-email-sent
(mf/use-fn
(fn [email]
(reset! user-email email)
(reset! current-section :register-email-sent)))
recovery-email-sent
(mf/use-fn
(fn [email]
(reset! user-email email)
(reset! current-section :recovery-email-sent)))
on-nav-root
(mf/use-fn #(st/emit! (rt/nav :auth-login {})))]
[:div {:class (stl/css :overlay)}
[:div {:class (stl/css :dialog-login)}
[:div {:class (stl/css :modal-close)}
[:button {:class (stl/css :modal-close-button)
:on-click on-nav-root}
deprecated-icon/close]]
[:div {:class (stl/css :login)}
[:div {:class (stl/css :logo)} deprecated-icon/logo]
(case @current-section
:login
[:*
[:div {:class (stl/css :logo-title)} (tr "labels.login")]
[:div {:class (stl/css :logo-subtitle)} (tr "not-found.login.free")]
[:> login-dialog* {:on-recovery-request set-section-recovery
:on-success-callback success-login
:handle-redirect true}]
[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :change-section)}
(tr "auth.register")
" "
[:a {:data-section "register"
:on-click set-section}
(tr "auth.register-submit")]]]
:register
[:*
[:div {:class (stl/css :logo-title)} (tr "not-found.login.signup-free")]
[:div {:class (stl/css :logo-subtitle)} (tr "not-found.login.start-using")]
[:& register/register-methods {:on-success-callback success-register :hide-separator true}]
#_[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :separator)}]
[:div {:class (stl/css :change-section)}
(tr "auth.already-have-account")
" "
[:a {:data-section "login"
:on-click set-section} (tr "auth.login-here")]]
[:div {:class (stl/css :links)}
[:hr {:class (stl/css :separator)}]
[:& register/terms-register]]]
:register-validate
[:div {:class (stl/css :form-container)}
[:& register/register-form
{:params {:token @register-token}
:on-success-callback register-email-sent}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :register)}
[:a {:data-section "register"
:on-click set-section}
(tr "labels.go-back")]]]]
:register-email-sent
[:div {:class (stl/css :form-container)}
[:& register/register-success-page {:params {:email @user-email :hide-logo true}}]]
:recovery-request
[:& recovery-request-page {:go-back-callback set-section-login
:on-success-callback recovery-email-sent}]
:recovery-email-sent
[:div {:class (stl/css :form-container)}
[:& recovery-sent-page {:email @user-email}]])]]]))
(mf/defc request-dialog*
{::mf/props :obj}
[{:keys [title content button-text on-button-click cancel-text on-close]}]
(let [on-click (or on-button-click on-close)]
[:div {:class (stl/css :overlay)}
[:div {:class (stl/css :dialog)}
[:div {:class (stl/css :modal-close)}
[:button {:class (stl/css :modal-close-button) :on-click on-close}
deprecated-icon/close]]
[:div {:class (stl/css :dialog-title)} title]
(for [[index content] (d/enumerate content)]
[:div {:key index} content])
[:div {:class (stl/css :sign-info)}
(when cancel-text
[:button {:class (stl/css :cancel-button)
:on-click on-close}
cancel-text])
[:button {:on-click on-click} button-text]]]]))
(mf/defc request-access*
[{:keys [file-id team-id is-default is-workspace profile]}]
(let [requested* (mf/use-state {:sent false :already-requested false})
requested (deref requested*)
on-close
(mf/use-fn
(mf/deps profile)
(fn []
(let [team-id (:default-team-id profile)]
(st/emit! (dcm/go-to-dashboard-recent :team-id team-id)))))
on-success
(mf/use-fn
#(reset! requested* {:sent true :already-requested false}))
on-error
(mf/use-fn
#(reset! requested* {:sent true :already-requested true}))
on-request-access
(mf/use-fn
(mf/deps file-id team-id is-workspace)
(fn []
(let [params (if (some? file-id)
{:file-id file-id
:is-viewer (not is-workspace)}
{:team-id team-id})
mdata {:on-success on-success
:on-error on-error}]
(st/emit! (dcm/create-team-access-request
(with-meta params mdata))))))]
(cond
is-default
[:> request-dialog* {:title (tr "not-found.no-permission.project")
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(and (some? file-id) (:already-requested requested))
[:> request-dialog* {:title (tr "not-found.no-permission.already-requested.file")
:content [(tr "not-found.no-permission.already-requested.or-others.file")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(:already-requested requested)
[:> request-dialog* {:title (tr "not-found.no-permission.already-requested.project")
:content [(tr "not-found.no-permission.already-requested.or-others.project")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(:sent requested)
[:> request-dialog* {:title (tr "not-found.no-permission.done.success")
:content [(tr "not-found.no-permission.done.remember")]
:button-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(some? file-id)
[:> request-dialog* {:title (tr "not-found.no-permission.file")
:content [(tr "not-found.no-permission.you-can-ask.file")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}]
(some? team-id)
[:> request-dialog* {:title (tr "not-found.no-permission.project")
:content [(tr "not-found.no-permission.you-can-ask.project")
(tr "not-found.no-permission.if-approves")]
:button-text (tr "not-found.no-permission.ask")
:on-button-click on-request-access
:cancel-text (tr "not-found.no-permission.go-dashboard")
:on-close on-close}])))
(mf/defc not-found*
[]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.not-found.main-message")]
[:div {:class (stl/css :desc-message)} (tr "not-found.desc-message.error")]
[:div {:class (stl/css :desc-message)} (tr "not-found.desc-message.doesnt-exist")]])
(mf/defc bad-gateway*
[]
(let [handle-retry
(mf/use-fn
(fn [] (st/emit! (rt/assign-exception nil))))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.bad-gateway.main-message")]
[:div {:class (stl/css :desc-message)} (tr "labels.bad-gateway.desc-message")]
[:div {:class (stl/css :sign-info)}
[:button {:on-click handle-retry} (tr "labels.retry")]]]))
(mf/defc service-unavailable*
[]
(let [on-click (mf/use-fn #(st/emit! (rt/assign-exception nil)))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.service-unavailable.main-message")]
[:div {:class (stl/css :desc-message)} (tr "labels.service-unavailable.desc-message")]
[:div {:class (stl/css :sign-info)}
[:button {:on-click on-click} (tr "labels.retry")]]]))
(mf/defc webgl-context-lost*
[]
(let [on-reload (mf/use-fn #(js/location.reload))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "errors.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "errors.webgl-context-lost.desc-message")]
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))
(defn- generate-report
[data]
(try
(let [team-id (:current-team-id @st/state)
profile-id (:profile-id @st/state)
trace (:app.main.errors/trace data)
instance (:app.main.errors/instance data)]
(with-out-str
(println "Hint: " (or (:hint data) (ex-message instance) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(println "URI: " cf/public-uri)
(when-let [file-id (:file-id data)]
(println "File ID:" (str file-id)))
(println)
(println "Data:")
(loop [data data]
(-> (d/without-qualified data)
(dissoc :explain)
(d/update-when :data (constantly "(...)"))
(pp/pprint {:level 8 :length 10}))
(println)
(when-let [explain (:explain data)]
(print explain))
(when (and (= :server-error (:type data))
(contains? data :data))
(recur (:data data))))
(println "Trace:")
(println trace)
(println)
(println "Last events:")
(pp/pprint @st/last-events {:length 200})
(println)))
(catch :default cause
(.error js/console "error on generating report.txt" cause)
nil)))
(mf/defc internal-error*
[{:keys [on-reset report] :as props}]
(let [report-uri (mf/use-ref nil)
on-reset (or on-reset #(st/emit! (rt/assign-exception nil)))
support-contact-click
(mf/use-fn
(mf/deps on-reset report)
(fn []
(tm/schedule on-reset)
(let [error-report-id (uuid/next)
error-href (rt/get-current-href)]
(set! errors/last-report {:id error-report-id :content report})
(st/emit!
(rt/nav :settings-feedback {:type "issue"
:error-report-id error-report-id
:error-href error-href})))))
on-download
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(when-let [uri (mf/ref-val report-uri)]
(dom/trigger-download-uri "report" "text/plain" uri))))]
(mf/with-effect [report]
(when (some? report)
(set! errors/last-report report)
(let [report (wapi/create-blob report "text/plain")
uri (wapi/create-uri report)]
(mf/set-ref-val! report-uri uri)
(fn []
(wapi/revoke-uri uri)))))
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.internal-error.main-message")]
[:div {:class (stl/css :desc-message)}
[:p {:class (stl/css :desc-text)} (tr "labels.internal-error.desc-message-first")]
[:p {:class (stl/css :desc-text)} (tr "labels.internal-error.desc-message-second")]]
(when (some? report)
[:a {:class (stl/css :download-link) :on-click on-download} (tr "labels.download" "report.txt")])
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "secondary"
:type "button"
:class (stl/css :support-btn)
:on-click support-contact-click} (tr "labels.contact-support")]
[:> button* {:variant "primary"
:type "button"
:class (stl/css :retry-btn)
:on-click on-reset} (tr "labels.retry")]]]))
(defn- load-info
"Load exception page info"
[path-params]
(let [default {:loaded true}
stream (cond
(:file-id path-params)
(->> (rp/cmd! :get-file-info {:id (:file-id path-params)})
(rx/map (fn [info]
{:loaded true
:file-id (:id info)})))
(:team-id path-params)
(->> (rp/cmd! :get-team-info {:id (:team-id path-params)})
(rx/map (fn [info]
{:loaded true
:team-id (:id info)
:team-default (:is-default info)})))
:else
(rx/of default))]
(->> stream
(rx/timeout 3000)
(rx/catch (fn [cause]
(if (instance? TimeoutError cause)
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
(let [type (get data :type)
report (mf/with-memo [data]
(some-> data ::errors/instance errors/generate-report))
props (mf/spread-props props {:report report})]
(mf/with-effect [data route report]
(let [params (:query-params route)
params (u/map->query-string params)]
(st/emit! (ev/event {::ev/name "exception-page"
:type (get data :type :unknown)
:href (rt/get-current-href)
:hint (get data :hint)
:path (get route :path)
:report report
:params params}))))
(case type
:not-found
[:> not-found* {}]
:authentication
[:> not-found* {}]
:bad-gateway
[:> bad-gateway* props]
:service-unavailable
[:> service-unavailable*]
:wasm-exception
(case (get data :exception-type)
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])
[:> internal-error* props])))
(mf/defc context-wrapper*
[{:keys [is-workspace is-dashboard is-viewer profile children]}]
[:*
(cond
is-workspace
[:div {:class (stl/css :workspace)}
[:div {:class (stl/css :workspace-left)}
deprecated-icon/logo-icon
[:div
[:div {:class (stl/css :project-name)} (tr "not-found.no-permission.project-name")]
[:div {:class (stl/css :file-name)} (tr "not-found.no-permission.penpot-file")]]]
[:div {:class (stl/css :workspace-right)}]]
is-viewer
[:div {:class (stl/css :viewer)}
;; FIXME: the viewer header was never designed to be reused
;; from other parts of the application, and this code looks
;; like a fast workaround reusing it as-is without a proper
;; component adaptation for be able to use it easily it on
;; viewer context or static error page context
[:& viewer.header/header {:project
{:name (tr "not-found.no-permission.project-name")}
:index 0
:file {:name (tr "not-found.no-permission.penpot-file")}
:page nil
:frame nil
:permissions {:is-logged true}
:zoom 1
:section :interactions
:shown-thumbnails false
:interactions-mode nil}]]
is-dashboard
[:div {:class (stl/css :dashboard)}
[:div {:class (stl/css :dashboard-sidebar)}
[:> sidebar*
{:team nil
:projects []
:project (:default-project-id profile)
:profile profile
:section :dashboard-projects
:search-term ""}]]])
children])
(mf/defc exception-page*
{::mf/props :obj}
[{:keys [data route] :as props}]
(let [type (:type data)
path (:path route)
params (:query-params route)
workspace? (str/includes? path "workspace")
dashboard? (str/includes? path "dashboard")
view? (str/includes? path "view")
;; We store the request access info int this state
info* (mf/use-state nil)
info (deref info*)
profile (mf/deref refs/profile)
auth-error? (= type :authentication)
not-found? (= type :not-found)
authenticated?
(is-authenticated? profile)
request-access?
(and
(or workspace? dashboard? view?)
(or (some? (:file-id info))
(some? (:team-id info))))]
(mf/with-effect [params info]
(when-not (:loaded info)
(->> (load-info params)
(rx/subs! (partial reset! info*)
(partial reset! info* {:loaded true})))))
(if (or auth-error? not-found?)
(if (not authenticated?)
[:> context-wrapper*
{:is-workspace workspace?
:is-dashboard dashboard?
:is-viewer view?
:profile profile}
[:> login-modal* {}]]
(when (get info :loaded false)
(if request-access?
[:> context-wrapper* {:is-workspace workspace?
:is-dashboard dashboard?
:is-viewer view?
:profile profile}
[:> request-access* {:file-id (:file-id info)
:team-id (:team-id info)
:is-default (:team-default info)
:profile profile
:is-workspace workspace?}]]
[:> exception-section* props])))
[:> exception-section* props])))