Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".
Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
320 lines
8.4 KiB
TypeScript
320 lines
8.4 KiB
TypeScript
import axios from 'axios'
|
|
|
|
const API_BASE_URL = 'http://localhost:8001/api/v1'
|
|
|
|
const api = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
// 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
|
|
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 the display label only — the on-disk path is controlled by
|
|
* the docker mount and cannot be changed from the UI. */
|
|
rename: async (folderId: string, name: string) => {
|
|
const response = await api.patch(`/folders/${folderId}`, { name })
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// 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
|
|
},
|
|
|
|
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
|
|
},
|
|
|
|
/** 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') => {
|
|
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
|
},
|
|
|
|
getOriginalUrl: (photoId: string) => {
|
|
return `${API_BASE_URL}/photos/${photoId}/original`
|
|
},
|
|
|
|
/** Full-resolution display URL. Backend serves the original for web-safe
|
|
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
|
|
getProxyUrl: (photoId: string) => {
|
|
return `${API_BASE_URL}/photos/${photoId}/proxy`
|
|
},
|
|
}
|
|
|
|
// Library API
|
|
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 () => {
|
|
const response = await api.get('/library/stats')
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
// 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}`)
|
|
},
|
|
|
|
/** 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
|
|
}
|
|
},
|
|
}
|
|
|
|
// Tags API
|
|
export interface Tag {
|
|
id: string
|
|
name: string
|
|
color: string | null
|
|
photo_count: number
|
|
}
|
|
|
|
export const tags = {
|
|
list: async (): Promise<Tag[]> => {
|
|
const response = await api.get('/tags')
|
|
return response.data
|
|
},
|
|
|
|
create: async (name: string, color?: string): Promise<Tag> => {
|
|
const response = await api.post('/tags', { name, color })
|
|
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}`)
|
|
},
|
|
}
|
|
|
|
// 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
|
|
},
|
|
}
|
|
|
|
export default api |