Merge pull request #708 from andredlng/feat/upload-failure-details

Surface which files failed during photo upload
This commit is contained in:
Paul Nothaft
2026-07-01 09:29:19 +02:00
committed by GitHub
6 changed files with 404 additions and 20 deletions
+170 -15
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useMemo, useEffect } from 'react'; import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Upload, X, Image, Loader2, Cog } from 'lucide-react'; import { Upload, X, Image, Loader2, Cog, AlertTriangle } from 'lucide-react';
import { Button } from '../common'; import { Button } from '../common';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { api } from '../../config/api'; import { api } from '../../config/api';
@@ -13,7 +13,14 @@ import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps { interface PhotoUploadProps {
eventId: number; eventId: number;
/** Refresh the photo grid. Called early (as bytes land) and again when
* processing finishes. Never closes the modal. */
onUploadComplete?: () => void; onUploadComplete?: () => void;
/** Fired once the transfer stage is done, reporting whether any file
* failed. The host (modal) uses this to decide whether to auto-close:
* a clean upload closes as before; a partial failure keeps the modal
* open so the failure report stays visible. */
onUploadSettled?: (result: { hasFailures: boolean }) => void;
} }
const DEFAULT_MAX_FILES_PER_UPLOAD = 500; const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
@@ -28,7 +35,22 @@ type UploadPhase =
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number } | { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number }; | { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => { // Why a file didn't make it into the gallery. Each maps to a distinct
// stage so the user knows whether to re-pick the file (rejected), retry
// the network (transfer), or check the source image (processing).
// - rejected: validation/queueing refused it (bad type, too large,
// corrupt) — returned per-file in the upload response.
// - transfer: the whole chunk request failed (timeout, 5xx, network).
// - processing: stored fine, but the background worker couldn't process
// it (from useUploadProgress's failedPhotos).
type UploadFailureKind = 'rejected' | 'transfer' | 'processing';
interface UploadFailure {
filename: string;
reason: string;
kind: UploadFailureKind;
}
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete, onUploadSettled }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -42,12 +64,30 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
// hook merges status across all of them so the user sees one unified // hook merges status across all of them so the user sees one unified
// progress count even when the upload spans multiple HTTP requests. // progress count even when the upload spans multiple HTTP requests.
const [uploadIds, setUploadIds] = useState<string[]>([]); const [uploadIds, setUploadIds] = useState<string[]>([]);
// Per-file failures surfaced from the transfer stage (chunk POSTs):
// validation rejections (response.errors) and whole-chunk failures.
// Processing failures are merged in from the progress hook below.
const [transferFailures, setTransferFailures] = useState<UploadFailure[]>([]);
// Processing failures are captured into state (not read live) because the
// completion effect clears uploadIds, which empties the progress hook's
// failedPhotos — reading live would make the rows vanish the instant they
// appear.
const [processingFailures, setProcessingFailures] = useState<UploadFailure[]>([]);
const [failuresDismissed, setFailuresDismissed] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, { const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
enabled: phase.kind === 'processing' && uploadIds.length > 0, enabled: phase.kind === 'processing' && uploadIds.length > 0,
}); });
// Single source of truth for the "which files failed" report: transfer
// stage failures (collected during handleUpload) plus processing failures
// (captured on completion). Both carry filename + reason.
const failures = useMemo<UploadFailure[]>(
() => [...transferFailures, ...processingFailures],
[transferFailures, processingFailures]
);
// Fetch categories for this event // Fetch categories for this event
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId], queryKey: ['event-categories', eventId],
@@ -162,6 +202,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setIsUploading(true); setIsUploading(true);
setUploadProgress(0); setUploadProgress(0);
setUploadIds([]); setUploadIds([]);
// Clear any prior failure report before this run.
setTransferFailures([]);
setProcessingFailures([]);
setFailuresDismissed(false);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues. // For large uploads, chunk the files by both count AND size to prevent memory/network issues.
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other // #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
@@ -195,9 +239,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} }
setTotalChunks(chunks.length); setTotalChunks(chunks.length);
let totalUploaded = 0;
let totalReplaced = 0; let totalReplaced = 0;
let failedFiles = []; // Accumulates transfer-stage failures (per-file rejections + whole-chunk
// failures) with their reasons, so the report can name each one.
const collected: UploadFailure[] = [];
// Whether at least one chunk was accepted for background processing.
let anyQueued = false;
try { try {
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
@@ -258,17 +305,39 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
}, },
}); });
totalUploaded += (response.data?.successCount || chunk.length);
totalReplaced += (response.data?.replacedCount || 0); totalReplaced += (response.data?.replacedCount || 0);
// Backend returns a per-request upload_id. Track it so the // The backend accepts the request (202) but may reject individual
// processing-status hook can poll/stream live progress. // files (bad type, too large, corrupt) and reports them in
if (response.data?.upload_id) { // `errors: [{ filename, error }]`. Surface each one by name.
const rejected = response.data?.errors;
if (Array.isArray(rejected)) {
for (const r of rejected) {
collected.push({
filename: r?.filename || t('upload.failures.unknownFile', 'Unknown file'),
reason: r?.error || t('upload.failures.unknownReason', 'Unknown error'),
kind: 'rejected',
});
}
}
// Track the per-request upload_id so the processing hook can poll
// for live progress — but only when photos were actually queued
// (count > 0). The backend returns an upload_id even when every
// file was rejected (count 0); tracking it there would make us wait
// for a processing phase that never starts, hanging the spinner.
if (response.data?.upload_id && (response.data?.count ?? 0) > 0) {
anyQueued = true;
const newId = response.data.upload_id as string; const newId = response.data.upload_id as string;
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId])); setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error); console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name)); const reason =
error?.response?.data?.error ||
error?.message ||
t('upload.failures.transferReason', 'Transfer failed');
collected.push(
...chunk.map((f) => ({ filename: f.name, reason, kind: 'transfer' as const }))
);
// Continue with next chunk even if one fails // Continue with next chunk even if one fails
continue; continue;
@@ -288,10 +357,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
if (totalReplaced > 0) { if (totalReplaced > 0) {
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`); toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
} }
if (failedFiles.length > 0) { // Publish transfer-stage failures to the report (rendered below with
// each filename + reason). The toast is just the headline; the list
// is where the user finds out *which* files failed.
setTransferFailures(collected);
if (collected.length > 0) {
toast.warning( toast.warning(
t('upload.someFilesFailed') || t('upload.failures.toast', '{{count}} file(s) could not be uploaded — see the list below.', {
`Transferred ${totalUploaded} files. ${failedFiles.length} files failed to transfer.` count: collected.length,
})
); );
} }
@@ -302,9 +376,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
onUploadComplete(); onUploadComplete();
} }
// If the backend never returned an upload_id (e.g. only failures // Settling (and the modal's close decision) is deferred until we know
// or pre-async-backend deployment), we have nothing to wait for — // the WHOLE outcome, including background processing. If nothing was
// fall through to the finally cleanup which resets state. // queued (every file rejected, or a pre-async backend), the transfer
// stage is already terminal — settle now and reset the UI. Otherwise
// the processing effect below settles once the worker finishes, so
// processing failures are included before the modal decides to close.
if (!anyQueued) {
onUploadSettled?.({ hasFailures: collected.length > 0 });
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
}
} catch (error: any) { } catch (error: any) {
console.error('Upload error:', error); console.error('Upload error:', error);
toast.error(error.response?.data?.error || t('toast.uploadError')); toast.error(error.response?.data?.error || t('toast.uploadError'));
@@ -325,6 +410,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
if (!processingAggregate.isComplete) return; if (!processingAggregate.isComplete) return;
if (processingAggregate.failed > 0) { if (processingAggregate.failed > 0) {
// Persist the failed photos into the report before uploadIds is cleared
// below (which would otherwise empty the progress hook's failedPhotos).
setProcessingFailures(
processingAggregate.failedPhotos.map((p) => ({
filename: p.filename,
reason: p.error || t('upload.failures.unknownReason', 'Unknown error'),
kind: 'processing' as const,
}))
);
toast.warning( toast.warning(
t('upload.processingFailed', { count: processingAggregate.failed }) || t('upload.processingFailed', { count: processingAggregate.failed }) ||
`${processingAggregate.failed} photo(s) failed to process` `${processingAggregate.failed} photo(s) failed to process`
@@ -336,6 +430,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} }
if (onUploadComplete) onUploadComplete(); if (onUploadComplete) onUploadComplete();
// Now the whole outcome is known — settle. The modal auto-closes only
// when nothing failed at either stage; any transfer OR processing
// failure keeps it open so the report (which lists both) stays visible.
onUploadSettled?.({
hasFailures: transferFailures.length > 0 || processingAggregate.failed > 0,
});
setIsUploading(false); setIsUploading(false);
setUploadProgress(0); setUploadProgress(0);
setCurrentChunk(0); setCurrentChunk(0);
@@ -487,6 +587,61 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</Button> </Button>
</div> </div>
{/* Failure report — names every file that didn't make it into the
gallery, grouped by failure stage, so the user can act on each.
Persists until dismissed or a new upload starts. */}
{!failuresDismissed && failures.length > 0 && (
<div
data-testid="upload-failure-report"
role="status"
aria-live="polite"
className="rounded-lg border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<p className="text-sm font-medium">
{t('upload.failures.title', '{{count}} file(s) could not be uploaded', {
count: failures.length,
})}
</p>
</div>
<button
type="button"
onClick={() => setFailuresDismissed(true)}
aria-label={t('common.dismiss', 'Dismiss')}
className="p-1 -m-1 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/40 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<ul className="mt-3 max-h-48 overflow-y-auto space-y-1.5">
{failures.map((f, i) => (
<li key={`${f.kind}-${f.filename}-${i}`} className="flex items-start gap-2 text-xs">
<span
className={clsx(
'flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded font-medium whitespace-nowrap',
f.kind === 'rejected' && 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
f.kind === 'transfer' && 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
f.kind === 'processing' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
)}
>
{f.kind === 'rejected' && t('upload.failures.kindRejected', 'Rejected')}
{f.kind === 'transfer' && t('upload.failures.kindTransfer', 'Transfer failed')}
{f.kind === 'processing' && t('upload.failures.kindProcessing', 'Processing failed')}
</span>
<span className="min-w-0">
<span className="font-medium text-neutral-800 dark:text-neutral-200 break-all">
{f.filename}
</span>
<span className="text-neutral-500 dark:text-neutral-400"> {f.reason}</span>
</span>
</li>
))}
</ul>
</div>
)}
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring') {/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
drives the determinate bar; the post-bytes wait ('processing') swaps drives the determinate bar; the post-bytes wait ('processing') swaps
in an indeterminate spinner with an explanatory hint so users don't in an indeterminate spinner with an explanatory hint so users don't
@@ -21,11 +21,19 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
// Refresh the host's grid, but do NOT close here — closing is decided by
// onUploadSettled so a partial-failure upload keeps the modal (and its
// failure report) open.
const handleUploadComplete = () => { const handleUploadComplete = () => {
if (onUploadComplete) { onUploadComplete?.();
onUploadComplete(); };
}
// Auto-close only on a clean upload; keep the modal open when some files
// failed so the report stays visible until the user dismisses it.
const handleUploadSettled = ({ hasFailures }: { hasFailures: boolean }) => {
if (!hasFailures) {
onClose(); onClose();
}
}; };
return ( return (
@@ -49,6 +57,7 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
<PhotoUpload <PhotoUpload
eventId={eventId} eventId={eventId}
onUploadComplete={handleUploadComplete} onUploadComplete={handleUploadComplete}
onUploadSettled={handleUploadSettled}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,152 @@
/**
* Coverage for the upload failure report — the "which files failed" list.
*
* Before this, a partial upload only showed a count ("some files failed"),
* and the backend's per-file `errors[]` were dropped entirely. These tests
* pin that every failure stage is named with its reason:
* - rejected: from the upload response's `errors: [{filename, error}]`
* - transfer: from a whole-chunk POST failure (the `catch` path)
* - processing: from useUploadProgress's `failedPhotos`
* plus the settle contract (hasFailures true/false) the modal relies on to
* decide whether to auto-close, and dismissal.
*/
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { PhotoUpload } from '../PhotoUpload';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key),
}),
};
});
vi.mock('react-toastify', () => ({
toast: { warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const postMock = vi.fn();
vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a), get: vi.fn() } }));
// Mutable processing aggregate, swapped per test. Returned only once photos
// are queued (uploadIds non-empty), mirroring the real hook flipping from
// "nothing to track" to "complete" — the transition that fires the settle
// effect.
const clean = () => ({
total: 0, pending: 0, processing: 0, complete: 0, failed: 0,
failedPhotos: [] as { id: number; filename: string; error: string | null }[],
isComplete: false, isReady: true,
});
const hoisted = vi.hoisted(() => ({ aggregate: null as any }));
vi.mock('../../../hooks/useUploadProgress', () => ({
useUploadProgress: (ids: string[]) => ({
snapshots: {},
error: null,
aggregate: ids && ids.length > 0
? hoisted.aggregate
: { total: 0, pending: 0, processing: 0, complete: 0, failed: 0, failedPhotos: [], isComplete: false, isReady: true },
}),
}));
vi.mock('../../../services/categories.service', () => ({
categoriesService: { getEventCategories: vi.fn().mockResolvedValue([]) },
}));
vi.mock('../../../services/settings.service', () => ({
settingsService: { getAllSettings: vi.fn().mockResolvedValue({}) },
}));
const renderWithClient = (ui: ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
async function uploadFile(container: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, new File([new Uint8Array([1, 2, 3])], 'good-photo.png', { type: 'image/png' }));
await user.click(screen.getByRole('button', { name: /common\.upload/ }));
}
describe('PhotoUpload failure report', () => {
beforeEach(() => {
postMock.mockReset();
hoisted.aggregate = clean();
});
afterEach(() => vi.clearAllMocks());
it('names rejected + processing failures and settles with hasFailures', async () => {
postMock.mockResolvedValue({
data: { successCount: 1, count: 1, upload_id: 'u1', errors: [{ filename: 'too-big.png', error: 'File too large' }] },
});
hoisted.aggregate = {
total: 2, pending: 0, processing: 0, complete: 1, failed: 1,
failedPhotos: [{ id: 5, filename: 'corrupt.jpg', error: 'Unsupported format' }],
isComplete: true, isReady: true,
};
const onUploadSettled = vi.fn();
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
expect(within(report).getByText('too-big.png')).toBeInTheDocument();
expect(within(report).getByText(/File too large/)).toBeInTheDocument();
expect(within(report).getByText('Rejected')).toBeInTheDocument();
expect(within(report).getByText('corrupt.jpg')).toBeInTheDocument();
expect(within(report).getByText('Processing failed')).toBeInTheDocument();
// Contract with the modal: the real component fires onUploadSettled and,
// because something failed, asks the host NOT to auto-close.
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: true }));
});
it('reports a whole-chunk transfer failure by name', async () => {
postMock.mockRejectedValue({ response: { data: { error: 'Network error' } } });
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
expect(within(report).getByText('good-photo.png')).toBeInTheDocument();
expect(within(report).getByText(/Network error/)).toBeInTheDocument();
expect(within(report).getByText('Transfer failed')).toBeInTheDocument();
});
it('settles clean when nothing fails, so the modal can auto-close', async () => {
postMock.mockResolvedValue({ data: { successCount: 1, count: 1, upload_id: 'u1', errors: [] } });
hoisted.aggregate = {
total: 1, pending: 0, processing: 0, complete: 1, failed: 0,
failedPhotos: [], isComplete: true, isReady: true,
};
const onUploadSettled = vi.fn();
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
await uploadFile(container, user);
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: false }));
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
});
it('can be dismissed', async () => {
postMock.mockResolvedValue({
data: { successCount: 0, count: 0, errors: [{ filename: 'too-big.png', error: 'File too large' }] },
});
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
await user.click(within(report).getByRole('button', { name: /Dismiss/i }));
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,48 @@
/**
* The upload modal must keep itself open when an upload partially fails, so
* the failure report stays visible; a clean upload still auto-closes. We stub
* PhotoUpload with buttons that fire its onUploadSettled callback both ways.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { PhotoUploadModal } from '../PhotoUploadModal';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({ t: (_k: string, fb?: any) => (typeof fb === 'string' ? fb : _k) }),
};
});
// Stub PhotoUpload: expose buttons that settle clean vs. with failures.
vi.mock('../PhotoUpload', () => ({
PhotoUpload: ({ onUploadSettled }: any) => (
<div>
<button onClick={() => onUploadSettled?.({ hasFailures: false })}>settle-clean</button>
<button onClick={() => onUploadSettled?.({ hasFailures: true })}>settle-failed</button>
</div>
),
}));
describe('PhotoUploadModal auto-close behaviour', () => {
it('closes after a clean upload', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
await user.click(screen.getByText('settle-clean'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open when some files failed', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
await user.click(screen.getByText('settle-failed'));
expect(onClose).not.toHaveBeenCalled();
});
});
+10
View File
@@ -169,6 +169,16 @@
"retryFailed": "Fehlgeschlagene erneut versuchen", "retryFailed": "Fehlgeschlagene erneut versuchen",
"uploadComplete": "Upload abgeschlossen!", "uploadComplete": "Upload abgeschlossen!",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"failures": {
"title": "{{count}} Datei(en) konnten nicht hochgeladen werden",
"toast": "{{count}} Datei(en) konnten nicht hochgeladen werden siehe Liste unten.",
"kindRejected": "Abgelehnt",
"kindTransfer": "Übertragung fehlgeschlagen",
"kindProcessing": "Verarbeitung fehlgeschlagen",
"transferReason": "Übertragung fehlgeschlagen",
"unknownReason": "Unbekannter Fehler",
"unknownFile": "Unbekannte Datei"
},
"replaceByName": "Vorhandene Fotos mit gleichem Namen ersetzen", "replaceByName": "Vorhandene Fotos mit gleichem Namen ersetzen",
"uploadPhotos": "Fotos hochladen", "uploadPhotos": "Fotos hochladen",
"uploadMedia": "Fotos & Videos hochladen", "uploadMedia": "Fotos & Videos hochladen",
+10
View File
@@ -169,6 +169,16 @@
"retryFailed": "Retry failed", "retryFailed": "Retry failed",
"uploadComplete": "Upload complete!", "uploadComplete": "Upload complete!",
"someFilesFailed": "Some files failed to upload", "someFilesFailed": "Some files failed to upload",
"failures": {
"title": "{{count}} file(s) could not be uploaded",
"toast": "{{count}} file(s) could not be uploaded — see the list below.",
"kindRejected": "Rejected",
"kindTransfer": "Transfer failed",
"kindProcessing": "Processing failed",
"transferReason": "Transfer failed",
"unknownReason": "Unknown error",
"unknownFile": "Unknown file"
},
"replaceByName": "Replace existing photos with same name", "replaceByName": "Replace existing photos with same name",
"uploadPhotos": "Upload Photos", "uploadPhotos": "Upload Photos",
"uploadMedia": "Upload Photos & Videos", "uploadMedia": "Upload Photos & Videos",