diff --git a/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/apps/web/core/components/issues/attachment/attachment-item-list.tsx index 4341e5c9510..9dc9e9c41fa 100644 --- a/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -4,13 +4,11 @@ * See the LICENSE file for details. */ -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { observer } from "mobx-react"; -import type { FileRejection } from "react-dropzone"; import { useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; // hooks @@ -19,6 +17,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useFileSize } from "@/hooks/use-file-size"; // types import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; +import { useAttachmentDropHandler } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentsListItem } from "./attachment-list-item"; import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item"; @@ -44,8 +43,6 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList issueServiceType = EIssueServiceType.ISSUES, } = props; const { t } = useTranslation(); - // states - const [isUploading, setIsUploading] = useState(false); // store hooks const { attachment: { getAttachmentsByIssueId }, @@ -62,58 +59,27 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList const issueAttachments = getAttachmentsByIssueId(issueId); // handlers - const handleFetchPropertyActivities = useCallback(() => { + const handleUploadSettled = useCallback(() => { fetchActivities(workspaceSlug, projectId, issueId); }, [fetchActivities, workspaceSlug, projectId, issueId]); - const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - - if (rejectedFiles.length === 0) { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; - - setIsUploading(true); - createAttachment(currentFile) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("attachment.error"), - }); - }) - .finally(() => { - handleFetchPropertyActivities(); - setIsUploading(false); - }); - return; - } - - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: - totalAttachedFiles > 1 - ? t("attachment.only_one_file_allowed") - : t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }), - }); - return; - }, - [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] - ); + const { onDrop, isUploading } = useAttachmentDropHandler({ + create: createAttachment, + maxFileSize, + onUploadSettled: handleUploadSettled, + }); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, maxSize: maxFileSize, - multiple: false, - disabled: isUploading || disabled, + multiple: true, + disabled: isUploading || disabled || !workspaceSlug, }); return ( <> - {uploadStatus?.map((uploadStatus) => ( - + {uploadStatus?.map((status) => ( + ))} {issueAttachments && ( <> diff --git a/apps/web/core/components/issues/attachment/attachment-upload.tsx b/apps/web/core/components/issues/attachment/attachment-upload.tsx index 2a37b3b985c..281dcda8f5d 100644 --- a/apps/web/core/components/issues/attachment/attachment-upload.tsx +++ b/apps/web/core/components/issues/attachment/attachment-upload.tsx @@ -4,13 +4,13 @@ * See the LICENSE file for details. */ -import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; // plane web hooks import { useFileSize } from "@/hooks/use-file-size"; // types import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; +import { useAttachmentDropHandler } from "../issue-detail-widgets/attachments/helper"; type TAttachmentOperationsModal = Pick; @@ -22,32 +22,21 @@ type Props = { export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(props: Props) { const { workspaceSlug, disabled = false, attachmentOperations } = props; - // states - const [isLoading, setIsLoading] = useState(false); // file size const { maxFileSize } = useFileSize(); + // drop handler + const { onDrop, progress, isUploading } = useAttachmentDropHandler({ + create: attachmentOperations.create, + maxFileSize, + }); - const onDrop = useCallback( - (acceptedFiles: File[]) => { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; - - setIsLoading(true); - attachmentOperations.create(currentFile).finally(() => setIsLoading(false)); - }, - [attachmentOperations, workspaceSlug] - ); - - const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ onDrop, maxSize: maxFileSize, - multiple: false, - disabled: isLoading || disabled, + multiple: true, + disabled: isUploading || disabled || !workspaceSlug, }); - const fileError = - fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null; - return (
{isDragActive ? (

Drop here...

- ) : fileError ? ( -

{fileError}

- ) : isLoading ? ( -

Uploading...

+ ) : progress ? ( +

+ {/* Files finished, not the file being worked on: uploads run concurrently, so + there is no single "current" file to point at. */} + {progress.total > 1 ? `Uploading ${progress.completed}/${progress.total}...` : "Uploading..."} +

) : ( -

Click or drag a file here

+

Click or drag files here

)}
diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index e32dfbbd098..5f7aa60a1f7 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -4,8 +4,10 @@ * See the LICENSE file for details. */ -import { useMemo } from "react"; -import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { useCallback, useMemo, useState } from "react"; +import type { FileRejection } from "react-dropzone"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; // hooks @@ -13,8 +15,24 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // types import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; +/** + * Number of attachments uploaded in parallel. Dropping a folder of screenshots should not + * open one request per file, but a strict sequence makes a large batch needlessly slow. + */ +const UPLOAD_CONCURRENCY = 3; + +type TAttachmentUploadSummary = { + uploadedCount: number; + failedFileNames: string[]; +}; + +export type TAttachmentUploadProgress = { + completed: number; + total: number; +}; + export type TAttachmentOperations = { - create: (file: File) => Promise; + create: (files: File[], onProgress?: (progress: TAttachmentUploadProgress) => void) => Promise; remove: (attachmentId: string) => Promise; }; @@ -27,34 +45,90 @@ export type TAttachmentHelpers = { snapshot: TAttachmentSnapshot; }; +/** + * Run `task` over every file, keeping at most `limit` uploads in flight. A rejected upload + * is recorded and never aborts the batch, so one bad file cannot discard the rest. + */ +const uploadWithConcurrency = async ( + files: File[], + limit: number, + task: (file: File) => Promise, + onSettled: (file: File, isSuccess: boolean) => void +): Promise => { + let cursor = 0; + const worker = async () => { + while (cursor < files.length) { + const file = files[cursor]; + cursor += 1; + if (!file) continue; + try { + // Sequential by design: each worker drains the queue one file at a time so that + // `limit` bounds the in-flight uploads. Promise.all here would be unbounded. + // eslint-disable-next-line no-await-in-loop + await task(file); + onSettled(file, true); + } catch { + // The store logs the underlying error; the caller turns this into a user-facing message. + onSettled(file, false); + } + } + }; + await Promise.all(Array.from({ length: Math.min(limit, files.length) }, worker)); +}; + export const useAttachmentOperations = ( workspaceSlug: string, projectId: string, issueId: string, issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES ): TAttachmentHelpers => { + const { t } = useTranslation(); const { attachment: { createAttachment, removeAttachment, getAttachmentsUploadStatusByIssueId }, } = useIssueDetail(issueServiceType); const attachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (file) => { + create: async (files, onProgress) => { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); - setPromiseToast(attachmentUploadPromise, { - loading: "Uploading attachment...", - success: { - title: "Attachment uploaded", - message: () => "The attachment has been successfully uploaded", - }, - error: { - title: "Attachment not uploaded", - message: () => "The attachment could not be uploaded", - }, - }); + if (files.length === 0) return; + + const summary: TAttachmentUploadSummary = { uploadedCount: 0, failedFileNames: [] }; + let completed = 0; + + await uploadWithConcurrency( + files, + UPLOAD_CONCURRENCY, + (file) => createAttachment(workspaceSlug, projectId, issueId, file), + (file, isSuccess) => { + if (isSuccess) summary.uploadedCount += 1; + else summary.failedFileNames.push(file.name); + completed += 1; + onProgress?.({ completed, total: files.length }); + } + ); + + // A partially successful batch keeps the uploaded files and names only the ones that failed. + if (summary.failedFileNames.length > 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("attachment.upload_failed_title", { count: summary.failedFileNames.length }), + message: + summary.uploadedCount > 0 + ? t("attachment.upload_partial_failure", { + count: summary.uploadedCount, + files: summary.failedFileNames.join(", "), + }) + : t("attachment.upload_failure", { files: summary.failedFileNames.join(", ") }), + }); + return; + } - await attachmentUploadPromise; + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("attachment.upload_success_title", { count: summary.uploadedCount }), + message: t("attachment.upload_success", { count: summary.uploadedCount }), + }); }, remove: async (attachmentId) => { try { @@ -74,7 +148,7 @@ export const useAttachmentOperations = ( } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, t] ); const attachmentsUploadStatus = getAttachmentsUploadStatusByIssueId(issueId); @@ -83,3 +157,63 @@ export const useAttachmentOperations = ( snapshot: { uploadStatus: attachmentsUploadStatus }, }; }; + +type TAttachmentDropHandlerArgs = { + create: TAttachmentOperations["create"]; + maxFileSize: number; + /** Runs once per drop, after the whole batch settles. */ + onUploadSettled?: () => void; +}; + +/** + * Shared `onDrop` for every work item attachment dropzone. Files rejected by the dropzone + * itself (over the size limit) are reported by name and the remaining ones still upload. + */ +export const useAttachmentDropHandler = (args: TAttachmentDropHandlerArgs) => { + const { create, maxFileSize, onUploadSettled } = args; + const { t } = useTranslation(); + // states + const [progress, setProgress] = useState(null); + + const onDrop = useCallback( + async (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length > 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("attachment.files_too_large", { + count: rejectedFiles.length, + size: maxFileSize / 1024 / 1024, + files: rejectedFiles.map((rejection) => rejection.file.name).join(", "), + }), + }); + } + + if (acceptedFiles.length === 0) return; + + setProgress({ completed: 0, total: acceptedFiles.length }); + try { + await create(acceptedFiles, setProgress); + } catch (error) { + // Per-file failures are already reported by `create`; this only catches a batch that + // never started, such as a missing workspace or project id. + console.error("Error in uploading issue attachments:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("attachment.error"), + }); + } finally { + setProgress(null); + onUploadSettled?.(); + } + }, + [create, maxFileSize, onUploadSettled, t] + ); + + return { + onDrop, + progress, + isUploading: progress !== null, + }; +}; diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index dc6155550f0..e27ad30d273 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -4,20 +4,18 @@ * See the LICENSE file for details. */ -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { observer } from "mobx-react"; -import type { FileRejection } from "react-dropzone"; import { useDropzone } from "react-dropzone"; import { PlusIcon } from "@plane/propel/icons"; // plane imports -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TIssueServiceType } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // plane web hooks import { useFileSize } from "@/hooks/use-file-size"; // local imports -import { useAttachmentOperations } from "./helper"; +import { useAttachmentDropHandler, useAttachmentOperations } from "./helper"; type Props = { workspaceSlug: string; @@ -30,8 +28,6 @@ type Props = { export const IssueAttachmentActionButton = observer(function IssueAttachmentActionButton(props: Props) { const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props; - // state - const [isLoading, setIsLoading] = useState(false); // store hooks const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType); // file size @@ -44,64 +40,39 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi issueServiceType ); // handlers - const handleFetchPropertyActivities = useCallback(() => { + const handleUploadSettled = useCallback(() => { fetchActivities(workspaceSlug, projectId, issueId); - }, [fetchActivities, workspaceSlug, projectId, issueId]); + setLastWidgetAction("attachments"); + }, [fetchActivities, workspaceSlug, projectId, issueId, setLastWidgetAction]); - const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - - if (rejectedFiles.length === 0) { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; - - setIsLoading(true); - attachmentOperations - .create(currentFile) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", - }); - }) - .finally(() => { - handleFetchPropertyActivities(); - setLastWidgetAction("attachments"); - setIsLoading(false); - }); - return; - } + const { onDrop, isUploading } = useAttachmentDropHandler({ + create: attachmentOperations.create, + maxFileSize, + onUploadSettled: handleUploadSettled, + }); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - totalAttachedFiles > 1 - ? "Only one file can be uploaded at a time." - : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, - }); - return; - }, - [attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction] - ); + // react-dropzone does not forward its disabled state to the root element, so the native + // button has to be given the same condition or it stays clickable while doing nothing. + const isDropzoneDisabled = isUploading || disabled || !workspaceSlug; const { getRootProps, getInputProps } = useDropzone({ onDrop, maxSize: maxFileSize, - multiple: false, - disabled: isLoading || disabled, + multiple: true, + disabled: isDropzoneDisabled, }); return ( + // Presentational wrapper: the button below is the real control, this only keeps the + // click from bubbling to the surrounding work item row. + // TODO: Remove extra div and move event propagation to button
{ - // TODO: Remove extra div and move event propagation to button e.stopPropagation(); }} > - diff --git a/packages/i18n/src/locales/cs/common.json b/packages/i18n/src/locales/cs/common.json index c1e9372c24e..b065293507a 100644 --- a/packages/i18n/src/locales/cs/common.json +++ b/packages/i18n/src/locales/cs/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Soubor nelze připojit. Zkuste to prosím znovu.", - "only_one_file_allowed": "Je možné nahrát pouze jeden soubor najednou.", - "file_size_limit": "Soubor musí být menší než {size}MB.", "drag_and_drop": "Přetáhněte soubor kamkoli pro nahrání", - "delete": "Smazat přílohu" + "delete": "Smazat přílohu", + "files_too_large": "Přeskočeno {count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}} kvůli překročení limitu {size} MB: {files}", + "upload_success_title": "{count, plural, one {Příloha nahrána} few {Přílohy nahrány} many {Přílohy nahrány} other {Přílohy nahrány}}", + "upload_success": "{count, plural, one {Přidán # soubor.} few {Přidány # soubory.} many {Přidáno # souboru.} other {Přidáno # souborů.}}", + "upload_failed_title": "{count, plural, one {Příloha nenahrána} few {Přílohy nenahrány} many {Přílohy nenahrány} other {Přílohy nenahrány}}", + "upload_partial_failure": "{count, plural, one {Přidán # soubor.} few {Přidány # soubory.} many {Přidáno # souboru.} other {Přidáno # souborů.}} Nepodařilo se přidat: {files}", + "upload_failure": "Nepodařilo se přidat: {files}. Zkuste to prosím znovu." }, "label": { "select": "Vybrat štítek", diff --git a/packages/i18n/src/locales/de/common.json b/packages/i18n/src/locales/de/common.json index 1e18d4ee26e..36fd3ee1c59 100644 --- a/packages/i18n/src/locales/de/common.json +++ b/packages/i18n/src/locales/de/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Datei konnte nicht angehängt werden. Bitte versuchen Sie es erneut.", - "only_one_file_allowed": "Es kann jeweils nur eine Datei hochgeladen werden.", - "file_size_limit": "Die Datei muss kleiner als {size} MB sein.", "drag_and_drop": "Datei hierher ziehen, um sie hochzuladen", - "delete": "Anhang löschen" + "delete": "Anhang löschen", + "files_too_large": "{count, plural, one {# Datei wurde} other {# Dateien wurden}} übersprungen – Limit von {size} MB überschritten: {files}", + "upload_success_title": "{count, plural, one {Anhang hochgeladen} other {Anhänge hochgeladen}}", + "upload_success": "{count, plural, one {# Datei wurde angehängt.} other {# Dateien wurden angehängt.}}", + "upload_failed_title": "{count, plural, one {Anhang nicht hochgeladen} other {Anhänge nicht hochgeladen}}", + "upload_partial_failure": "{count, plural, one {# Datei wurde angehängt.} other {# Dateien wurden angehängt.}} Nicht angehängt: {files}", + "upload_failure": "Nicht angehängt: {files}. Bitte erneut versuchen." }, "label": { "select": "Label auswählen", diff --git a/packages/i18n/src/locales/en/common.json b/packages/i18n/src/locales/en/common.json index a138304371e..8a0f93aeec3 100644 --- a/packages/i18n/src/locales/en/common.json +++ b/packages/i18n/src/locales/en/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "File could not be attached. Try uploading again.", - "only_one_file_allowed": "Only one file can be uploaded at a time.", - "file_size_limit": "File must be of {size}MB or less in size.", "drag_and_drop": "Drag and drop anywhere to upload", - "delete": "Delete attachment" + "delete": "Delete attachment", + "files_too_large": "Skipped {count, plural, one {# file} other {# files}} over the {size}MB limit: {files}", + "upload_success_title": "{count, plural, one {Attachment uploaded} other {Attachments uploaded}}", + "upload_success": "{count, plural, one {# file has been attached.} other {# files have been attached.}}", + "upload_failed_title": "{count, plural, one {Attachment not uploaded} other {Attachments not uploaded}}", + "upload_partial_failure": "{count, plural, one {# file was attached.} other {# files were attached.}} Could not attach: {files}", + "upload_failure": "Could not attach: {files}. Try uploading again." }, "label": { "select": "Add labels", diff --git a/packages/i18n/src/locales/es/common.json b/packages/i18n/src/locales/es/common.json index 64440c9c653..57a4d8b56bb 100644 --- a/packages/i18n/src/locales/es/common.json +++ b/packages/i18n/src/locales/es/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "No se pudo adjuntar el archivo. Intenta subirlo de nuevo.", - "only_one_file_allowed": "Solo se puede subir un archivo a la vez.", - "file_size_limit": "El archivo debe tener {size}MB o menos de tamaño.", "drag_and_drop": "Arrastra y suelta en cualquier lugar para subir", - "delete": "Eliminar archivo adjunto" + "delete": "Eliminar archivo adjunto", + "files_too_large": "Se {count, plural, one {omitió # archivo} other {omitieron # archivos}} por superar el límite de {size} MB: {files}", + "upload_success_title": "{count, plural, one {Archivo adjuntado} other {Archivos adjuntados}}", + "upload_success": "{count, plural, one {Se adjuntó # archivo.} other {Se adjuntaron # archivos.}}", + "upload_failed_title": "{count, plural, one {Archivo no adjuntado} other {Archivos no adjuntados}}", + "upload_partial_failure": "{count, plural, one {Se adjuntó # archivo.} other {Se adjuntaron # archivos.}} No se pudo adjuntar: {files}", + "upload_failure": "No se pudo adjuntar: {files}. Inténtelo de nuevo." }, "label": { "select": "Seleccionar etiqueta", diff --git a/packages/i18n/src/locales/fr/common.json b/packages/i18n/src/locales/fr/common.json index a94ab8aba2d..164860feb6f 100644 --- a/packages/i18n/src/locales/fr/common.json +++ b/packages/i18n/src/locales/fr/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Le fichier n’a pas pu être joint. Essayez de le télécharger à nouveau.", - "only_one_file_allowed": "Un seul fichier peut être téléchargé à la fois.", - "file_size_limit": "Le fichier doit faire {size}MB ou moins.", "drag_and_drop": "Glissez-déposez n’importe où pour uploader", - "delete": "Supprimer la pièce jointe" + "delete": "Supprimer la pièce jointe", + "files_too_large": "{count, plural, one {# fichier ignoré} many {# fichiers ignorés} other {# fichiers ignorés}} car au-delà de la limite de {size} Mo : {files}", + "upload_success_title": "{count, plural, one {Pièce jointe ajoutée} many {Pièces jointes ajoutées} other {Pièces jointes ajoutées}}", + "upload_success": "{count, plural, one {# fichier a été joint.} many {# fichiers ont été joints.} other {# fichiers ont été joints.}}", + "upload_failed_title": "{count, plural, one {Pièce jointe non ajoutée} many {Pièces jointes non ajoutées} other {Pièces jointes non ajoutées}}", + "upload_partial_failure": "{count, plural, one {# fichier a été joint.} many {# fichiers ont été joints.} other {# fichiers ont été joints.}} Impossible de joindre : {files}", + "upload_failure": "Impossible de joindre : {files}. Veuillez réessayer." }, "label": { "select": "Sélectionner une étiquette", diff --git a/packages/i18n/src/locales/id/common.json b/packages/i18n/src/locales/id/common.json index 7d266850dc4..37bf2404104 100644 --- a/packages/i18n/src/locales/id/common.json +++ b/packages/i18n/src/locales/id/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "File tidak dapat dilampirkan. Coba unggah lagi.", - "only_one_file_allowed": "Hanya satu file yang dapat diunggah pada satu waktu.", - "file_size_limit": "File harus berukuran {size}MB atau lebih kecil.", "drag_and_drop": "Seret dan jatuhkan di mana saja untuk mengunggah", - "delete": "Hapus lampiran" + "delete": "Hapus lampiran", + "files_too_large": "Melewati {count, plural, one {# berkas} other {# berkas}} yang melebihi batas {size} MB: {files}", + "upload_success_title": "{count, plural, one {Lampiran diunggah} other {Lampiran diunggah}}", + "upload_success": "{count, plural, one {# berkas telah dilampirkan.} other {# berkas telah dilampirkan.}}", + "upload_failed_title": "{count, plural, one {Lampiran tidak diunggah} other {Lampiran tidak diunggah}}", + "upload_partial_failure": "{count, plural, one {# berkas telah dilampirkan.} other {# berkas telah dilampirkan.}} Tidak dapat melampirkan: {files}", + "upload_failure": "Tidak dapat melampirkan: {files}. Silakan coba lagi." }, "label": { "select": "Pilih label", diff --git a/packages/i18n/src/locales/it/common.json b/packages/i18n/src/locales/it/common.json index 6fda1b04957..41e617e7a6c 100644 --- a/packages/i18n/src/locales/it/common.json +++ b/packages/i18n/src/locales/it/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Impossibile allegare il file. Riprova a caricarlo.", - "only_one_file_allowed": "È possibile caricare un solo file alla volta.", - "file_size_limit": "Il file deve essere di {size}MB o meno.", "drag_and_drop": "Trascina e rilascia ovunque per caricare", - "delete": "Elimina allegato" + "delete": "Elimina allegato", + "files_too_large": "{count, plural, one {# file ignorato} many {# file ignorati} other {# file ignorati}} perché supera il limite di {size} MB: {files}", + "upload_success_title": "{count, plural, one {Allegato caricato} many {Allegati caricati} other {Allegati caricati}}", + "upload_success": "{count, plural, one {# file è stato allegato.} many {# file sono stati allegati.} other {# file sono stati allegati.}}", + "upload_failed_title": "{count, plural, one {Allegato non caricato} many {Allegati non caricati} other {Allegati non caricati}}", + "upload_partial_failure": "{count, plural, one {# file è stato allegato.} many {# file sono stati allegati.} other {# file sono stati allegati.}} Impossibile allegare: {files}", + "upload_failure": "Impossibile allegare: {files}. Riprovare." }, "label": { "select": "Seleziona etichetta", diff --git a/packages/i18n/src/locales/ja/common.json b/packages/i18n/src/locales/ja/common.json index 4899411b615..4360279759d 100644 --- a/packages/i18n/src/locales/ja/common.json +++ b/packages/i18n/src/locales/ja/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "ファイルを添付できませんでした。もう一度アップロードしてください。", - "only_one_file_allowed": "一度にアップロードできるファイルは1つだけです。", - "file_size_limit": "ファイルサイズは{size}MB以下である必要があります。", "drag_and_drop": "どこにでもドラッグ&ドロップでアップロード", - "delete": "添付ファイルを削除" + "delete": "添付ファイルを削除", + "files_too_large": "{size}MB の上限を超えたため、{count, plural, one {# 件} other {# 件}}のファイルをスキップしました:{files}", + "upload_success_title": "{count, plural, one {添付ファイルをアップロードしました} other {添付ファイルをアップロードしました}}", + "upload_success": "{count, plural, one {# 件のファイルを添付しました。} other {# 件のファイルを添付しました。}}", + "upload_failed_title": "{count, plural, one {添付ファイルをアップロードできませんでした} other {添付ファイルをアップロードできませんでした}}", + "upload_partial_failure": "{count, plural, one {# 件のファイルを添付しました。} other {# 件のファイルを添付しました。}}添付できませんでした:{files}", + "upload_failure": "添付できませんでした:{files}。もう一度お試しください。" }, "label": { "select": "ラベルを選択", diff --git a/packages/i18n/src/locales/ko/common.json b/packages/i18n/src/locales/ko/common.json index 73eea6b1c25..0c07f98d465 100644 --- a/packages/i18n/src/locales/ko/common.json +++ b/packages/i18n/src/locales/ko/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "파일을 첨부할 수 없습니다. 다시 업로드하세요.", - "only_one_file_allowed": "한 번에 하나의 파일만 업로드할 수 있습니다.", - "file_size_limit": "파일 크기는 {size}MB 이하이어야 합니다.", "drag_and_drop": "업로드하려면 아무 곳에나 드래그 앤 드롭하세요", - "delete": "첨부 파일 삭제" + "delete": "첨부 파일 삭제", + "files_too_large": "{size}MB 제한을 초과한 파일 {count, plural, one {#개} other {#개}}를 건너뛰었습니다: {files}", + "upload_success_title": "{count, plural, one {첨부 파일 업로드 완료} other {첨부 파일 업로드 완료}}", + "upload_success": "{count, plural, one {파일 #개를 첨부했습니다.} other {파일 #개를 첨부했습니다.}}", + "upload_failed_title": "{count, plural, one {첨부 파일 업로드 실패} other {첨부 파일 업로드 실패}}", + "upload_partial_failure": "{count, plural, one {파일 #개를 첨부했습니다.} other {파일 #개를 첨부했습니다.}} 첨부하지 못한 파일: {files}", + "upload_failure": "다음 파일을 첨부하지 못했습니다: {files}. 다시 시도해 주십시오." }, "label": { "select": "레이블 선택", diff --git a/packages/i18n/src/locales/pl/common.json b/packages/i18n/src/locales/pl/common.json index 4c4850cbf66..b4f9607014e 100644 --- a/packages/i18n/src/locales/pl/common.json +++ b/packages/i18n/src/locales/pl/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Nie udało się dodać pliku. Spróbuj ponownie.", - "only_one_file_allowed": "Możesz przesłać tylko jeden plik naraz.", - "file_size_limit": "Plik musi być mniejszy niż {size}MB.", "drag_and_drop": "Przeciągnij plik w dowolne miejsce, aby przesłać", - "delete": "Usuń załącznik" + "delete": "Usuń załącznik", + "files_too_large": "Pominięto {count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}} z powodu przekroczenia limitu {size} MB: {files}", + "upload_success_title": "{count, plural, one {Załącznik przesłany} few {Załączniki przesłane} many {Załączniki przesłane} other {Załączniki przesłane}}", + "upload_success": "Dodano {count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}.", + "upload_failed_title": "{count, plural, one {Załącznik nieprzesłany} few {Załączniki nieprzesłane} many {Załączniki nieprzesłane} other {Załączniki nieprzesłane}}", + "upload_partial_failure": "Dodano {count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}. Nie udało się dodać: {files}", + "upload_failure": "Nie udało się dodać: {files}. Proszę spróbować ponownie." }, "label": { "select": "Wybierz etykietę", diff --git a/packages/i18n/src/locales/pt-BR/common.json b/packages/i18n/src/locales/pt-BR/common.json index 565e85d8739..7066f713542 100644 --- a/packages/i18n/src/locales/pt-BR/common.json +++ b/packages/i18n/src/locales/pt-BR/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Não foi possível anexar o arquivo. Tente enviar novamente.", - "only_one_file_allowed": "Apenas um arquivo pode ser enviado por vez.", - "file_size_limit": "O arquivo deve ter {size}MB ou menos.", "drag_and_drop": "Arraste e solte em qualquer lugar para enviar", - "delete": "Excluir anexo" + "delete": "Excluir anexo", + "files_too_large": "{count, plural, one {# arquivo ignorado} many {# arquivos ignorados} other {# arquivos ignorados}} por exceder o limite de {size} MB: {files}", + "upload_success_title": "{count, plural, one {Anexo enviado} many {Anexos enviados} other {Anexos enviados}}", + "upload_success": "{count, plural, one {# arquivo foi anexado.} many {# arquivos foram anexados.} other {# arquivos foram anexados.}}", + "upload_failed_title": "{count, plural, one {Anexo não enviado} many {Anexos não enviados} other {Anexos não enviados}}", + "upload_partial_failure": "{count, plural, one {# arquivo foi anexado.} many {# arquivos foram anexados.} other {# arquivos foram anexados.}} Não foi possível anexar: {files}", + "upload_failure": "Não foi possível anexar: {files}. Tente enviar novamente." }, "label": { "select": "Selecionar etiqueta", diff --git a/packages/i18n/src/locales/ro/common.json b/packages/i18n/src/locales/ro/common.json index e2b44ce87ec..812b7e11646 100644 --- a/packages/i18n/src/locales/ro/common.json +++ b/packages/i18n/src/locales/ro/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Fișierul nu a putut fi atașat. Încearcă să încarci din nou.", - "only_one_file_allowed": "Se poate încărca doar un fișier o dată.", - "file_size_limit": "Fișierul trebuie să aibă {size}MB sau mai puțin.", "drag_and_drop": "Trage și plasează oriunde pentru a încărca", - "delete": "Șterge atașamentul" + "delete": "Șterge atașamentul", + "files_too_large": "{count, plural, one {# fișier a fost omis} few {# fișiere au fost omise} other {# de fișiere au fost omise}} pentru depășirea limitei de {size} MB: {files}", + "upload_success_title": "{count, plural, one {Atașament încărcat} few {Atașamente încărcate} other {Atașamente încărcate}}", + "upload_success": "{count, plural, one {# fișier a fost atașat.} few {# fișiere au fost atașate.} other {# de fișiere au fost atașate.}}", + "upload_failed_title": "{count, plural, one {Atașament neîncărcat} few {Atașamente neîncărcate} other {Atașamente neîncărcate}}", + "upload_partial_failure": "{count, plural, one {# fișier a fost atașat.} few {# fișiere au fost atașate.} other {# de fișiere au fost atașate.}} Nu s-au putut atașa: {files}", + "upload_failure": "Nu s-au putut atașa: {files}. Încercați din nou." }, "label": { "select": "Selectează eticheta", diff --git a/packages/i18n/src/locales/ru/common.json b/packages/i18n/src/locales/ru/common.json index c2881e55f8a..3995942fe95 100644 --- a/packages/i18n/src/locales/ru/common.json +++ b/packages/i18n/src/locales/ru/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Ошибка прикрепления файла", - "only_one_file_allowed": "Можно загрузить только один файл", - "file_size_limit": "Максимальный размер файла - {size} МБ", "drag_and_drop": "Перетащите файл для загрузки", - "delete": "Удалить вложение" + "delete": "Удалить вложение", + "files_too_large": "{count, plural, one {Пропущен # файл} few {Пропущено # файла} many {Пропущено # файлов} other {Пропущено # файла}} из-за превышения лимита {size} МБ: {files}", + "upload_success_title": "{count, plural, one {Вложение загружено} few {Вложения загружены} many {Вложения загружены} other {Вложения загружены}}", + "upload_success": "{count, plural, one {Прикреплён # файл.} few {Прикреплено # файла.} many {Прикреплено # файлов.} other {Прикреплено # файла.}}", + "upload_failed_title": "{count, plural, one {Вложение не загружено} few {Вложения не загружены} many {Вложения не загружены} other {Вложения не загружены}}", + "upload_partial_failure": "{count, plural, one {Прикреплён # файл.} few {Прикреплено # файла.} many {Прикреплено # файлов.} other {Прикреплено # файла.}} Не удалось прикрепить: {files}", + "upload_failure": "Не удалось прикрепить: {files}. Попробуйте ещё раз." }, "label": { "select": "Выбрать метку", diff --git a/packages/i18n/src/locales/sk/common.json b/packages/i18n/src/locales/sk/common.json index 4fc339cece6..057047d310c 100644 --- a/packages/i18n/src/locales/sk/common.json +++ b/packages/i18n/src/locales/sk/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Súbor sa nedá pripojiť. Skúste to prosím znova.", - "only_one_file_allowed": "Je možné nahrať iba jeden súbor naraz.", - "file_size_limit": "Súbor musí byť menší ako {size}MB.", "drag_and_drop": "Pretiahnite súbor kamkoľvek pre nahratie", - "delete": "Zmazať prílohu" + "delete": "Zmazať prílohu", + "files_too_large": "Preskočené {count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}} nad limitom {size} MB: {files}", + "upload_success_title": "{count, plural, one {Príloha nahraná} few {Prílohy nahrané} many {Prílohy nahrané} other {Prílohy nahrané}}", + "upload_success": "{count, plural, one {Pridaný # súbor.} few {Pridané # súbory.} many {Pridaných # súboru.} other {Pridaných # súborov.}}", + "upload_failed_title": "{count, plural, one {Príloha nenahraná} few {Prílohy nenahrané} many {Prílohy nenahrané} other {Prílohy nenahrané}}", + "upload_partial_failure": "{count, plural, one {Pridaný # súbor.} few {Pridané # súbory.} many {Pridaných # súboru.} other {Pridaných # súborov.}} Nepodarilo sa pridať: {files}", + "upload_failure": "Nepodarilo sa pridať: {files}. Skúste to znova." }, "label": { "select": "Vybrať štítok", diff --git a/packages/i18n/src/locales/tr-TR/common.json b/packages/i18n/src/locales/tr-TR/common.json index 74bd5cbf9b3..0582bd09873 100644 --- a/packages/i18n/src/locales/tr-TR/common.json +++ b/packages/i18n/src/locales/tr-TR/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Dosya eklenemedi. Tekrar yüklemeyi deneyin.", - "only_one_file_allowed": "Aynı anda yalnızca bir dosya yüklenebilir.", - "file_size_limit": "Dosya boyutu {size}MB veya daha az olmalıdır.", "drag_and_drop": "Yüklemek için herhangi bir yere sürükleyip bırakın", - "delete": "Eki sil" + "delete": "Eki sil", + "files_too_large": "{size} MB sınırını aşan {count, plural, one {# dosya} other {# dosya}} atlandı: {files}", + "upload_success_title": "{count, plural, one {Ek yüklendi} other {Ekler yüklendi}}", + "upload_success": "{count, plural, one {# dosya eklendi.} other {# dosya eklendi.}}", + "upload_failed_title": "{count, plural, one {Ek yüklenmedi} other {Ekler yüklenmedi}}", + "upload_partial_failure": "{count, plural, one {# dosya eklendi.} other {# dosya eklendi.}} Eklenemedi: {files}", + "upload_failure": "Eklenemedi: {files}. Lütfen tekrar deneyin." }, "label": { "select": "Etiket seç", diff --git a/packages/i18n/src/locales/ua/common.json b/packages/i18n/src/locales/ua/common.json index 89bd906d8fa..4b1807a85bc 100644 --- a/packages/i18n/src/locales/ua/common.json +++ b/packages/i18n/src/locales/ua/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Не вдалося додати файл. Спробуйте ще раз.", - "only_one_file_allowed": "Можна завантажити лише один файл одночасно.", - "file_size_limit": "Файл має бути меншим за {size}МБ.", "drag_and_drop": "Перетягніть файл сюди для завантаження", - "delete": "Видалити вкладення" + "delete": "Видалити вкладення", + "files_too_large": "{count, plural, one {Пропущено # файл} few {Пропущено # файла} many {Пропущено # файлів} other {Пропущено # файла}} через перевищення ліміту {size} МБ: {files}", + "upload_success_title": "{count, plural, one {Вкладення завантажено} few {Вкладення завантажено} many {Вкладення завантажено} other {Вкладення завантажено}}", + "upload_success": "{count, plural, one {Прикріплено # файл.} few {Прикріплено # файла.} many {Прикріплено # файлів.} other {Прикріплено # файла.}}", + "upload_failed_title": "{count, plural, one {Вкладення не завантажено} few {Вкладення не завантажено} many {Вкладення не завантажено} other {Вкладення не завантажено}}", + "upload_partial_failure": "{count, plural, one {Прикріплено # файл.} few {Прикріплено # файла.} many {Прикріплено # файлів.} other {Прикріплено # файла.}} Не вдалося прикріпити: {files}", + "upload_failure": "Не вдалося прикріпити: {files}. Спробуйте ще раз." }, "label": { "select": "Вибрати мітку", diff --git a/packages/i18n/src/locales/vi-VN/common.json b/packages/i18n/src/locales/vi-VN/common.json index f9aae43c1b9..90ffa8a7529 100644 --- a/packages/i18n/src/locales/vi-VN/common.json +++ b/packages/i18n/src/locales/vi-VN/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "Không thể đính kèm tệp. Vui lòng tải lên lại.", - "only_one_file_allowed": "Chỉ có thể tải lên một tệp mỗi lần.", - "file_size_limit": "Kích thước tệp phải nhỏ hơn hoặc bằng {size}MB.", "drag_and_drop": "Kéo và thả vào bất kỳ đâu để tải lên", - "delete": "Xóa tệp đính kèm" + "delete": "Xóa tệp đính kèm", + "files_too_large": "Đã bỏ qua {count, plural, one {# tệp} other {# tệp}} vượt quá giới hạn {size} MB: {files}", + "upload_success_title": "{count, plural, one {Đã tải lên tệp đính kèm} other {Đã tải lên tệp đính kèm}}", + "upload_success": "{count, plural, one {Đã đính kèm # tệp.} other {Đã đính kèm # tệp.}}", + "upload_failed_title": "{count, plural, one {Chưa tải lên tệp đính kèm} other {Chưa tải lên tệp đính kèm}}", + "upload_partial_failure": "{count, plural, one {Đã đính kèm # tệp.} other {Đã đính kèm # tệp.}} Không thể đính kèm: {files}", + "upload_failure": "Không thể đính kèm: {files}. Vui lòng thử lại." }, "label": { "select": "Chọn nhãn", diff --git a/packages/i18n/src/locales/zh-CN/common.json b/packages/i18n/src/locales/zh-CN/common.json index dd67d925a07..8863407559b 100644 --- a/packages/i18n/src/locales/zh-CN/common.json +++ b/packages/i18n/src/locales/zh-CN/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "无法附加文件。请重新上传。", - "only_one_file_allowed": "一次只能上传一个文件。", - "file_size_limit": "文件大小必须小于或等于 {size}MB。", "drag_and_drop": "拖放到任意位置以上传", - "delete": "删除附件" + "delete": "删除附件", + "files_too_large": "已跳过超出 {size}MB 限制的 {count, plural, one {# 个文件} other {# 个文件}}:{files}", + "upload_success_title": "{count, plural, one {附件已上传} other {附件已上传}}", + "upload_success": "{count, plural, one {已附加 # 个文件。} other {已附加 # 个文件。}}", + "upload_failed_title": "{count, plural, one {附件未上传} other {附件未上传}}", + "upload_partial_failure": "{count, plural, one {已附加 # 个文件。} other {已附加 # 个文件。}}无法附加:{files}", + "upload_failure": "无法附加:{files}。请重试。" }, "label": { "select": "选择标签", diff --git a/packages/i18n/src/locales/zh-TW/common.json b/packages/i18n/src/locales/zh-TW/common.json index 834a2d922d8..9f078a9da59 100644 --- a/packages/i18n/src/locales/zh-TW/common.json +++ b/packages/i18n/src/locales/zh-TW/common.json @@ -759,10 +759,14 @@ }, "attachment": { "error": "無法附加檔案。請重新上傳。", - "only_one_file_allowed": "一次只能上傳一個檔案。", - "file_size_limit": "檔案大小必須小於或等於 {size}MB。", "drag_and_drop": "拖曳到任何位置以上傳", - "delete": "刪除附件" + "delete": "刪除附件", + "files_too_large": "已略過超出 {size}MB 限制的 {count, plural, one {# 個檔案} other {# 個檔案}}:{files}", + "upload_success_title": "{count, plural, one {附件已上傳} other {附件已上傳}}", + "upload_success": "{count, plural, one {已附加 # 個檔案。} other {已附加 # 個檔案。}}", + "upload_failed_title": "{count, plural, one {附件未上傳} other {附件未上傳}}", + "upload_partial_failure": "{count, plural, one {已附加 # 個檔案。} other {已附加 # 個檔案。}}無法附加:{files}", + "upload_failure": "無法附加:{files}。請重試。" }, "label": { "select": "選擇標籤",