mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(photos): paginated search with infinite scroll (#613)
Replace bulk-loading all Immich photos (up to 20k) with paginated search: 50 photos per page, automatic infinite scroll via IntersectionObserver. Prevents server blocking on large libraries. - Backend: searchPhotos accepts page/size params, returns hasMore - Frontend: loads 50 at a time, appends on scroll - AbortController cancels in-flight requests on tab switch
This commit is contained in:
@@ -1369,12 +1369,18 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
const [albums, setAlbums] = useState<any[]>([])
|
const [albums, setAlbums] = useState<any[]>([])
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
const [searchPage, setSearchPage] = useState(1)
|
||||||
|
const [searchFrom, setSearchFrom] = useState('')
|
||||||
|
const [searchTo, setSearchTo] = useState('')
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [customFrom, setCustomFrom] = useState('')
|
const [customFrom, setCustomFrom] = useState('')
|
||||||
const [customTo, setCustomTo] = useState('')
|
const [customTo, setCustomTo] = useState('')
|
||||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||||
const [addToOpen, setAddToOpen] = useState(false)
|
const [addToOpen, setAddToOpen] = useState(false)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// compute trip range
|
// compute trip range
|
||||||
const tripRange = useMemo(() => {
|
const tripRange = useMemo(() => {
|
||||||
@@ -1392,19 +1398,31 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
return abortRef.current.signal
|
return abortRef.current.signal
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchPhotos = async (from: string, to: string) => {
|
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||||
const signal = cancelPending()
|
const signal = cancelPending()
|
||||||
setLoading(true)
|
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||||
setPhotos([])
|
setSearchFrom(from)
|
||||||
|
setSearchTo(to)
|
||||||
|
setSearchPage(page)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
||||||
method: 'POST', credentials: 'include', signal,
|
method: 'POST', credentials: 'include', signal,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ from, to }),
|
body: JSON.stringify({ from, to, page, size: 50 }),
|
||||||
})
|
})
|
||||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const assets = data.assets || []
|
||||||
|
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||||
|
setHasMore(!!data.hasMore)
|
||||||
|
}
|
||||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||||
if (!signal.aborted) setLoading(false)
|
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMorePhotos = () => {
|
||||||
|
if (loadingMore || !hasMore) return
|
||||||
|
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAlbumPhotos = async (albumId: string) => {
|
const loadAlbumPhotos = async (albumId: string) => {
|
||||||
@@ -1681,6 +1699,17 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Infinite scroll trigger */}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center py-4 mt-2" ref={el => {
|
||||||
|
if (!el) return
|
||||||
|
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) loadMorePhotos() }, { rootMargin: '200px' })
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}}>
|
||||||
|
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { from, to } = req.body;
|
const { from, to, page, size } = req.body;
|
||||||
const result = await searchPhotos(authReq.user.id, from, to);
|
const result = await searchPhotos(authReq.user.id, from, to, Number(page) || 1, Math.min(Number(size) || 50, 200));
|
||||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
res.json({ assets: result.assets });
|
res.json({ assets: result.assets, hasMore: result.hasMore });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -149,44 +149,36 @@ export async function browseTimeline(
|
|||||||
export async function searchPhotos(
|
export async function searchPhotos(
|
||||||
userId: number,
|
userId: number,
|
||||||
from?: string,
|
from?: string,
|
||||||
to?: string
|
to?: string,
|
||||||
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
page: number = 1,
|
||||||
|
size: number = 50,
|
||||||
|
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
|
||||||
const creds = getImmichCredentials(userId);
|
const creds = getImmichCredentials(userId);
|
||||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allAssets: any[] = [];
|
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||||
let page = 1;
|
method: 'POST',
|
||||||
const pageSize = 1000;
|
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||||
const maxPages = 5; // Cap at 5000 photos to avoid timeouts on large libraries
|
body: JSON.stringify({
|
||||||
while (true) {
|
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||||
method: 'POST',
|
type: 'IMAGE',
|
||||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
size,
|
||||||
body: JSON.stringify({
|
page,
|
||||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
}),
|
||||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
signal: AbortSignal.timeout(15000) as any,
|
||||||
type: 'IMAGE',
|
});
|
||||||
size: pageSize,
|
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||||
page,
|
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||||
}),
|
const items = data.assets?.items || [];
|
||||||
signal: AbortSignal.timeout(15000) as any,
|
const assets = items.map((a: any) => ({
|
||||||
});
|
|
||||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
|
||||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
|
||||||
const items = data.assets?.items || [];
|
|
||||||
allAssets.push(...items);
|
|
||||||
if (items.length < pageSize) break;
|
|
||||||
page++;
|
|
||||||
if (page > maxPages) break;
|
|
||||||
}
|
|
||||||
const assets = allAssets.map((a: any) => ({
|
|
||||||
id: a.id,
|
id: a.id,
|
||||||
takenAt: a.fileCreatedAt || a.createdAt,
|
takenAt: a.fileCreatedAt || a.createdAt,
|
||||||
city: a.exifInfo?.city || null,
|
city: a.exifInfo?.city || null,
|
||||||
country: a.exifInfo?.country || null,
|
country: a.exifInfo?.country || null,
|
||||||
}));
|
}));
|
||||||
return { assets, hasMore: page > maxPages };
|
return { assets, hasMore: items.length >= size };
|
||||||
} catch {
|
} catch {
|
||||||
return { error: 'Could not reach Immich', status: 502 };
|
return { error: 'Could not reach Immich', status: 502 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user