177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
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: any = null;
|
|
try {
|
|
payload = await res.clone().json();
|
|
} catch {}
|
|
|
|
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?.error?.message ?? `Like failed: ${res.status}`
|
|
);
|
|
error.code = payload?.error?.code ?? 'like_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.likes_count ?? json.data?.likes_count ?? 0;
|
|
}
|
|
|
|
export async function uploadPhoto(eventToken: string, file: File, taskId?: number, emotionSlug?: string): Promise<number> {
|
|
const formData = new FormData();
|
|
formData.append('photo', file, `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
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let payload: any = null;
|
|
try {
|
|
payload = await res.clone().json();
|
|
} catch {}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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: any = null;
|
|
try {
|
|
payload = await res.clone().json();
|
|
} catch {}
|
|
|
|
const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed');
|
|
error.code = payload?.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();
|
|
}
|