mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(journey): paginate Immich picker and group photos by date
The /search route was looping up to 20 pages server-side, returning a blob of up to 1000 photos with no hasMore flag, which prevented the client's existing ScrollTrigger infinite scroll from ever firing. Now the route proxies the client's page param directly to Immich and returns a single page plus hasMore, enabling full library browsing. The photo picker grid now groups photos by takenAt date (already present in every asset response) with a date label above each group, restoring the date-oriented browsing from V2. Closes #674.
This commit is contained in:
@@ -1423,6 +1423,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Photo date grouping ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||||
|
const map = new Map<string, any[]>()
|
||||||
|
for (const asset of photos) {
|
||||||
|
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(asset)
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([date, assets]) => ({
|
||||||
|
date,
|
||||||
|
label: date === '__unknown__'
|
||||||
|
? 'Unknown date'
|
||||||
|
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||||
|
assets,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||||
@@ -1732,51 +1750,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
<div>
|
||||||
{photos.map((asset: any) => {
|
{groupPhotosByDate(photos).map(group => (
|
||||||
const isSelected = selected.has(asset.id)
|
<div key={group.date}>
|
||||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||||
return (
|
{group.label}
|
||||||
<div
|
</p>
|
||||||
key={asset.id}
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
{group.assets.map((asset: any) => {
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
const isSelected = selected.has(asset.id)
|
||||||
alreadyAdded
|
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||||
? 'opacity-40 cursor-not-allowed'
|
return (
|
||||||
: isSelected
|
<div
|
||||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
key={asset.id}
|
||||||
: 'cursor-pointer'
|
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||||
}`}
|
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||||
>
|
alreadyAdded
|
||||||
<img
|
? 'opacity-40 cursor-not-allowed'
|
||||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
: isSelected
|
||||||
alt=""
|
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||||
className="w-full h-full object-cover"
|
: 'cursor-pointer'
|
||||||
loading="lazy"
|
}`}
|
||||||
onError={e => {
|
>
|
||||||
const img = e.currentTarget
|
<img
|
||||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||||
if (!img.src.includes('/original')) img.src = original
|
alt=""
|
||||||
}}
|
className="w-full h-full object-cover"
|
||||||
/>
|
loading="lazy"
|
||||||
{alreadyAdded && (
|
onError={e => {
|
||||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
const img = e.currentTarget
|
||||||
<Check size={12} />
|
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||||
</div>
|
if (!img.src.includes('/original')) img.src = original
|
||||||
)}
|
}}
|
||||||
{isSelected && !alreadyAdded && (
|
/>
|
||||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
{alreadyAdded && (
|
||||||
<Check size={12} />
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||||
</div>
|
<Check size={12} />
|
||||||
)}
|
</div>
|
||||||
{asset.city && (
|
)}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
{isSelected && !alreadyAdded && (
|
||||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||||
</div>
|
<Check size={12} />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{asset.city && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||||
|
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
))}
|
||||||
{/* Infinite scroll trigger */}
|
{/* Infinite scroll trigger */}
|
||||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,16 +60,12 @@ 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, size } = req.body;
|
const { from, to, size, page } = req.body;
|
||||||
|
const pageNum = Math.max(1, Number(page) || 1);
|
||||||
const pageSize = Math.min(Number(size) || 50, 200);
|
const pageSize = Math.min(Number(size) || 50, 200);
|
||||||
const allAssets: any[] = [];
|
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
|
||||||
for (let page = 1; page <= 20; page++) {
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
||||||
if (result.assets) allAssets.push(...result.assets);
|
|
||||||
if (!result.hasMore) break;
|
|
||||||
}
|
|
||||||
res.json({ assets: allAssets });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -273,18 +273,19 @@ describe('Immich browse and search', () => {
|
|||||||
expect(res.body.buckets.length).toBeGreaterThan(0);
|
expect(res.body.buckets.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('IMMICH-042 — POST /search returns mapped assets', async () => {
|
it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`${IMMICH}/search`)
|
.post(`${IMMICH}/search`)
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({});
|
.send({ page: 1, size: 50 });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||||
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
||||||
|
expect(typeof res.body.hasMore).toBe('boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
||||||
@@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => {
|
|||||||
|
|
||||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Immich searchPhotos pagination safety', () => {
|
describe('Immich searchPhotos pagination pass-through', () => {
|
||||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||||
|
|
||||||
// Return a full page of 1000 items on every call, so the loop would
|
// Return a full page so hasMore=true (items.length >= size)
|
||||||
// run indefinitely without the page > 20 safety check.
|
|
||||||
const fullPageResponse = {
|
const fullPageResponse = {
|
||||||
ok: true, status: 200,
|
ok: true, status: 200,
|
||||||
headers: { get: () => null },
|
headers: { get: () => null },
|
||||||
json: () => Promise.resolve({
|
json: () => Promise.resolve({
|
||||||
assets: {
|
assets: {
|
||||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
items: Array.from({ length: 50 }, (_, i) => ({
|
||||||
id: `asset-${i}`,
|
id: `asset-p2-${i}`,
|
||||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||||
exifInfo: { city: 'Paris', country: 'France' },
|
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
body: null,
|
body: null,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
// Clear previous call history so the count only reflects this test
|
|
||||||
vi.mocked(safeFetch).mockClear();
|
vi.mocked(safeFetch).mockClear();
|
||||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`${IMMICH}/search`)
|
.post(`${IMMICH}/search`)
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({});
|
.send({ page: 2, size: 50 });
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
// Single page returned — not 20× aggregation
|
||||||
expect(res.body.assets.length).toBe(20000);
|
expect(res.body.assets.length).toBe(50);
|
||||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
expect(res.body.hasMore).toBe(true);
|
||||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
// Immich was called exactly once
|
||||||
|
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1);
|
||||||
|
// page=2 was forwarded to Immich
|
||||||
|
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
|
||||||
|
expect(callBody.page).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||||
|
|
||||||
|
// Partial page → hasMore=false
|
||||||
|
const partialPageResponse = {
|
||||||
|
ok: true, status: 200,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
assets: {
|
||||||
|
items: Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
id: `asset-last-${i}`,
|
||||||
|
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||||
|
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
body: null,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
vi.mocked(safeFetch).mockResolvedValue(partialPageResponse);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`${IMMICH}/search`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ page: 5, size: 50 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.assets.length).toBe(3);
|
||||||
|
expect(res.body.hasMore).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user