platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden? Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!) hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt. geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen" der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren). Aufgabenseite verbessert (Zwischenstand)
256 lines
7.6 KiB
TypeScript
256 lines
7.6 KiB
TypeScript
// @ts-nocheck
|
|
import { getDeviceId } from '../lib/device';
|
|
|
|
export type UploadError = Error & {
|
|
code?: string;
|
|
status?: number;
|
|
meta?: Record<string, unknown>;
|
|
};
|
|
|
|
function getCsrfToken(): string | null {
|
|
// Method 1: Meta tag (preferred for SPA)
|
|
const metaToken = document.querySelector('meta[name="csrf-token"]');
|
|
if (metaToken) {
|
|
return (metaToken as HTMLMetaElement).getAttribute('content') || null;
|
|
}
|
|
|
|
// Method 2: XSRF-TOKEN cookie (Sanctum fallback)
|
|
const name = 'XSRF-TOKEN=';
|
|
const decodedCookie = decodeURIComponent(document.cookie);
|
|
const ca = decodedCookie.split(';');
|
|
for (let i = 0; i < ca.length; i++) {
|
|
const c = ca[i].trimStart();
|
|
if (c.startsWith(name)) {
|
|
const token = c.substring(name.length);
|
|
try {
|
|
// Decode base64 if needed
|
|
return decodeURIComponent(atob(token));
|
|
} catch {
|
|
return token;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.warn('No CSRF token found - API requests may fail');
|
|
return null;
|
|
}
|
|
|
|
function getCsrfHeaders(): Record<string, string> {
|
|
const token = getCsrfToken();
|
|
const headers: Record<string, string> = {
|
|
'X-Device-Id': getDeviceId(),
|
|
'Accept': 'application/json',
|
|
};
|
|
|
|
if (token) {
|
|
headers['X-CSRF-TOKEN'] = token;
|
|
headers['X-XSRF-TOKEN'] = token;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
export async function likePhoto(id: number): Promise<number> {
|
|
const headers = getCsrfHeaders();
|
|
|
|
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let payload: unknown = null;
|
|
try {
|
|
payload = await res.clone().json();
|
|
} catch (error) {
|
|
console.warn('Like photo: failed to parse error payload', error);
|
|
}
|
|
|
|
if (res.status === 419) {
|
|
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
|
error.code = 'csrf_mismatch';
|
|
error.status = res.status;
|
|
throw error;
|
|
}
|
|
|
|
const error: UploadError = new Error(
|
|
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}`
|
|
);
|
|
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed';
|
|
error.status = res.status;
|
|
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
|
if (meta) {
|
|
error.meta = meta;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const json = await res.json();
|
|
return json.likes_count ?? json.data?.likes_count ?? 0;
|
|
}
|
|
|
|
type UploadOptions = {
|
|
guestName?: string;
|
|
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, file.name || `photo-${Date.now()}.jpg`);
|
|
if (taskId) formData.append('task_id', taskId.toString());
|
|
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
|
if (options.guestName) formData.append('guest_name', options.guestName);
|
|
formData.append('device_id', getDeviceId());
|
|
|
|
const maxRetries = options.maxRetries ?? 2;
|
|
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
|
const headers = getCsrfHeaders();
|
|
|
|
const attemptUpload = (): Promise<Record<string, unknown>> =>
|
|
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) as Record<string, unknown> | null;
|
|
|
|
if (status >= 200 && status < 300) {
|
|
resolve(payload);
|
|
return;
|
|
}
|
|
|
|
const error: UploadError = new Error(
|
|
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}`
|
|
);
|
|
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
|
|
error.status = status;
|
|
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
|
if (meta) {
|
|
error.meta = meta;
|
|
}
|
|
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 {
|
|
const json = await attemptUpload();
|
|
const payload = json as { photo_id?: number; id?: number; data?: { id?: number } };
|
|
return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0;
|
|
} catch (error) {
|
|
const err = error as UploadError;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
throw new Error('Upload failed after retries');
|
|
}
|
|
|
|
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
|
|
const headers = getCsrfHeaders();
|
|
|
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let payload: unknown = null;
|
|
try {
|
|
payload = await res.clone().json();
|
|
} catch (error) {
|
|
console.warn('Share link error payload parse failed', error);
|
|
}
|
|
|
|
const errorPayload = payload as { error?: { message?: string; code?: string } } | null;
|
|
const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed');
|
|
error.code = errorPayload?.error?.code ?? 'share_failed';
|
|
error.status = res.status;
|
|
throw error;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
export async function fetchPhotoShare(slug: string) {
|
|
const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const payload = await res.json().catch(() => null);
|
|
const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable');
|
|
error.code = payload?.error?.code ?? 'share_unavailable';
|
|
error.status = res.status;
|
|
throw error;
|
|
}
|
|
|
|
return res.json();
|
|
}
|