- {photos.map((asset: any) => {
- const isSelected = selected.has(asset.id)
- const alreadyAdded = existingAssetIds.has(asset.id)
- return (
-
!alreadyAdded && toggleAsset(asset.id)}
- className={`relative aspect-square rounded-lg overflow-hidden ${
- alreadyAdded
- ? 'opacity-40 cursor-not-allowed'
- : isSelected
- ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
- : 'cursor-pointer'
- }`}
- >
-

{
- const img = e.currentTarget
- const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
- if (!img.src.includes('/original')) img.src = original
- }}
- />
- {alreadyAdded && (
-
-
-
- )}
- {isSelected && !alreadyAdded && (
-
-
-
- )}
- {asset.city && (
-
- )}
+
+ {groupPhotosByDate(photos).map(group => (
+
+
+ {group.label}
+
+
+ {group.assets.map((asset: any) => {
+ const isSelected = selected.has(asset.id)
+ const alreadyAdded = existingAssetIds.has(asset.id)
+ return (
+
!alreadyAdded && toggleAsset(asset.id)}
+ className={`relative aspect-square rounded-lg overflow-hidden ${
+ alreadyAdded
+ ? 'opacity-40 cursor-not-allowed'
+ : isSelected
+ ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
+ : 'cursor-pointer'
+ }`}
+ >
+

{
+ const img = e.currentTarget
+ const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
+ if (!img.src.includes('/original')) img.src = original
+ }}
+ />
+ {alreadyAdded && (
+
+
+
+ )}
+ {isSelected && !alreadyAdded && (
+
+
+
+ )}
+ {asset.city && (
+
+ )}
+
+ )
+ })}
- )
- })}
+
+ ))}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum &&
}
diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts
index 87f3fa0f..9486234a 100644
--- a/server/src/routes/memories/immich.ts
+++ b/server/src/routes/memories/immich.ts
@@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
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 allAssets: any[] = [];
- for (let page = 1; page <= 20; page++) {
- const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
- 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 });
+ const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
+ if (result.error) return res.status(result.status!).json({ error: result.error });
+ res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
});
// ── Asset Details ──────────────────────────────────────────────────────────
diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts
index eb19cd48..2b3cdefb 100644
--- a/server/tests/integration/memories-immich.test.ts
+++ b/server/tests/integration/memories-immich.test.ts
@@ -273,18 +273,19 @@ describe('Immich browse and search', () => {
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);
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
- .send({});
+ .send({ page: 1, size: 50 });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
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 () => {
@@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => {
// ── searchPhotos pagination safety ────────────────────────────────────────────
-describe('Immich searchPhotos pagination safety', () => {
- it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
+describe('Immich searchPhotos pagination pass-through', () => {
+ it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
const { user } = createUser(testDb);
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
- // run indefinitely without the page > 20 safety check.
+ // Return a full page so hasMore=true (items.length >= size)
const fullPageResponse = {
ok: true, status: 200,
headers: { get: () => null },
json: () => Promise.resolve({
assets: {
- items: Array.from({ length: 1000 }, (_, i) => ({
- id: `asset-${i}`,
+ items: Array.from({ length: 50 }, (_, i) => ({
+ id: `asset-p2-${i}`,
fileCreatedAt: '2024-06-01T10:00:00.000Z',
- exifInfo: { city: 'Paris', country: 'France' },
+ exifInfo: { city: 'Berlin', country: 'Germany' },
})),
},
}),
body: null,
} as any;
- // Clear previous call history so the count only reflects this test
vi.mocked(safeFetch).mockClear();
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
const res = await request(app)
.post(`${IMMICH}/search`)
.set('Cookie', authCookie(user.id))
- .send({});
+ .send({ page: 2, size: 50 });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.assets)).toBe(true);
- // 20 pages × 1000 items = 20000 assets total (safety limit)
- expect(res.body.assets.length).toBe(20000);
- // safeFetch should have been called exactly 20 times (the safety limit)
- expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
+ // Single page returned — not 20× aggregation
+ expect(res.body.assets.length).toBe(50);
+ expect(res.body.hasMore).toBe(true);
+ // 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);
});
});
From da70388f4b932c702726b13b2c74ba806a921ef0 Mon Sep 17 00:00:00 2001
From: jubnl
Date: Thu, 16 Apr 2026 15:37:24 +0200
Subject: [PATCH 3/7] fix(journey): resolve Immich photos on public share by
matching trek_photos.id
validateShareTokenForPhoto was querying journey_photos by jp.id but the
public page sends p.photo_id (trek_photos.id) in the URL. In a fresh
database the IDs coincidentally match, masking the bug. In production
instances with many Immich-synced photos the trek_photos autoincrement
is far ahead of journey_photos, causing a 404 for every Immich photo
on the public share page.
Fix: change the lookup to jp.photo_id = ? so validation is keyed on
trek_photos.id, which is what the client sends and what streamPhoto
needs. Updated the test helper to return trekId and added a regression
test that pre-populates trek_photos to produce diverging IDs. Closes #675.
---
server/src/services/journeyShareService.ts | 2 +-
.../unit/services/journeyShareService.test.ts | 33 +++++++++++++++++--
2 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts
index 46ef6926..c85621de 100644
--- a/server/src/services/journeyShareService.ts
+++ b/server/src/services/journeyShareService.ts
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
- WHERE jp.id = ? AND je.journey_id = ?
+ WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/services/journeyShareService.test.ts
index 371e170e..8027e068 100644
--- a/server/tests/unit/services/journeyShareService.test.ts
+++ b/server/tests/unit/services/journeyShareService.test.ts
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------
-/** Insert a journey_photos row and return its id. */
+/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,11 +70,13 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
- const result = testDb.prepare(`
+ testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now());
- return result.lastInsertRowid as number;
+ // Return trek_photos.id — this is p.photo_id in the public API response
+ // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
+ return trekId;
}
// -- Tests --------------------------------------------------------------------
@@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => {
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
+
+ it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
+ // Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
+ // trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
+ // instead of jp.photo_id, causing a 404 for Immich photos in public shares.
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const entry = createJourneyEntry(testDb, journey.id, user.id);
+
+ // Pre-populate trek_photos to push the autoincrement higher
+ for (let i = 0; i < 5; i++) {
+ testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
+ }
+
+ // This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
+ const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
+ const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
+
+ // photoId = trek_photos.id (6), not journey_photos.id (1)
+ const result = validateShareTokenForPhoto(token, trekPhotoId);
+
+ expect(result).not.toBeNull();
+ expect(result!.ownerId).toBe(user.id);
+ expect(result!.journeyId).toBe(journey.id);
+ });
});
describe('validateShareTokenForAsset', () => {
From 47b7678975859e5fd7fa1fe7bf8939c33c263d73 Mon Sep 17 00:00:00 2001
From: jubnl
Date: Thu, 16 Apr 2026 15:45:37 +0200
Subject: [PATCH 4/7] fix(journey): remove backdropFilter from modal overlays
to fix iOS Safari PWA white screen
backdrop-filter: blur() on position:fixed elements is a known Safari iOS
compositing failure in standalone (PWA) mode. When the GPU layer behind
a fixed overlay is uninitialized, the blur samples white instead of the
actual content, overriding the semi-transparent background and rendering
a fully white screen that requires a force-close to escape.
The JourneySettingsDialog (bottom-sheet on mobile) was most affected due
to its items-end layout, but all five modal overlays in JourneyDetailPage
had the same pattern. Removed backdropFilter from all five and bumped
opacity from 0.6 to 0.75 to maintain visual separation. Closes #678.
---
client/src/pages/JourneyDetailPage.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index 0eb56e05..9786de43 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -1565,7 +1565,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
-
+
{/* Header */}
@@ -2027,7 +2027,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}
return (
-
+
@@ -2411,7 +2411,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
}
return (
-
+
@@ -2508,7 +2508,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
}
return (
-
+
@@ -2765,7 +2765,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
return (
-
{ if (e.target === e.currentTarget) e.preventDefault() }}>
+
{ if (e.target === e.currentTarget) e.preventDefault() }}>
e.stopPropagation()}>
From 1b7ea2c87d3b400e5ee5186c26ef8fd8711b2346 Mon Sep 17 00:00:00 2001
From: jubnl
Date: Thu, 16 Apr 2026 15:54:07 +0200
Subject: [PATCH 5/7] fix(journey): replace window.open with srcdoc iframe
overlay for PDF preview
Rewrites downloadJourneyBookPDF to render the preview in an in-page srcdoc
iframe overlay instead of calling window.open(), which Safari iOS PWA blocks
in async callbacks. Matches the existing TripPDF pattern. Fixes #679.
---
.../components/PDF/JourneyBookPDF.test.tsx | 52 ++++++++++---------
client/src/components/PDF/JourneyBookPDF.tsx | 51 +++++++++++-------
2 files changed, 60 insertions(+), 43 deletions(-)
diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx
index bb43e711..1e8c5810 100644
--- a/client/src/components/PDF/JourneyBookPDF.test.tsx
+++ b/client/src/components/PDF/JourneyBookPDF.test.tsx
@@ -1,8 +1,8 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
//
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
-// that opens a new browser window and writes a full HTML document into it.
-// It does NOT render a React component. Tests verify window.open behaviour.
+// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
+// Tests verify the overlay DOM structure and HTML content.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial = {}): JourneyDetail {
} as unknown as JourneyDetail;
}
-// ── Mock window.open ─────────────────────────────────────────────────────────
+// ── Helpers to inspect the overlay ───────────────────────────────────────────
-let mockWindow: {
- document: { write: ReturnType; close: ReturnType };
- focus: ReturnType;
-};
+function getOverlay(): HTMLElement | null {
+ return document.getElementById('journey-pdf-overlay');
+}
-beforeEach(() => {
- mockWindow = {
- document: { write: vi.fn(), close: vi.fn() },
- focus: vi.fn(),
- };
- vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
-});
+function getIframe(): HTMLIFrameElement | null {
+ return getOverlay()?.querySelector('iframe') ?? null;
+}
+
+// ── Setup ────────────────────────────────────────────────────────────────────
afterEach(() => {
+ document.getElementById('journey-pdf-overlay')?.remove();
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
- it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
+ it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
await downloadJourneyBookPDF(buildJourney());
- expect(window.open).toHaveBeenCalledWith('', '_blank');
+ expect(getOverlay()).not.toBeNull();
+ expect(document.body.contains(getOverlay())).toBe(true);
});
- it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
+ it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
await downloadJourneyBookPDF(buildJourney());
- expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
- const html = mockWindow.document.write.mock.calls[0][0] as string;
+ const iframe = getIframe();
+ expect(iframe).not.toBeNull();
+ const html = iframe!.srcdoc;
expect(html).toContain('');
expect(html).toContain('