/** Photos timeline endpoint — ported from features/library/photos.js. */ import { apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { FileItem } from '$lib/api/types'; /** * A timeline photo/video. Extends {@link FileItem} with the pixel dimensions the * list endpoint returns, used by the justified (aspect-preserving) grid layout. */ export interface PhotoItem extends FileItem { width?: number; height?: number; } export interface PhotoPage { items: PhotoItem[]; nextCursor: string | null; } /** EXIF metadata returned by `/api/files/{id}/metadata` (subset used by the lightbox). */ export interface FileMetadata { file_id: string; captured_at?: number; latitude?: number | null; longitude?: number | null; camera_make?: string | null; camera_model?: string | null; orientation?: number | null; width?: number | null; height?: number | null; } /** Result of a batch trash request (200 = all, 206 = partial success). */ export interface BatchTrashResult { successful: string[]; failed: string[]; } /** One server-side photo cluster for the Places map (`GET /api/photos/geo`). */ export interface GeoCluster { lng: number; lat: number; count: number; sample_file_id: string; } /** * Fetch geotagged-photo clusters for a viewport. The backend aggregates * server-side on a grid keyed by zoom, so the client draws one lightweight * marker per cluster — no client-side clustering needed. `bbox` is * `"west,south,east,north"` in decimal degrees. Available only when the * Places feature is enabled (otherwise the route 404s). */ export async function fetchPhotosGeo(bbox: string, zoom: number): Promise { const res = await apiFetch(`/api/photos/geo?bbox=${encodeURIComponent(bbox)}&zoom=${zoom}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`photos geo failed: ${res.status}`); return (await res.json()) as GeoCluster[]; } /** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */ const BATCH_CHUNK_SIZE = 1000; /** * Fetch one page of the photo timeline. The next-page cursor is returned in the * `X-Next-Cursor` response header; the page is the last one when fewer than * `limit` items come back. */ export async function fetchPhotos(limit = 60, before?: string | null): Promise { let url = `/api/photos?limit=${limit}`; if (before) url += `&before=${encodeURIComponent(before)}`; const res = await apiFetch(url, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`photos failed: ${res.status}`); const items = (await res.json()) as PhotoItem[]; const cursor = res.headers.get('X-Next-Cursor'); return { items: items ?? [], nextCursor: cursor && items && items.length >= limit ? cursor : null }; } /** Fetch EXIF metadata for a file. Returns `null` on any error (non-critical). */ export async function fetchFileMetadata(fileId: string): Promise { try { const res = await apiFetch(`/api/files/${fileId}/metadata`, { credentials: 'same-origin' }); if (!res.ok) return null; return (await res.json()) as FileMetadata; } catch { return null; } } /** * Move files to trash in batches via `POST /api/batch/trash`. One request per * chunk (up to {@link BATCH_CHUNK_SIZE} ids); 200 = all trashed, 206 = partial. * Returns the set of ids that were actually trashed across all chunks. */ export async function batchTrash(fileIds: string[]): Promise> { const trashed = new Set(); for (let i = 0; i < fileIds.length; i += BATCH_CHUNK_SIZE) { const chunk = fileIds.slice(i, i + BATCH_CHUNK_SIZE); const res = await apiFetch('/api/batch/trash', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, body: JSON.stringify({ file_ids: chunk, folder_ids: [] }) }); // 200 = all trashed, 206 = partial; both carry `successful`. if (!res.ok && res.status !== 206) continue; const data = (await res.json().catch(() => ({}))) as Partial; const ok = Array.isArray(data?.successful) ? data.successful : chunk; for (const id of ok) trashed.add(id); } return trashed; }