From 8cdbdbb6dad217e5d5c5922c047cb1af209dba76 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 15 Oct 2026 08:45:14 +0000 Subject: [PATCH] :sparkles: 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 --- frontend/src/app/main/data/exports/files.cljs | 10 +- frontend/src/app/main/ui/exports/files.cljs | 319 +++++++++++++----- frontend/src/app/main/ui/exports/files.scss | 179 +++++++--- frontend/translations/en.po | 46 ++- frontend/translations/es.po | 70 +++- 5 files changed, 471 insertions(+), 153 deletions(-) diff --git a/frontend/src/app/main/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 87d64649d4..905716897c 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -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. diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 8a527c2209..4313d42999 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -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")])]]])]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index b2a56e435e..6fa97eae35 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -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; } diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 5a33b78c61..3ab36bd706 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 35a7c50984..9ddcfba9d0 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"