import axios from 'axios' // Relative API base. In production the nginx in front of the SPA proxies // /api/ to the backend container; in dev the vite server has the same // proxy in vite.config.ts. Using a relative URL means requests are // always same-origin, so the app works whether you hit it from // localhost, a LAN IP, or a reverse proxy without any CORS dance. const API_BASE_URL = '/api/v1' const api = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, }) // ── Auth interceptors ────────────────────────────────────────────────── // Attach the stored JWT to every outgoing request. api.interceptors.request.use((config) => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // On 401 responses, attempt one silent token refresh. If that also // fails, clear stored credentials so the AuthContext falls back to the // login screen on its next render. let isRefreshing = false let refreshSubscribers: ((token: string) => void)[] = [] api.interceptors.response.use( (response) => response, async (error) => { const original = error.config if (error.response?.status !== 401 || original._retry) { return Promise.reject(error) } // Skip retry for auth endpoints themselves to avoid loops. if (original.url?.startsWith('/auth/')) { return Promise.reject(error) } original._retry = true if (!isRefreshing) { isRefreshing = true // The refresh token lives in AuthContext memory, not in // localStorage. The interceptor can't access it directly, so we // rely on the AuthContext's scheduled refresh to keep the access // token fresh. If the access token is truly expired and no // refresh has happened, we just force a logout. localStorage.removeItem('access_token') isRefreshing = false // Reject — AuthContext will detect the missing token and show login. return Promise.reject(error) } // Another request is already refreshing — queue this one. return new Promise((resolve) => { refreshSubscribers.push((token: string) => { original.headers.Authorization = `Bearer ${token}` resolve(api(original)) }) }) }, ) // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in // .env → bootstrap on backend startup), so the UI only reads them and // optionally renames the display label. export interface FolderTreeNode { id: string name: string path: string photo_count: number /** User-set "hide from cross-cutting views" flag. When true, photos * in this folder (and descendants) are excluded from All Photos, * Map, Tags, People, Search and sidebar counts, but remain visible * when the user navigates directly into the folder. */ is_hidden: boolean children: FolderTreeNode[] } export const sourceFolders = { list: async () => { const response = await api.get('/folders') return response.data }, /** Recursive folder tree, one root per active source root. */ tree: async (): Promise => { const response = await api.get('/folders/tree') return response.data }, scan: async (folderId: string) => { const response = await api.post(`/folders/${folderId}/scan`) return response.data }, /** Rename a folder. SourceRoot ids only update the display label; * Folder ids actually move the directory on disk and update every * descendant photo's filepath. */ rename: async (folderId: string, name: string) => { const response = await api.patch(`/folders/${folderId}`, { name }) return response.data }, /** Create a new sub-folder under an existing Folder. parent_id MUST * be a Folder row id (not a SourceRoot id). */ create: async (parentId: string, name: string) => { const response = await api.post('/folders', { name, parent_id: parentId, }) return response.data as { id: string; name: string; path: string; parent_id: string } }, /** Toggle "hide from views" on a folder or source root. Hidden * folders still index + thumbnail their photos, but those photos * are excluded from cross-cutting views (All Photos, Map, Tags, * People, Search, sidebar counts). Navigating directly into the * folder still shows them. Propagates to every descendant folder. */ setHidden: async (folderId: string, hidden: boolean) => { const response = await api.post(`/folders/${folderId}/hide`, { hidden, }) return response.data as { id: string name: string path: string is_hidden: boolean } }, /** Delete a folder. mode=discard moves all photos under it to the * discard pile (recoverable) and leaves the folder + on-disk dir * alone. mode=permanent unlinks files, removes folder rows, and * rmtrees the directory — irreversible. */ delete: async (folderId: string, mode: 'discard' | 'permanent') => { const response = await api.delete(`/folders/${folderId}`, { params: { mode }, }) return response.data as { status: string mode: string discarded?: number deleted_photos?: number file_errors?: number } }, } // Photos API export const photos = { list: async (params?: { limit?: number offset?: number folder_id?: string heap_id?: string rating?: number flag?: string }) => { const response = await api.get('/photos', { params }) return response.data }, get: async (photoId: string) => { const response = await api.get(`/photos/${photoId}`) return response.data }, /** Lightweight list of every photo with GPS coordinates, used by the * Map view. Returns one tiny object per photo (id, lat, lon, taken_at) * rather than the full photo payload — keeps responses small even on * big libraries. */ mapPoints: async () => { const response = await api.get('/photos/map') return response.data as Array<{ id: string latitude: number longitude: number taken_at: string | null }> }, update: async (photoId: string, data: { filename?: string rating?: number user_title?: string | null user_notes?: string | null color_label?: string | null is_picked?: boolean is_discarded?: boolean taken_at?: string }) => { const response = await api.patch(`/photos/${photoId}`, data) return response.data }, /** Bulk discard — matches the backend BulkAction schema. */ bulkDiscard: async (photoIds: string[]) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'discard', }) return response.data }, /** Bulk restore from discarded. */ bulkRestore: async (photoIds: string[]) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'restore', }) return response.data }, /** Bulk set rating (0-5). */ bulkSetRating: async (photoIds: string[], rating: number) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'set_rating', value: rating, }) return response.data }, /** Bulk set color label (or null to clear). */ bulkSetColor: async (photoIds: string[], color: string | null) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'set_color', value: color, }) return response.data }, /** Bulk set taken_at — one ISO datetime applied to every listed photo. * Backend rewrites EXIF on disk per-photo and surfaces per-photo errors * in the `errors` array so the UI can report a partial apply. */ bulkSetTakenAt: async (photoIds: string[], isoDatetime: string) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'set_taken_at', value: isoDatetime, }) return response.data as { status: string updated: number skipped: number errors: { id: string; message: string }[] } }, /** Bulk set taken_at with a per-photo map. Used by the "guess from folder" * flow where every selected photo gets its own suggested date. */ bulkSetTakenAtMap: async (map: Record) => { const response = await api.post('/photos/bulk', { ids: Object.keys(map), action: 'set_taken_at_map', value: map, }) return response.data as { status: string updated: number skipped: number errors: { id: string; message: string }[] } }, /** Add the listed tags to every listed photo. Idempotent — re-adding * an existing (photo, tag) pair is a no-op. Returns { added: N }. */ bulkAddTags: async (photoIds: string[], tagIds: string[]) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'add_tags', value: tagIds, }) return response.data as { status: string; added: number } }, /** Remove the listed tags from every listed photo. Removing a * non-member is a no-op. Returns { removed: N }. */ bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => { const response = await api.post('/photos/bulk', { ids: photoIds, action: 'remove_tags', value: tagIds, }) return response.data as { status: string; removed: number } }, /** Move photos into a target folder (or source root). Returns * { moved, errors[] }. */ move: async (photoIds: string[], targetId: string) => { const response = await api.post('/photos/move', { photo_ids: photoIds, target_id: targetId, }) return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> } }, /** Copy photos into a target folder. Originals are unaffected; new * rows are created with is_duplicate=true. */ copy: async (photoIds: string[], targetId: string) => { const response = await api.post('/photos/copy', { photo_ids: photoIds, target_id: targetId, }) return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> } }, getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => { const token = localStorage.getItem('access_token') const qs = token ? `?token=${encodeURIComponent(token)}` : '' return `${API_BASE_URL}/photos/${photoId}/thumb/${size}${qs}` }, getOriginalUrl: (photoId: string) => { const token = localStorage.getItem('access_token') const qs = token ? `?token=${encodeURIComponent(token)}` : '' return `${API_BASE_URL}/photos/${photoId}/original${qs}` }, /** Full-resolution display URL. Backend serves the original for web-safe * formats and a transcoded WebP for RAW/HEIC/TIFF. */ getProxyUrl: (photoId: string) => { const token = localStorage.getItem('access_token') const qs = token ? `?token=${encodeURIComponent(token)}` : '' return `${API_BASE_URL}/photos/${photoId}/proxy${qs}` }, /** "On this day" memories — photos taken on this date in previous years. */ memories: async (): Promise => { const response = await api.get('/photos/memories') return response.data }, } // Library API export type MediaType = 'photo' | 'raw' | 'heic' | 'video' export interface ThumbnailStats { total: number pending: number processing: number completed: number failed: number by_media_type: Record } export interface RegenerateResult { status: string matched: number queued: number cleared_dirs: number file_errors: number filters: { media_types: MediaType[] | null only_failed: boolean } } export interface MissingStats { would_delete?: number deleted?: number would_delete_folders?: number deleted_folders?: number skipped_unmounted: number dry_run: boolean } export interface PruneResult extends MissingStats { status?: string message?: string } export interface WorkerInfo { name: string status: 'online' | 'unreachable' active: number reserved: number scheduled: number concurrency: number | null processed: Record queues: string[] active_tasks: Array<{ id: string name: string args: unknown time_start: number | null }> } export interface WorkerFailure { photo_id: string filename: string media_type: string error: string updated_at: string | null } export interface WorkerStatus { broker_ok: boolean broker_error: string | null inspect_error: string | null workers: WorkerInfo[] worker_count: number queues: Record failures: { total: number recent: WorkerFailure[] } scan_errors: string[] } export interface PipelineStage { key: string label: string done: number total: number hint: string /** True when the stage legitimately runs on a subset of photos — e.g. * GPS / tags / OCR / faces — so 100% coverage is never expected and * the UI should not frame "missing" as a problem. */ partial?: boolean /** True when the stage doesn't have a done/total progress semantic * (e.g. face clusters, duplicate groups — those are output counts, * not ratios). The UI renders a plain count instead of a bar. */ standalone?: boolean } export interface PipelineStats { total_photos: number total_images: number embedder_model: string stages: PipelineStage[] } export interface ScanStatus { is_scanning: boolean current_folder: string | null processed_files: number total_files: number errors: string[] } export const library = { scan: async () => { const response = await api.post('/library/scan') return response.data }, scanStatus: async () => { const response = await api.get('/library/scan/status') return response.data }, stats: async (scope?: 'global'): Promise => { const response = await api.get('/library/stats', { params: scope ? { scope } : undefined, }) return response.data }, /** Maintenance / admin actions surfaced via the Settings panel. */ maintenance: { thumbnailStats: async (scope?: 'global'): Promise => { const response = await api.get('/library/maintenance/thumbnail-stats', { params: scope ? { scope } : undefined, }) return response.data }, /** Reset on-disk thumbs and re-queue Celery generation. With no * filters, every photo in the library is re-queued. */ regenerateThumbnails: async ( body: { media_types?: MediaType[] only_failed?: boolean only_pending?: boolean } = {}, scope?: 'global', ): Promise => { const response = await api.post( '/library/maintenance/regenerate-thumbnails', body, { params: scope ? { scope } : undefined }, ) return response.data }, /** Celery worker fleet diagnostics + recent task failures. */ workerStatus: async (scope?: 'global'): Promise => { const response = await api.get('/library/maintenance/worker-status', { params: scope ? { scope } : undefined, }) return response.data }, /** Per-stage ingestion progress. */ pipelineStats: async (scope?: 'global'): Promise => { const response = await api.get('/library/maintenance/pipeline-stats', { params: scope ? { scope } : undefined, }) return response.data }, /** Dry-run count of photo rows whose files are no longer on disk * (under a mounted source root). */ missingStats: async (): Promise => { const response = await api.get('/library/maintenance/missing-stats') return response.data }, /** Actually delete the orphaned photo rows. */ pruneMissing: async (): Promise => { const response = await api.post('/library/maintenance/prune-missing') return response.data }, /** Re-run the source-roots / folders / photos integrity cleanup that * normally runs on backend startup. */ cleanup: async (): Promise<{ status: string; message?: string }> => { const response = await api.post('/library/maintenance/cleanup') return response.data }, /** Recompute duplicate groups from current perceptual hashes. * Idempotent — safe to fire repeatedly. */ regroupDuplicates: async (): Promise<{ status: string; message?: string }> => { const response = await api.post('/library/maintenance/regroup-duplicates') return response.data }, /** Compute pHash for every photo currently missing one. One-shot * recovery path for libraries that existed before the phash column * was added. */ backfillPhashes: async (): Promise<{ status: string; message?: string }> => { const response = await api.post('/library/maintenance/backfill-phashes') return response.data }, }, /** Duplicate groups computed by app.services.duplicates.regroup_duplicates. * Drives the grouped grid view in the Duplicates section. */ duplicates: { groups: async (scope?: 'global'): Promise => { const response = await api.get('/library/duplicates/groups', { params: scope ? { scope } : undefined, }) return response.data }, }, } // ── Duplicate groups ───────────────────────────────────────────────────── export interface DuplicateGroupMember { id: string filename: string taken_at: string | null file_size: number | null width: number | null height: number | null thumb_small: string | null file_hash: string | null folder_id: string | null media_type: string } export interface DuplicateGroup { group_id: string member_count: number /** "exact" iff every member shares the same SHA-256 (true byte * duplicates that pHash also caught). "similar" otherwise. */ reason: 'exact' | 'similar' members: DuplicateGroupMember[] } export interface DuplicateGroupsResponse { groups: DuplicateGroup[] total_groups: number total_members: number } // ── Memories ("On this day") ──────────────────────────────────────────── export interface MemoryPhoto { id: string filename: string taken_at: string thumb_small: string | null thumb_medium: string | null media_type: string width: number | null height: number | null rating: number } export interface MemoryGroup { year: number years_ago: number photos: MemoryPhoto[] } export interface MemoriesResponse { date: string memories: MemoryGroup[] } export interface LibraryStats { all_photos: number rated: number colored: number with_gps: number duplicates: number discarded: number total_photos: number total_videos: number total_size: number total_size_gb: number source_dirs: string[] } // Heaps API export interface Heap { id: string name: string is_active: boolean created_at: string updated_at: string | null photo_count: number } export const heaps = { list: async (): Promise => { const response = await api.get('/heaps') return response.data }, create: async (name: string): Promise => { const response = await api.post('/heaps', { name }) return response.data }, update: async ( heapId: string, data: { name?: string; is_active?: boolean } ): Promise => { const response = await api.patch(`/heaps/${heapId}`, data) return response.data }, delete: async (heapId: string): Promise => { await api.delete(`/heaps/${heapId}`) }, /** Duplicate a heap, copying its membership but never marking the new * one as active. The new heap is named "{name} (copy)". */ duplicate: async (heapId: string): Promise => { const response = await api.post(`/heaps/${heapId}/duplicate`) return response.data }, /** Lightweight: just the photo ids in a heap, for client-side membership * lookups (the basket affordance on thumbnails). */ photoIds: async (heapId: string): Promise => { const response = await api.get(`/heaps/${heapId}/photo_ids`) return response.data }, addPhotos: async (heapId: string, photoIds: string[]) => { const response = await api.post(`/heaps/${heapId}/photos`, { photo_ids: photoIds, }) return response.data }, removePhotos: async (heapId: string, photoIds: string[]) => { const response = await api.delete(`/heaps/${heapId}/photos`, { data: { photo_ids: photoIds }, }) return response.data }, /** Convert a heap into a folder by moving (or copying) every member * photo into the target directory. Optionally creates a subfolder * inside the target by name. */ convert: async ( heapId: string, body: { target_id: string mode: 'move' | 'copy' delete_heap: boolean subfolder_name?: string | null } ) => { const response = await api.post(`/heaps/${heapId}/convert`, body) return response.data as { status: string mode: 'move' | 'copy' moved: number copied: number errors: Array<{ id: string; error: string }> heap_deleted: boolean } }, } // Upload API — single file per request so the browser can fan out many // POSTs in parallel with per-file progress. For folder uploads the // caller passes each File's webkitRelativePath so the backend can // materialise the folder structure under the destination. export const uploads = { uploadFile: async ( file: File, destinationFolderId: string, opts: { relativePath?: string heapId?: string | null onProgress?: (loadedBytes: number, totalBytes: number) => void signal?: AbortSignal } = {} ) => { const form = new FormData() form.append('file', file) form.append('destination_folder_id', destinationFolderId) if (opts.relativePath) form.append('relative_path', opts.relativePath) if (opts.heapId) form.append('heap_id', opts.heapId) const response = await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' }, signal: opts.signal, onUploadProgress: (evt) => { if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total) }, }) return response.data as { photo_id: string filename: string folder_id: string folder_path: string heap_id: string | null } }, } // Download helpers — build a URL the browser can pull directly via an // . The backend accepts `?token=` so an works without a // custom fetch + save-blob dance; the Authorization header is not // settable on a plain link click. export const downloads = { folderUrl: (folderId: string): string => { const token = localStorage.getItem('access_token') || '' return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}` }, heapUrl: (heapId: string): string => { const token = localStorage.getItem('access_token') || '' return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}` }, trigger: (url: string) => { // Kicking off a download via a transient click keeps the // browser in charge of the file dialog + progress indicator. We // use target=_blank so the current SPA route isn't replaced if // the server returns an error mid-stream. const a = document.createElement('a') a.href = url a.rel = 'noopener' a.target = '_blank' document.body.appendChild(a) a.click() document.body.removeChild(a) }, } // Sharing API export interface SharedHeap { id: string name: string owner_username: string permission: 'read' | 'write' photo_count: number } export interface SharedFolder { id: string name: string folder_type: 'folder' | 'source_root' owner_username: string permission: 'read' | 'write' photo_count: number } export interface ShareInfo { id: string shared_with_id: string shared_with_username: string permission: string created_at: string } export const sharing = { // Heap shares shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => { const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission }) return response.data }, heapShares: async (heapId: string): Promise => { const response = await api.get(`/sharing/heaps/${heapId}`) return response.data }, revokeHeapShare: async (heapId: string, shareId: string) => { await api.delete(`/sharing/heaps/${heapId}/${shareId}`) }, sharedHeaps: async (): Promise => { const response = await api.get('/sharing/heaps/shared-with-me') return response.data }, // Folder shares shareFolder: async (folderId: string, username: string, permission: 'read' | 'write' = 'read') => { const response = await api.post(`/sharing/folders/${folderId}`, { username, permission }) return response.data }, folderShares: async (folderId: string): Promise => { const response = await api.get(`/sharing/folders/${folderId}`) return response.data }, revokeFolderShare: async (folderId: string, shareId: string) => { await api.delete(`/sharing/folders/${folderId}/${shareId}`) }, sharedFolders: async (): Promise => { const response = await api.get('/sharing/folders/shared-with-me') return response.data }, } // Tags API export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster' export interface Tag { id: string name: string color: string | null kind: TagKind source: string | null representative_photo_id: string | null photo_count: number } export const tags = { list: async (kind?: TagKind): Promise => { const params = kind ? { kind } : undefined const response = await api.get('/tags', { params }) return response.data }, create: async (name: string, color?: string, kind: TagKind = 'user'): Promise => { const response = await api.post('/tags', { name, color, kind }) return response.data }, update: async (tagId: string, data: { name?: string; color?: string }): Promise => { const response = await api.patch(`/tags/${tagId}`, data) return response.data }, delete: async (tagId: string): Promise => { await api.delete(`/tags/${tagId}`) }, merge: async (sourceId: string, targetId: string): Promise<{ merged_into: string; target_name: string }> => { const response = await api.post(`/tags/${sourceId}/merge`, { target_id: targetId }) return response.data }, /** Add one or more tags to a photo. */ addToPhoto: async (photoId: string, tagIds: string[]) => { const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds }) return response.data }, /** Remove a tag from a photo. */ removeFromPhoto: async (photoId: string, tagId: string): Promise => { await api.delete(`/photos/${photoId}/tags/${tagId}`) }, } // Search API — hybrid FTS + semantic search export interface SearchResult { id: string filename: string filepath: string media_type: string width: number height: number taken_at: string | null rating: number color_label: string | null thumb_small: string thumb_medium: string score: number } export const search = { query: async (params: { q?: string filters?: { tag_ids?: string[] date_from?: string date_to?: string } limit?: number offset?: number }): Promise<{ results: SearchResult[]; total: number }> => { const response = await api.post('/photos/search', params) return response.data }, } // Discard API export const discard = { list: async () => { const response = await api.get('/discard') return response.data }, restore: async (photoIds: string[]) => { const response = await api.post('/discard/restore', { photo_ids: photoIds, }) return response.data }, empty: async () => { const response = await api.delete('/discard/empty') return response.data }, /** Permanently delete a specific subset of discarded photos. The backend * silently skips ids that aren't in the pile, so this can never bypass * the soft-delete safety net. */ deletePermanent: async (photoIds: string[]) => { const response = await api.delete('/discard', { data: { photo_ids: photoIds }, }) return response.data }, } // Admin API — user management (admin only) export interface AdminUser { id: string username: string email: string | null role: 'admin' | 'user' is_active: boolean media_path: string created_at: string | null photo_count: number } export const admin = { listUsers: async (): Promise<{ users: AdminUser[]; total: number }> => { const response = await api.get('/admin/users') return response.data }, createUser: async (data: { username: string password: string role: string }): Promise => { const response = await api.post('/admin/users', data) return response.data }, updateUser: async ( userId: string, data: { role?: string; is_active?: boolean; new_password?: string }, ): Promise => { const response = await api.patch(`/admin/users/${userId}`, data) return response.data }, deleteUser: async (userId: string): Promise<{ status: string }> => { const response = await api.delete(`/admin/users/${userId}`) return response.data }, // --- AI / vision feature flags + manual pipeline triggers ----------- listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => { const response = await api.get('/admin/feature-flags') return response.data }, /** Set ``value`` to toggle; pass ``null`` to clear the override and * fall back to the YAML default. */ setFeatureFlag: async ( name: string, value: boolean | null, ): Promise<{ flags: FeatureFlagSnapshot }> => { const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value }) return response.data }, triggerAiBackfill: async (body: { task?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null limit?: number | null }): Promise<{ status: string; task_id: string }> => { const response = await api.post('/admin/ai/backfill', body) return response.data }, triggerFaceRecluster: async (): Promise<{ status: string; task_id: string }> => { const response = await api.post('/admin/ai/recluster-faces') return response.data }, triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => { const response = await api.post('/admin/ai/rescan') return response.data }, } export interface FeatureFlagState { effective: boolean default: boolean overridden: boolean } export type FeatureFlagSnapshot = Record export type FeaturesMap = Record // Public read of effective feature flags. Available to any signed-in // user so the frontend can hide sections that depend on a disabled // pipeline stage (e.g. People when faces are off). export const features = { list: async (): Promise => { const response = await api.get('/features') return response.data }, } export default api