Files
Oxicloud/frontend/src/lib/api/endpoints/photos.ts
T

117 lines
4.1 KiB
TypeScript
Raw Normal View History

2026-06-17 17:06:30 -06:00
/** Photos timeline endpoint — ported from features/library/photos.js. */
import { apiFetch } from '$lib/api/client';
2026-06-17 22:07:18 -06:00
import { getCsrfHeaders } from '$lib/api/csrf';
2026-06-17 17:06:30 -06:00
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;
}
2026-06-17 17:06:30 -06:00
export interface PhotoPage {
items: PhotoItem[];
2026-06-17 17:06:30 -06:00
nextCursor: string | null;
}
2026-06-17 22:07:18 -06:00
/** 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<GeoCluster[]> {
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[];
}
2026-06-17 22:07:18 -06:00
/** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */
const BATCH_CHUNK_SIZE = 1000;
2026-06-17 17:06:30 -06:00
/**
* 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<PhotoPage> {
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[];
2026-06-17 17:06:30 -06:00
const cursor = res.headers.get('X-Next-Cursor');
return {
items: items ?? [],
nextCursor: cursor && items && items.length >= limit ? cursor : null
};
}
2026-06-17 22:07:18 -06:00
/** Fetch EXIF metadata for a file. Returns `null` on any error (non-critical). */
export async function fetchFileMetadata(fileId: string): Promise<FileMetadata | null> {
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<Set<string>> {
const trashed = new Set<string>();
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<BatchTrashResult>;
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
for (const id of ok) trashed.add(id);
}
return trashed;
}