✨ Rework the export dialog

Every exported file is its own job, so each row of the dialog reports
its own state: waiting for a worker, running with the milestone of the
job and an animated bar, exported once the artifact downloads, or failed
in a red row with the hint of the job. The header only counts the files
and subtitles the artifact format and, once it exists, its size.

Closing changes too: the Close button is gone, the X cancels like Cancel
Export and the dialog stays with the terminal states, and Done dismisses
it. Success, failure and cancellation replace each other, so a row never
reports two outcomes at once, and rows are matched by id canonicalized
to string.

The retry button of the finished design is left out: no retry mechanism
exists on the client yet.

AI-assisted-by: muse-spark-1.3
This commit is contained in:
Andrey Antukh 2026-10-15 08:45:14 +00:00
parent 464756ec59
commit 8cdbdbb6da
5 changed files with 471 additions and 153 deletions

View File

@ -105,7 +105,8 @@
(= "completed" status)
(rx/of {:file-id (:id file)
:uri (:resource-uri result)
:filename (:name file)})
:filename (:name file)
:size (:size result)})
(= "failed" status)
(rx/of {:file-id (:id file)
@ -121,9 +122,14 @@
:else
(rx/empty)))))))
;; a transport failure carries no job error: a nil `ex-data` would
;; make the dialog drop the message and leave the row hanging, so a
;; synthetic error takes its place and the row reports it
(rx/catch (fn [cause]
(rx/of {:file-id (:id file)
:error (ex-data cause)})))))
:error (or (ex-data cause)
{:type :internal
:code :unknown-error})})))))
(defn export-files
"Start files exportation process.

View File

@ -11,55 +11,107 @@
[app.common.data.macros :as dm]
[app.config :as cf]
[app.main.data.exports.files :as fexp]
[app.main.data.imports :as imp]
[app.main.data.jobs :as dj]
[app.main.data.modal :as modal]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.jobs.progress :as jp]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- same-file?
"Whether an id from a message names a row of the dialog. The ids cross
serialization boundaries, so a message can carry the id as a string
while the row keeps the uuid (or the other way round): both sides are
canonicalized instead of assuming one type."
[a b]
(= (str a) (str b)))
(defn- error-hint
"What a failed row reports: the message of the failure when it says
something useful, or nil. A string error already is the message; a map
carries it under `:hint`; anything else is unwrapped with `ex-data`
first, which itself can answer a string."
[error]
(let [data (cond
(string? error) error
(map? error) error
:else (ex-data error))]
(some-> (if (string? data) data (:hint data))
(str/trim)
(not-empty))))
(defn- mark-file-error
[files file-id]
(mapv #(cond-> %
(= file-id (:id %))
(assoc :export-error true
:loading false))
"The export of a file failed: a terminal state that replaces any
other, so a row never reports success and failure at once."
[files file-id error]
(mapv (fn [file]
(if (same-file? file-id (:id file))
(-> file
(assoc :export-error true
:error error
:loading false)
(dissoc :export-success :size :progress :queued))
file))
files))
(defn- mark-file-cancelled
"The job of a file was cancelled elsewhere: a neutral terminal state,
neither success nor error."
neither success nor error, replacing any other."
[files file-id]
(mapv #(cond-> %
(= file-id (:id %))
(assoc :export-cancelled true
:loading false))
(mapv (fn [file]
(if (same-file? file-id (:id file))
(-> file
(assoc :export-cancelled true
:loading false)
(dissoc :export-success :size :export-error :error
:progress :queued))
file))
files))
(defn- mark-all-cancelled
"Every file still loading is over: its job was just cancelled, or it
was never created because the exports run one after another."
[files]
(mapv (fn [file]
(if (:loading file)
(-> file
(assoc :export-cancelled true
:loading false)
(dissoc :progress :queued))
file))
files))
(defn- mark-file-success
[files file-id]
(mapv #(cond-> %
(= file-id (:id %))
(assoc :export-success true
:loading false))
"The export of a file produced its artifact: a terminal state that
replaces any other, so a row never reports success and failure at
once."
[files file-id size]
(mapv (fn [file]
(if (same-file? file-id (:id file))
(-> file
(assoc :export-success true
:size size
:loading false)
(dissoc :export-error :error :progress :queued))
file))
files))
(defn- mark-file-progress
"The milestone the job of a file is in, to show it while it runs."
[files file-id progress]
(mapv #(cond-> %
(= file-id (:id %))
(same-file? file-id (:id %))
(assoc :progress progress
:queued false))
files))
@ -68,7 +120,7 @@
"The job of a file exists but no worker picked it up yet."
[files file-id]
(mapv #(cond-> %
(= file-id (:id %))
(same-file? file-id (:id %))
(assoc :queued true))
files))
@ -83,53 +135,114 @@
(mf/defc export-entry*
{::mf/private true}
[{:keys [file]}]
(let [level (cond
(:export-success file) :success
(:export-error file) :error
(:export-cancelled file) :warning
:else :info)]
(let [success? (:export-success file)
error? (:export-error file)
cancelled? (:export-cancelled file)
progress (:progress file)
running? (and (:loading file) (some? progress))
;; the job of the file exists but no worker picked it up yet: its
;; queue only ends with a milestone, so until then the row waits
waiting? (and (:loading file) (nil? progress))
size (imp/format-file-size (:size file))
;; the size of the artifact only exists once the job produced it:
;; until then the subtitle only says what it will be
subtitle (if (some? size) (dm/str size " .penpot") ".penpot")]
[:div {:class (stl/css-case
:file-entry true
:loading (:loading file)
:success (:export-success file)
:error (:export-error file))}
:success success?
:error error?)}
(if (:loading file)
[:*
[:div {:class (stl/css :file-name)}
[:> loader* {:width 26
:title (tr "labels.loading")}]
[:> text* {:class (stl/css :file-name-label)
:as "span"
:typography t/body-large}
(:name file)]]
[:div {:class (stl/css :file-main)}
[:div {:class (stl/css :file-icon)
:aria-hidden "true"}
[:> icon* {:icon-id (if error? i/close i/folder) :size "m"}]]
;; while the file is being produced: waiting for a worker, then
;; the milestone of the job
(when (or (some? (:progress file)) (:queued file))
(let [progress (:progress file)]
[:> text* {:class (stl/css :status-message)
:as "span"
:typography (if (some? progress) t/body-large t/body-medium)
:role "status"
:aria-live "polite"}
(if (some? progress)
(jp/milestone-text progress)
(tr "jobs.queued"))]))]
[:div {:class (stl/css :file-meta)}
[:> text* {:class (stl/css :file-name-label)
:as "span"
:typography t/body-medium}
(:name file)]
[:> text* {:class (stl/css :file-subtitle)
:as "span"
:typography t/body-small}
subtitle]]
[:*
[:> context-notification {:level level
:content (:name file)}]
;; a row carries its name and what its artifact will weigh, and only
;; the state that is its own: while the file runs, the milestone and
;; the bar live under it
(cond
success?
[:div {:class (stl/css :file-status)}
[:> icon* {:icon-id i/status-tick :size "s"}]
[:> text* {:as "span"
:typography t/body-medium}
(tr "files-export-modal.status.exported")]]
;; a cancelled file is a neutral terminal state, but the label is
;; what tells the user why no artifact came out
(when (:export-cancelled file)
[:> text* {:class (stl/css :status-message)
:as "span"
:typography t/body-medium
:role "status"
:aria-live "polite"}
(tr "jobs.export-cancelled")])])]))
waiting?
[:div {:class (stl/css :file-status)}
[:> icon* {:icon-id i/clock :size "s"}]
[:> text* {:as "span"
:typography t/body-medium}
(tr "files-export-modal.status.waiting")]])]
;; the milestone of the job, with an animated bar: the stages of an
;; export are not comparable one with another, so the client cannot
;; know a percentage of the file, only that it moves
(when running?
[:div {:class (stl/css :file-progress)}
[:> text* {:class (stl/css :progress-message)
:as "span"
:typography t/body-small
:role "status"
:aria-live "polite"}
(jp/milestone-text progress)]
[:div {:class (stl/css :progress-bar)
:role "progressbar"}
[:div {:class (stl/css :progress-fill)}]]])
(when error?
[:div {:class (stl/css :file-error)}
[:> text* {:class (stl/css :error-message)
:as "span"
:typography t/body-small}
;; the reason is user-facing text already (the hint of the job),
;; never a key
(tr "files-export-modal.export-error"
(or (error-hint (:error file))
(tr "labels.error")))]])
;; a cancelled file is a neutral terminal state, but the label is
;; what tells the user why no artifact came out
(when cancelled?
[:div {:class (stl/css :file-error)}
[:> text* {:class (stl/css :status-message)
:as "span"
:typography t/body-small
:role "status"
:aria-live "polite"}
(tr "jobs.export-cancelled")]])]))
(mf/defc export-phase-header*
{::mf/private true}
[{:keys [files]}]
(let [total (count files)
done (count (filterv :export-success files))
;; every file is its own job, so the header only counts them: the
;; progress of each one lives in its row, there is no global bar
running? (boolean (some :loading files))]
[:div {:class (stl/css :phase-header)}
[:> heading* {:level 3
:typography t/headline-medium
:class (stl/css :phase-title)}
(if running?
(tr "files-export-modal.exporting.title" (i18n/c total))
(tr "files-export-modal.exported.title" (i18n/c done)))]
[:> text* {:as "p"
:typography t/body-medium
:class (stl/css :phase-message)}
(if running?
(tr "files-export-modal.exporting.message")
(tr "files-export-modal.exported.message"))]]))
(mf/defc export-dialog
{::mf/register modal/components
@ -145,6 +258,10 @@
state (deref state*)
selected (:selected state)
status (:status state)
;; files still in flight: the dialog status never leaves
;; :exporting, so loading rows are the signal of a running
;; export, not the status
in-progress? (boolean (->> state :files (some :loading)))
;; Jobs still running, as file-id -> job-id, to cancel them on
;; demand; a file that reaches its outcome leaves the map, so
@ -164,7 +281,7 @@
(reset! sub* (->> (fexp/export-files :files files :type selected
:on-job #(swap! jobs* assoc (:file-id %) (:job-id %)))
(rx/subs!
(fn [{:keys [file-id error filename uri progress queued
(fn [{:keys [file-id error filename uri size progress queued
cancelled]}]
(cond
(some? progress)
@ -175,7 +292,7 @@
(some? error)
(swap! jobs* dissoc file-id)
(swap! state* update :files mark-file-error file-id)
(swap! state* update :files mark-file-error file-id error)
(true? cancelled)
(swap! jobs* dissoc file-id)
@ -186,35 +303,52 @@
(some? uri)
(do
(swap! jobs* dissoc file-id)
(swap! state* update :files mark-file-success file-id)
(swap! state* update :files mark-file-success file-id size)
(dom/trigger-download-uri filename "application/penpot" uri)))))))))
on-cancel-export
on-stop-jobs
(mf/use-fn
(mf/deps jobs sub)
(fn [event]
(dom/prevent-default event)
;; stop listening first, so no late message repaints the
;; entries of a dialog that is going away
(fn []
;; stop listening first, so no late message repaints the rows
(when (some? sub)
(rx/dispose! sub))
;; only the jobs still in flight are cancelled: finished ones
;; already left the map, and failures are ignored because a job may have just finished on its own
(run! dj/cancel-job (vals jobs))
(reset! jobs* {})
(reset! sub* nil)
(reset! sub* nil)))
on-cancel-export
(mf/use-fn
(mf/deps on-stop-jobs state*)
(fn [event]
(dom/prevent-default event)
(on-stop-jobs)
;; the dialog stays open with the terminal states: closing it
;; is what dismisses it, cancelling whatever is still pending
(swap! state* update :files mark-all-cancelled)))
on-close
(mf/use-fn
(mf/deps on-stop-jobs)
(fn [event]
(dom/prevent-default event)
;; closing dismisses the dialog, cancelling whatever is still
;; pending, so an export never outlives its dialog
(on-stop-jobs)
(st/emit! (modal/hide))))
on-cancel
(mf/use-fn
(mf/deps status on-cancel-export)
(mf/deps status in-progress? on-cancel-export on-close)
(fn [event]
(if (= :exporting status)
;; closing mid-export stops the jobs, like Cancel
;; the X cancels like Cancel Export while files are still in
;; flight, and dismisses like Done once everything reached a
;; terminal state
(if (and (= :exporting status) in-progress?)
(on-cancel-export event)
(do
(dom/prevent-default event)
(st/emit! (modal/hide))))))
(on-close event))))
on-accept
(mf/use-fn
@ -243,7 +377,7 @@
[:> heading* {:level 2
:typography t/headline-large
:class (stl/css :modal-title)}
(tr "files-download-modal.title")]
(tr "files-export-modal.title")]
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-cancel
@ -308,7 +442,7 @@
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "secondary"
:type "button"
:on-click on-cancel}
:on-click on-close}
(tr "labels.cancel")]
[:> button* {:variant "primary"
@ -317,21 +451,20 @@
(tr "labels.continue")]]]]
(= status :exporting)
(let [in-progress? (->> state :files (some :loading))]
[:*
[:div {:class (stl/css :modal-content)}
(for [file (:files state)]
[:> export-entry* {:file file :key (dm/str (:id file))}])]
[:*
[:div {:class (stl/css :modal-content)}
[:> export-phase-header* {:files (:files state)}]
(for [file (:files state)]
[:> export-entry* {:file file :key (dm/str (:id file))}])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(when in-progress?
[:> button* {:variant "secondary"
:type "button"
:on-click on-cancel-export}
(tr "labels.cancel")])
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(if in-progress?
[:> button* {:variant "secondary"
:type "button"
:on-click on-cancel-export}
(tr "files-export-modal.cancel-export")]
[:> button* {:variant "primary"
:type "button"
:disabled in-progress?
:on-click on-cancel}
(tr "labels.close")]]]]))]]))
:on-click on-close}
(tr "files-export-modal.done")])]]])]]))

View File

@ -48,6 +48,12 @@
flex-direction: column;
gap: var(--sp-l);
margin-block-end: var(--sp-xxl);
// like the import wizard: the rows scroll and the header and footer
// stay put, instead of the content pushing the footer out of the box
flex: 1;
min-block-size: 0;
overflow: hidden auto;
}
.modal-content-extended {
@ -153,60 +159,153 @@
margin: 0;
}
.file-entry {
--file-entry-color: var(--color-foreground-secondary);
.phase-header {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
&.loading {
.file-name {
color: var(--color-foreground-secondary);
}
.phase-title {
color: var(--color-foreground-primary);
}
.phase-message {
color: var(--color-foreground-secondary);
margin: 0;
}
.file-entry {
--file-entry-fg-color: var(--color-foreground-secondary);
display: flex;
flex-direction: column;
gap: var(--sp-m);
&.success {
--file-entry-fg-color: var(--color-accent-success);
}
&.error {
.file-name {
color: var(--color-foreground-secondary);
--file-entry-fg-color: var(--color-accent-error);
.file-icon svg {
stroke: var(--color-foreground-secondary);
}
}
}
&.success {
.file-name {
color: var(--color-foreground-secondary);
.file-icon svg {
stroke: var(--color-foreground-secondary);
}
}
padding: var(--sp-m);
border-radius: $br-8;
background-color: var(--color-background-error);
}
}
.file-name {
.file-main {
display: flex;
align-items: center;
gap: var(--sp-m);
}
.file-icon {
display: flex;
justify-content: center;
align-items: center;
block-size: px2rem(16);
inline-size: px2rem(16);
color: var(--color-foreground-secondary);
}
.file-icon {
display: flex;
justify-content: center;
align-items: center;
block-size: px2rem(40);
inline-size: px2rem(40);
flex-shrink: 0;
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-secondary);
.file-name-label {
display: block;
max-inline-size: 99%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.error & {
background-color: var(--color-accent-error);
color: var(--color-background-error);
}
}
.icon-status-tick {
fill: none;
stroke: var(--color-accent-primary);
.file-meta {
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
flex: 1 1 auto;
min-inline-size: 0;
.error & {
color: var(--color-foreground-primary);
}
}
.file-name-label {
display: block;
max-inline-size: 99%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-foreground-primary);
}
.file-subtitle {
color: var(--color-foreground-secondary);
.error & {
color: var(--file-entry-fg-color);
}
}
.file-status {
display: flex;
align-items: center;
gap: var(--sp-s);
flex-shrink: 0;
color: var(--file-entry-fg-color);
white-space: nowrap;
}
.file-progress {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.progress-message {
color: var(--color-foreground-secondary);
}
.progress-bar {
block-size: px2rem(4);
border-radius: $br-8;
background-color: var(--color-background-tertiary);
overflow: hidden;
}
.progress-fill {
block-size: 100%;
border-radius: $br-8;
background-color: var(--color-accent-primary);
// the twin of the indeterminate bar of the import wizard: the stages
// of an export are not comparable one with another, so the fill slides
// through the bar while the job of the file runs
width: 40%;
animation: progress-slide 1.2s ease-in-out infinite;
}
@keyframes progress-slide {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
.file-error {
padding-inline-start: calc(px2rem(40) + var(--sp-m));
.error & {
padding-inline-start: 0;
}
}
.error-message {
display: flex;
align-items: flex-start;
color: var(--file-entry-fg-color);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@ -2153,9 +2153,37 @@ msgstr "Idea"
msgid "feedback.type.issue"
msgstr "Issue"
#: src/app/main/ui/exports/files.cljs:140
msgid "files-download-modal.title"
msgstr "Download files"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.cancel-export"
msgstr "Cancel export"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.done"
msgstr "Done"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.export-error"
msgstr "Couldn't export: %s"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exported.message"
msgstr "Check your downloads folder."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exported.title"
msgid_plural "files-export-modal.exported.title"
msgstr[0] "1 file exported"
msgstr[1] "%s files exported"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exporting.message"
msgstr "Each file downloads as soon as it's ready."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exporting.title"
msgid_plural "files-export-modal.exporting.title"
msgstr[0] "Exporting 1 file"
msgstr[1] "Exporting %s files"
#: src/app/main/ui/exports/files.cljs:183
msgid "files-export-modal.options.detach-libraries.message"
@ -2197,6 +2225,18 @@ msgstr "Embed library assets in the file"
msgid "files-download-modal.unsaved-changes"
msgstr "Some of your latest changes are not saved yet, so this download will not include them."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.status.exported"
msgstr "Exported"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.status.waiting"
msgstr "Waiting"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.title"
msgstr "Export file"
#: src/app/main/ui/settings/password.cljs:45
msgid "generic.error"
msgstr "An error has occurred"

View File

@ -728,12 +728,6 @@ msgstr[1] "%s ficheros se han importado correctamente."
msgid "dashboard.import.import-warning"
msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados."
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.importing.title"
msgid_plural "dashboard.import.importing.title"
msgstr[0] "Importando 1 archivo"
msgstr[1] "Importando %s archivos"
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.importing.done"
msgid_plural "dashboard.import.importing.done"
@ -744,6 +738,12 @@ msgstr[1] "%s hechos"
msgid "dashboard.import.importing.message"
msgstr "Reconstruyendo páginas, componentes y recursos en tu proyecto."
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.importing.title"
msgid_plural "dashboard.import.importing.title"
msgstr[0] "Importando 1 archivo"
msgstr[1] "Importando %s archivos"
#: src/app/main/ui/dashboard/import.cljs:710
msgid "dashboard.import.next-file"
msgstr "Siguiente archivo"
@ -752,15 +752,15 @@ msgstr "Siguiente archivo"
msgid "dashboard.import.no-perms"
msgstr "No tienes permisos para importar en este equipo"
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.queued.title"
msgstr "Esperando a empezar..."
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.queued.message"
msgstr ""
"Tus archivos ya están subidos. La importación empieza en cuanto haya un hueco "
"libre."
"Tus archivos ya están subidos. La importación empieza en cuanto haya un "
"hueco libre."
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.queued.title"
msgstr "Esperando a empezar..."
#: src/app/main/ui/dashboard/import.cljs
msgid "dashboard.import.ready.message"
@ -2193,9 +2193,37 @@ msgstr "Idea"
msgid "feedback.type.issue"
msgstr "Problema"
#: src/app/main/ui/exports/files.cljs:140
msgid "files-download-modal.title"
msgstr "Descargar ficheros"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.cancel-export"
msgstr "Cancelar exportación"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.done"
msgstr "Listo"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.export-error"
msgstr "No se ha podido exportar: %s"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exported.message"
msgstr "Revisa tu carpeta de descargas."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exported.title"
msgid_plural "files-export-modal.exported.title"
msgstr[0] "1 archivo exportado"
msgstr[1] "%s archivos exportados"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exporting.message"
msgstr "Cada archivo se descarga en cuanto está listo."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.exporting.title"
msgid_plural "files-export-modal.exporting.title"
msgstr[0] "Exportando 1 archivo"
msgstr[1] "Exportando %s archivos"
#: src/app/main/ui/exports/files.cljs:183
msgid "files-export-modal.options.detach-libraries.message"
@ -2239,6 +2267,18 @@ msgstr "Incluir librerias compartidas dentro de las librerias del fichero"
msgid "files-download-modal.unsaved-changes"
msgstr "Algunos de tus últimos cambios aún no se han guardado, así que esta descarga no los incluirá."
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.status.exported"
msgstr "Exportado"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.status.waiting"
msgstr "En espera"
#: src/app/main/ui/exports/files.cljs
msgid "files-export-modal.title"
msgstr "Exportar archivo"
#: src/app/main/ui/settings/password.cljs:45
msgid "generic.error"
msgstr "Ha ocurrido un error"