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>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
|
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
|
import api from '../services/api'
|
|
import type { Photo } from '../types/photo'
|
|
|
|
/**
|
|
* Optimistically strip the supplied photo ids from every cached
|
|
* timeline list. Used by discard / delete flows so the photos vanish
|
|
* from the grid the instant the user acts, without waiting for the
|
|
* mutation round-trip + invalidation refetch. The subsequent invalidate
|
|
* still runs and reconciles the cache with server truth.
|
|
*/
|
|
export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) {
|
|
if (ids.length === 0) return
|
|
const removed = new Set(ids)
|
|
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) => {
|
|
if (!prev) return prev
|
|
return prev.filter((p) => !removed.has(p.id))
|
|
})
|
|
}
|
|
|
|
interface CursorPage {
|
|
photos: Photo[]
|
|
next_cursor: string | null
|
|
}
|
|
|
|
async function fetchCursorPage(
|
|
params: Record<string, unknown>,
|
|
signal?: AbortSignal,
|
|
): Promise<CursorPage> {
|
|
const resp = await api.get<CursorPage>('/photos', { params, signal })
|
|
return resp.data
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for the timeline photos query. Both Timeline and
|
|
* PreviewView call this so they share one cache entry — previously
|
|
* PreviewView looked the cache up by key directly, which broke the moment
|
|
* Timeline's key gained the filter params.
|
|
*/
|
|
export function usePhotosQuery() {
|
|
const q = useFilterStore((s) => s.q)
|
|
const dateFrom = useFilterStore((s) => s.dateFrom)
|
|
const dateTo = useFilterStore((s) => s.dateTo)
|
|
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
|
const ratingMin = useFilterStore((s) => s.ratingMin)
|
|
const ratingMax = useFilterStore((s) => s.ratingMax)
|
|
const colorLabel = useFilterStore((s) => s.colorLabel)
|
|
const flag = useFilterStore((s) => s.flag)
|
|
const heapId = useFilterStore((s) => s.heapId)
|
|
const folderId = useFilterStore((s) => s.folderId)
|
|
const tagIds = useFilterStore((s) => s.tagIds)
|
|
const duplicates = useFilterStore((s) => s.duplicates)
|
|
const needsReview = useFilterStore((s) => s.needsReview)
|
|
const groupBy = useFilterStore((s) => s.groupBy)
|
|
const sortBy = useFilterStore((s) => s.sortBy)
|
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
|
|
|
const filterParams = useMemo(
|
|
() =>
|
|
filtersToParams({
|
|
q,
|
|
dateFrom,
|
|
dateTo,
|
|
mediaTypes,
|
|
ratingMin,
|
|
ratingMax,
|
|
colorLabel,
|
|
flag,
|
|
heapId,
|
|
folderId,
|
|
tagIds,
|
|
duplicates,
|
|
needsReview,
|
|
groupBy,
|
|
sortBy,
|
|
sortOrder,
|
|
}),
|
|
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder]
|
|
)
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
return useQuery({
|
|
queryKey: ['photos', filterParams],
|
|
queryFn: async ({ signal }) => {
|
|
// Two-phase fetch using cursor-based (keyset) pagination.
|
|
// Phase 1 returns the first page (resolves the useQuery promise
|
|
// so consumers exit loading state). Phase 2 chains cursors in
|
|
// the background — each response includes a `next_cursor` that
|
|
// seeks directly to the next slice via an indexed range scan,
|
|
// O(1) regardless of depth (no OFFSET skipping).
|
|
const PER_PAGE = 500
|
|
const MAX_PAGES = 200
|
|
|
|
const first = await fetchCursorPage(
|
|
{ per_page: PER_PAGE, ...filterParams },
|
|
signal,
|
|
)
|
|
const firstBatch = first.photos || []
|
|
let nextCursor: string | null = first.next_cursor
|
|
|
|
if (nextCursor) {
|
|
// Fire-and-forget background loop using cursor chaining.
|
|
void (async () => {
|
|
for (let i = 0; i < MAX_PAGES && nextCursor; i++) {
|
|
if (signal?.aborted) return
|
|
try {
|
|
const page = await fetchCursorPage(
|
|
{ per_page: PER_PAGE, cursor: nextCursor, ...filterParams },
|
|
signal,
|
|
)
|
|
if (signal?.aborted) return
|
|
const more = page.photos || []
|
|
nextCursor = page.next_cursor
|
|
queryClient.setQueryData<Photo[]>(
|
|
['photos', filterParams],
|
|
(prev) => (prev ? [...prev, ...more] : more)
|
|
)
|
|
if (!nextCursor || more.length < PER_PAGE) return
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
})()
|
|
}
|
|
|
|
return firstBatch
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
}
|