legal documents improved, gäste-pwa uploads optimiert: client-side compression/resize.
This commit is contained in:
@@ -32,6 +32,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -201,6 +202,8 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
let active = true;
|
||||
|
||||
async function loadTask() {
|
||||
if (taskId === null) return;
|
||||
|
||||
const currentTaskId = taskId;
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
|
||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||
@@ -553,19 +556,57 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
const handleUsePhoto = useCallback(async () => {
|
||||
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadProgress(2);
|
||||
setUploadError(null);
|
||||
setUploadWarning(null);
|
||||
setStatusMessage(t('upload.status.preparing'));
|
||||
|
||||
if (uploadProgressTimerRef.current) {
|
||||
window.clearInterval(uploadProgressTimerRef.current);
|
||||
uploadProgressTimerRef.current = null;
|
||||
}
|
||||
uploadProgressTimerRef.current = window.setInterval(() => {
|
||||
setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev));
|
||||
}, 400);
|
||||
|
||||
const maxEdge = 2400;
|
||||
const targetBytes = 4_000_000;
|
||||
let fileForUpload = reviewPhoto.file;
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
|
||||
setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
|
||||
const optimized = await compressPhoto(reviewPhoto.file, {
|
||||
maxEdge,
|
||||
targetBytes,
|
||||
qualityStart: 0.82,
|
||||
});
|
||||
|
||||
fileForUpload = optimized;
|
||||
setUploadProgress(10);
|
||||
|
||||
if (optimized.size < reviewPhoto.file.size - 50_000) {
|
||||
const saved = formatBytes(reviewPhoto.file.size - optimized.size);
|
||||
setUploadWarning(
|
||||
t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
|
||||
.replace('{saved}', saved)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Image optimization failed, uploading original', e);
|
||||
setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.'));
|
||||
}
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventKey, fileForUpload, task.id, emotionSlug || undefined, {
|
||||
maxRetries: 2,
|
||||
onProgress: (percent) => {
|
||||
setUploadProgress(Math.max(15, Math.min(98, percent)));
|
||||
setStatusMessage(t('upload.status.uploading'));
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
setUploadWarning(
|
||||
t('upload.retrying', 'Verbindung holperig – neuer Versuch ({attempt}).')
|
||||
.replace('{attempt}', `${attempt}`)
|
||||
);
|
||||
},
|
||||
});
|
||||
setUploadProgress(100);
|
||||
setStatusMessage(t('upload.status.completed'));
|
||||
markCompleted(task.id);
|
||||
@@ -580,6 +621,12 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
setErrorDialog(dialog);
|
||||
setUploadError(dialog.description);
|
||||
|
||||
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
|
||||
setUploadWarning(
|
||||
t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.')
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
uploadErr.code === 'photo_limit_exceeded'
|
||||
|| uploadErr.code === 'upload_device_limit'
|
||||
@@ -592,10 +639,6 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
setMode('review');
|
||||
} finally {
|
||||
if (uploadProgressTimerRef.current) {
|
||||
window.clearInterval(uploadProgressTimerRef.current);
|
||||
uploadProgressTimerRef.current = null;
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);
|
||||
|
||||
@@ -90,49 +90,117 @@ export async function likePhoto(id: number): Promise<number> {
|
||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||
}
|
||||
|
||||
export async function uploadPhoto(eventToken: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
|
||||
type UploadOptions = {
|
||||
onProgress?: (percent: number) => void;
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
onRetry?: (attempt: number) => void;
|
||||
};
|
||||
|
||||
export async function uploadPhoto(
|
||||
eventToken: string,
|
||||
file: File,
|
||||
taskId?: number,
|
||||
emotionSlug?: string,
|
||||
options: UploadOptions = {}
|
||||
): Promise<number> {
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file, `photo-${Date.now()}.jpg`);
|
||||
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
|
||||
if (taskId) formData.append('task_id', taskId.toString());
|
||||
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||
formData.append('device_id', getDeviceId());
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
// Don't set Content-Type for FormData - let browser handle it with boundary
|
||||
});
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||
const headers = getCsrfHeaders();
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: any = null;
|
||||
const attemptUpload = (attempt: number): Promise<any> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = 'json';
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
const onAbort = () => xhr.abort();
|
||||
options.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && options.onProgress) {
|
||||
const percent = Math.min(99, Math.round((event.loaded / event.total) * 100));
|
||||
options.onProgress(percent);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const status = xhr.status;
|
||||
const payload = xhr.response ?? null;
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
resolve(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
payload?.error?.message ?? `Upload failed: ${status}`
|
||||
);
|
||||
error.code = payload?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
|
||||
error.status = status;
|
||||
if (payload?.error?.meta) {
|
||||
error.meta = payload.error.meta as Record<string, unknown>;
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
const error: UploadError = new Error('Network error during upload');
|
||||
error.code = 'network_error';
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
const error: UploadError = new Error('Upload timed out');
|
||||
error.code = 'timeout';
|
||||
reject(error);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch {}
|
||||
const json = await attemptUpload(attempt + 1);
|
||||
return json?.photo_id ?? json?.id ?? json?.data?.id ?? 0;
|
||||
} catch (error) {
|
||||
const err = error as UploadError;
|
||||
|
||||
if (res.status === 419) {
|
||||
const csrfError: UploadError = new Error(
|
||||
'CSRF token mismatch during upload. Please refresh the page and try again.'
|
||||
);
|
||||
csrfError.code = 'csrf_mismatch';
|
||||
csrfError.status = res.status;
|
||||
throw csrfError;
|
||||
if (attempt < maxRetries && (err.code === 'network_error' || (err.status ?? 0) >= 500)) {
|
||||
options.onRetry?.(attempt + 1);
|
||||
const delay = 300 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map CSRF mismatch specifically for caller handling
|
||||
if ((err.status ?? 0) === 419) {
|
||||
err.code = 'csrf_mismatch';
|
||||
}
|
||||
|
||||
// Flag common validation failure for file size/validation
|
||||
if ((err.status ?? 0) === 422 && !err.code) {
|
||||
err.code = 'validation_error';
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
payload?.error?.message ?? `Upload failed: ${res.status}`
|
||||
);
|
||||
error.code = payload?.error?.code ?? 'upload_failed';
|
||||
error.status = res.status;
|
||||
if (payload?.error?.meta) {
|
||||
error.meta = payload.error.meta as Record<string, unknown>;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
|
||||
throw new Error('Upload failed after retries');
|
||||
}
|
||||
|
||||
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
||||
|
||||
Reference in New Issue
Block a user