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';
|
|
|
|
|
|
2026-06-19 12:59:16 +00:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-06-19 12:59:16 +00:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:59:16 +00:00
|
|
|
/** 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}`);
|
2026-06-19 12:59:16 +00:00
|
|
|
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;
|
|
|
|
|
}
|