Änderungen:
- resources/js/guest/services/galleryApi.ts: Locale-Felder (event.name, event.description) werden nach dem Fetch per coerceLocalized
in Strings überführt.
- resources/js/guest/services/photosApi.ts: fetchPhotoShare normalisiert event.name auf einen String mit de/en-Fallback; Fehler bei
fehlendem Share unverändert.
286 lines
8.4 KiB
TypeScript
286 lines
8.4 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;
|
|
}
|
|
|
|
const payload = await res.json();
|
|
|
|
const normalize = (value: unknown, fallback: string): string => {
|
|
if (typeof value === 'string' && value.trim() !== '') {
|
|
return value;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const obj = value as Record<string, unknown>;
|
|
const preferred = ['de', 'en'];
|
|
for (const key of preferred) {
|
|
const candidate = obj[key];
|
|
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
|
return candidate;
|
|
}
|
|
}
|
|
const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== '');
|
|
if (typeof firstString === 'string') {
|
|
return firstString;
|
|
}
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
if (payload?.event) {
|
|
payload.event = {
|
|
...payload.event,
|
|
name: normalize(payload.event?.name, 'Fotospiel Event'),
|
|
};
|
|
}
|
|
|
|
return payload;
|
|
}
|