Drops face recognition, OCR, object detection, and semantic embeddings. The sole remaining vision task is a CLIP-based binary classifier (photography vs other); photos in "other" get needs_review=true so screenshots, documents, memes and scans can be triaged from a new filter pill in the UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1043 lines
30 KiB
TypeScript
1043 lines
30 KiB
TypeScript
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<FolderTreeNode[]> => {
|
|
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<string, string>) => {
|
|
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<MemoriesResponse> => {
|
|
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<string, number>
|
|
}
|
|
|
|
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<string, number>
|
|
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<string, number>
|
|
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
|
|
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<LibraryStats> => {
|
|
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<ThumbnailStats> => {
|
|
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<RegenerateResult> => {
|
|
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<WorkerStatus> => {
|
|
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<PipelineStats> => {
|
|
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<MissingStats> => {
|
|
const response = await api.get('/library/maintenance/missing-stats')
|
|
return response.data
|
|
},
|
|
|
|
/** Actually delete the orphaned photo rows. */
|
|
pruneMissing: async (): Promise<PruneResult> => {
|
|
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<DuplicateGroupsResponse> => {
|
|
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
|
|
needs_review: 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<Heap[]> => {
|
|
const response = await api.get('/heaps')
|
|
return response.data
|
|
},
|
|
|
|
create: async (name: string): Promise<Heap> => {
|
|
const response = await api.post('/heaps', { name })
|
|
return response.data
|
|
},
|
|
|
|
update: async (
|
|
heapId: string,
|
|
data: { name?: string; is_active?: boolean }
|
|
): Promise<Heap> => {
|
|
const response = await api.patch(`/heaps/${heapId}`, data)
|
|
return response.data
|
|
},
|
|
|
|
delete: async (heapId: string): Promise<void> => {
|
|
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<Heap> => {
|
|
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<string[]> => {
|
|
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
|
|
// <a href>. The backend accepts `?token=` so an <a> 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 <a> 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<ShareInfo[]> => {
|
|
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<SharedHeap[]> => {
|
|
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<ShareInfo[]> => {
|
|
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<SharedFolder[]> => {
|
|
const response = await api.get('/sharing/folders/shared-with-me')
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// Tags API
|
|
export type TagKind = 'user' | 'content_type'
|
|
|
|
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<Tag[]> => {
|
|
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<Tag> => {
|
|
const response = await api.post('/tags', { name, color, kind })
|
|
return response.data
|
|
},
|
|
|
|
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
|
|
const response = await api.patch(`/tags/${tagId}`, data)
|
|
return response.data
|
|
},
|
|
|
|
delete: async (tagId: string): Promise<void> => {
|
|
await api.delete(`/tags/${tagId}`)
|
|
},
|
|
|
|
/** 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<void> => {
|
|
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<AdminUser> => {
|
|
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<AdminUser> => {
|
|
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: {
|
|
limit?: number | null
|
|
}): Promise<{ status: string; task_id: string }> => {
|
|
const response = await api.post('/admin/ai/backfill', body)
|
|
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<string, FeatureFlagState>
|
|
|
|
export type FeaturesMap = Record<string, boolean>
|
|
|
|
// 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<FeaturesMap> => {
|
|
const response = await api.get('/features')
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
export default api |