Compare commits

...

8 Commits

Author SHA1 Message Date
Julien G. c7a9210215 Merge pull request #684 from mauriceboe/fix/batch-673-674-675-678-679-680
fix(journey): batch bug fixes #673 #674 #675 #678 #679 #680
2026-04-16 16:06:52 +02:00
jubnl d5d63aa979 test(journey): fix FE-PAGE-JOURNEYDETAIL-027 flaky spinner assertion
Pre-seed the store into loading state before render instead of relying on
timing. RTL's render() flushes all microtasks via act(), so the MSW response
lands before render() returns, leaving no observable loading window.
2026-04-16 16:01:06 +02:00
jubnl 84574020f2 fix(journey): increase PDF preview button touch targets for mobile
Raises button min-height to 44px and bumps padding/font-size to meet Apple HIG
minimum touch-target guidelines on iOS PWA. Fixes #680.
2026-04-16 15:55:20 +02:00
jubnl 1b7ea2c87d 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.
2026-04-16 15:54:07 +02:00
jubnl 47b7678975 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.
2026-04-16 15:45:37 +02:00
jubnl da70388f4b 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.
2026-04-16 15:37:24 +02:00
jubnl 6c1a795460 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.
2026-04-16 15:32:56 +02:00
jubnl 75d23eb6aa fix(journey): keep page mounted during in-place journey refetch
loadJourney previously set loading=true unconditionally, causing the
JourneyDetailPage guard (if loading || !current) to unmount the entire
page tree on every background refetch — entry saves, settings saves,
trip link/unlink, contributor invite, delete, and WS realtime events
all triggered the full-page spinner flash.

Now loading is only toggled on cold loads (current?.id !== id).
Warm refreshes replace current silently so the hero, sidebar, map,
and timeline stay mounted throughout. Closes #673.
2026-04-16 15:27:13 +02:00
10 changed files with 270 additions and 123 deletions
@@ -1,8 +1,8 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006 // FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
// //
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)` // JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
// that opens a new browser window and writes a full HTML document into it. // that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
// It does NOT render a React component. Tests verify window.open behaviour. // Tests verify the overlay DOM structure and HTML content.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
} as unknown as JourneyDetail; } as unknown as JourneyDetail;
} }
// ── Mock window.open ───────────────────────────────────────────────────────── // ── Helpers to inspect the overlay ───────────────────────────────────────────
let mockWindow: { function getOverlay(): HTMLElement | null {
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }; return document.getElementById('journey-pdf-overlay');
focus: ReturnType<typeof vi.fn>; }
};
beforeEach(() => { function getIframe(): HTMLIFrameElement | null {
mockWindow = { return getOverlay()?.querySelector('iframe') ?? null;
document: { write: vi.fn(), close: vi.fn() }, }
focus: vi.fn(),
}; // ── Setup ────────────────────────────────────────────────────────────────────
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
});
afterEach(() => { afterEach(() => {
document.getElementById('journey-pdf-overlay')?.remove();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => { 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()); 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()); await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.write).toHaveBeenCalledTimes(1); const iframe = getIframe();
const html = mockWindow.document.write.mock.calls[0][0] as string; expect(iframe).not.toBeNull();
const html = iframe!.srcdoc;
expect(html).toContain('<!DOCTYPE html>'); expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>'); expect(html).toContain('</html>');
}); });
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => { it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
await downloadJourneyBookPDF(buildJourney()); await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.close).toHaveBeenCalledTimes(1); const overlay = getOverlay()!;
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
}); });
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney()); await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string; const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road'); expect(html).toContain('Iceland Ring Road');
}); });
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney()); await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string; const html = getIframe()!.srcdoc;
expect(html).toContain('Golden Circle'); expect(html).toContain('Golden Circle');
// Story text is rendered via markdown // Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.'); expect(html).toContain('An incredible day of geysers and waterfalls.');
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] }); const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey); await downloadJourneyBookPDF(journey);
expect(window.open).toHaveBeenCalled(); expect(getOverlay()).not.toBeNull();
const html = mockWindow.document.write.mock.calls[0][0] as string; const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road'); expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present // No entry pages, but cover and closing page are still present
expect(html).toContain('Journey Book'); expect(html).toContain('Journey Book');
+33 -18
View File
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; } .entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
} }
.print-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
.print-bar .btn-save { background: white; color: #0f172a; }
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
</style> </style>
</head> </head>
<body> <body>
<div class="print-bar">
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
<button class="btn-save" onclick="window.print()">Save as PDF</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<!-- Page 1: Cover --> <!-- Page 1: Cover -->
<div class="cover-page"> <div class="cover-page">
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
</body> </body>
</html>` </html>`
const win = window.open('', '_blank') // Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
if (!win) return // This avoids window.open() which Safari iOS blocks in async callbacks
win.document.write(html) // and window.close() which doesn't work reliably in standalone PWA mode.
win.document.close() const overlay = document.createElement('div')
overlay.id = 'journey-pdf-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
header.innerHTML = `
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} &middot; ${totalPages} pages</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
} }
+4 -1
View File
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-027 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => { describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
it('renders a spinner while journey data is loading', () => { it('renders a spinner while journey data is loading', () => {
// Do NOT await the waitFor -- we check the loading state before data arrives // Pre-seed the store into a loading state (current: null, loading: true).
// We can't rely on render() timing because RTL wraps in act(), which flushes
// all microtasks including the MSW response before render() returns.
useJourneyStore.setState({ loading: true, current: null });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
// The spinner has animate-spin class on a div // The spinner has animate-spin class on a div
const spinner = document.querySelector('.animate-spin'); const spinner = document.querySelector('.animate-spin');
+76 -49
View File
@@ -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 }: {
@@ -1547,7 +1565,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery') : t('journey.picker.newGallery')
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */} {/* Header */}
@@ -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>
@@ -2000,7 +2027,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2384,7 +2411,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2481,7 +2508,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
@@ -2738,7 +2765,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
+41
View File
@@ -314,6 +314,47 @@ describe('journeyStore', () => {
expect(storedEntry?.photos[0].id).toBe(201); expect(storedEntry?.photos[0].id).toBe(201);
}); });
// ── loadJourney silent refresh ───────────────────────────────────────────
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
const existing = buildJourneyDetail({ id: 5, title: 'Old' });
useJourneyStore.setState({ current: existing, loading: false });
const loadingValues: boolean[] = [];
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' });
server.use(
http.get('/api/journeys/5', () => HttpResponse.json(refreshed))
);
await useJourneyStore.getState().loadJourney(5);
unsub();
expect(loadingValues.every(v => v === false)).toBe(true);
expect(useJourneyStore.getState().current?.title).toBe('Refreshed');
});
it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => {
const existing = buildJourneyDetail({ id: 5 });
useJourneyStore.setState({ current: existing, loading: false });
const loadingValues: boolean[] = [];
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
const other = buildJourneyDetail({ id: 99 });
server.use(
http.get('/api/journeys/99', () => HttpResponse.json(other))
);
await useJourneyStore.getState().loadJourney(99);
unsub();
expect(loadingValues).toContain(true);
expect(useJourneyStore.getState().current?.id).toBe(99);
expect(useJourneyStore.getState().loading).toBe(false);
});
// ── clear ──────────────────────────────────────────────────────────────── // ── clear ────────────────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-015: clear resets state', () => { it('FE-STORE-JOURNEY-015: clear resets state', () => {
+3 -2
View File
@@ -124,7 +124,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
}, },
loadJourney: async (id) => { loadJourney: async (id) => {
set({ loading: true, notFound: false }) const cold = get().current?.id !== id
if (cold) set({ loading: true, notFound: false })
try { try {
const data = await journeyApi.get(id) const data = await journeyApi.get(id)
set({ current: data }) set({ current: data })
@@ -134,7 +135,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
} }
throw err throw err
} finally { } finally {
set({ loading: false }) if (cold) set({ loading: false })
} }
}, },
+5 -9
View File
@@ -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 ──────────────────────────────────────────────────────────
+1 -1
View File
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
FROM journey_photos jp FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.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; `).get(photoId, row.journey_id) as any;
if (!photo) return null; if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -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);
}); });
}); });
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------ // -- 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( function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,11 +70,13 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number; 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) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?) VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now()); `).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 -------------------------------------------------------------------- // -- Tests --------------------------------------------------------------------
@@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id); 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', () => { describe('validateShareTokenForAsset', () => {