From f6b3931bc40713af84297aa08bc192f7ca6626bb Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 22 Apr 2026 15:56:20 +0200 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20mobile=20public=20share=20?= =?UTF-8?q?=E2=80=94=20remove=20map=20tab=20(#828),=20cap=20timeline=20wid?= =?UTF-8?q?th=20(#827),=20wire=20entry=20click=20(#826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #828: exclude 'map' from availableViews on mobile; MobileMapTimeline already shows combined map+timeline so the standalone map tab is redundant - #827: cap timeline feed column at xl:max-w-[50%] on ≥1280px viewports so the map aside is not dwarfed on wide monitors; applies to both desktop two-column layouts (JourneyPublicPage) - #826: wire MobileMapTimeline onEntryClick to setViewingEntry; render MobileEntryView with readOnly + public photo URL builder so photos load via the share token endpoint; add publicPhotoUrl prop to MobileEntryView so photo URLs are routable for both authenticated and public-share contexts --- .../components/Journey/MobileEntryView.tsx | 10 ++-- client/src/pages/JourneyPublicPage.test.tsx | 58 +++++++++++++++++++ client/src/pages/JourneyPublicPage.tsx | 53 +++++++++++++---- 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index 50be5edf..766f8b7f 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record = { cold: { icon: Snowflake, label: 'Cold' }, } -function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { +function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { + if (builder) return builder(p.photo_id) return `/api/photos/${p.photo_id}/${size}` } interface Props { entry: JourneyEntry readOnly?: boolean + publicPhotoUrl?: (photoId: number) => string onClose: () => void onEdit: () => void onDelete: () => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void } -export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { +export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) { const photos = entry.photos || [] const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null @@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe {photos.length > 0 && (
onPhotoClick(photos, 0)} @@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe {photos.map((p, i) => ( onPhotoClick(photos, i)} diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index 1702a7d0..460e82c4 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({ ), })); +vi.mock('../components/Journey/MobileMapTimeline', () => ({ + default: ({ onEntryClick }: any) => ( +
+ +
+ ), +})); + +const mockIsMobile = { value: false }; +vi.mock('../hooks/useIsMobile', () => ({ + useIsMobile: () => mockIsMobile.value, +})); + import JourneyPublicPage from './JourneyPublicPage'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -106,6 +121,9 @@ const mockJourneyData = { share_gallery: true, share_map: true, }, + gallery: [ + { id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 }, + ], stats: { entries: 2, photos: 1, @@ -136,6 +154,7 @@ function setup404() { beforeEach(() => { resetAllStores(); vi.clearAllMocks(); + mockIsMobile.value = false; }); // ── Tests ──────────────────────────────────────────────────────────────────── @@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => { ], }, ], + gallery: [ + { id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 }, + { id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 }, + { id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 }, + ], stats: { entries: 1, photos: 3, places: 0 }, }; @@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => { expect(statsContainer!.textContent).toContain('7'); }); + // FE-PAGE-PUBLICJOURNEY-019 — bug #828 + it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => { + mockIsMobile.value = true; + setupSuccess(); + render(); + await waitFor(() => { + expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim())); + expect(mapBtn).toBeUndefined(); + }); + + // FE-PAGE-PUBLICJOURNEY-020 — bug #826 + it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => { + const user = userEvent.setup(); + mockIsMobile.value = true; + setupSuccess(); + render(); + await waitFor(() => { + expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); + }); + + // The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked + const openBtn = screen.getByText('Open Entry'); + await user.click(openBtn); + + // MobileEntryView should slide in with the entry title + await waitFor(() => { + expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); + }); + }); + // FE-PAGE-PUBLICJOURNEY-016 it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { const user = userEvent.setup(); diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index b7a9772a..3962d7d9 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' +import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' import { formatLocationName } from '../utils/formatters' import { DAY_COLORS } from '../components/Journey/dayColors' @@ -44,6 +45,17 @@ interface PublicPhoto { caption?: string | null } +interface PublicGalleryPhoto { + id: number + journey_id: number + photo_id: number + provider?: string + asset_id?: string | null + owner_id?: number | null + file_path?: string | null + caption?: string | null +} + const MOOD_CONFIG: Record = { amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, @@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record = { cold: { icon: Snowflake, label: 'Cold' }, } -function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { +function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` } @@ -96,6 +108,7 @@ export default function JourneyPublicPage() { const locale = useSettingsStore(s => s.settings.language) || 'en' const mapRef = useRef(null) const [activeEntryId, setActiveEntryId] = useState(null) + const [viewingEntry, setViewingEntry] = useState(null) const handleMarkerClick = useCallback((entryId: string) => { setActiveEntryId(entryId) @@ -113,21 +126,19 @@ export default function JourneyPublicPage() { }, [token]) const entries = (data?.entries || []) as PublicEntry[] + const gallery = (data?.gallery || []) as PublicGalleryPhoto[] const perms = data?.permissions || {} const journey = data?.journey || {} const stats = data?.stats || {} - const timelineEntries = useMemo( - () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), - [entries], - ) + const timelineEntries = useMemo(() => entries, [entries]) const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const mapEntries = useMemo( () => timelineEntries.filter(e => e.location_lat && e.location_lng), [timelineEntries], ) - const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) + const allPhotos = gallery // Map entries with day color/label for colored markers. // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors @@ -189,7 +200,7 @@ export default function JourneyPublicPage() { const availableViews = [ perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - !desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] // Shared timeline renderer used in both layout modes @@ -402,11 +413,11 @@ export default function JourneyPublicPage() { // Shared gallery renderer const renderGallery = () => (
- {allPhotos.map(({ photo }, idx) => ( + {allPhotos.map((photo, idx) => (
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} + onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} >
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() { // ── Desktop two-column: scrollable timeline feed + sticky map ──────────
{/* Left: feed */} -
+
{renderTabs(availableViews)} {view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'gallery' && perms.share_gallery && renderGallery()} @@ -563,7 +574,7 @@ export default function JourneyPublicPage() { mapEntries={sidebarMapItems as any} dark={document.documentElement.classList.contains('dark')} readOnly - onEntryClick={() => {}} + onEntryClick={(entry) => setViewingEntry(entry as any)} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> @@ -607,6 +618,26 @@ export default function JourneyPublicPage() { onClose={() => setLightbox(null)} /> )} + + {/* Mobile entry detail view (public share) */} + {viewingEntry && ( + `/api/public/journey/${token}/photos/${photoId}/original`} + onClose={() => setViewingEntry(null)} + onEdit={() => {}} + onDelete={() => {}} + onPhotoClick={(photos, idx) => setLightbox({ + photos: photos.map(p => ({ + id: String(p.id), + src: photoUrl(p as any, token!, 'original'), + caption: (p as any).caption ?? null, + })), + index: idx, + })} + /> + )}
) } From 7c9e945b8cffc3bff5e743afc8af73e67b784d38 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 22 Apr 2026 15:56:34 +0200 Subject: [PATCH 02/13] fix: serve real thumbnails for local photos instead of full-resolution originals (#822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add thumbnailService that lazy-generates a WebP thumbnail (800px max, q80) on first GET /api/photos/:id/thumbnail request using sharp. The generated file is stored at uploads/journey/thumbs/.webp and the path is persisted to trek_photos.thumbnail_path so subsequent requests are served directly from disk. Also populates width/height as a side-effect. streamPhoto now branches on kind for local file_path rows — thumbnail requests use the stored/generated thumb path; original requests (and fallback when thumb generation fails) continue to serve the full file. Remote providers (Immich, Synology) are unaffected. --- server/package-lock.json | 575 ++++++++++++++++++ server/package.json | 1 + .../services/memories/photoResolverService.ts | 27 +- .../src/services/memories/thumbnailService.ts | 40 ++ 4 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 server/src/services/memories/thumbnailService.ts diff --git a/server/package-lock.json b/server/package-lock.json index 82696908..7eb10679 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,6 +25,7 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "semver": "^7.7.4", + "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^6.0.2", "undici": "^7.0.0", @@ -132,6 +133,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -560,6 +571,519 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5083,6 +5607,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5766,6 +6334,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/server/package.json b/server/package.json index 197aac87..7ae1cec8 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "semver": "^7.7.4", + "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^6.0.2", "undici": "^7.0.0", diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts index 82d07243..428c1aa4 100644 --- a/server/src/services/memories/photoResolverService.ts +++ b/server/src/services/memories/photoResolverService.ts @@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService'; import { fail, success } from './helpersService'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; import * as photoCache from './trekPhotoCache'; +import { ensureLocalThumbnail } from './thumbnailService'; // ── Lookup / Register ──────────────────────────────────────────────────── @@ -101,7 +102,31 @@ export async function streamPhoto( } if (photo.file_path) { - const localPath = path.join(__dirname, '../../../uploads', photo.file_path); + const uploadsRoot = path.join(__dirname, '../../../uploads'); + + if (kind === 'thumbnail') { + let thumbRel = photo.thumbnail_path ?? null; + if (!thumbRel) { + const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path); + if (result) { + thumbRel = result.thumbnailRelPath; + db.prepare( + 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?' + ).run(thumbRel, result.width, result.height, photo.id); + } + } + if (thumbRel) { + const thumbAbs = path.join(uploadsRoot, thumbRel); + if (fs.existsSync(thumbAbs)) { + res.set('Cache-Control', 'public, max-age=86400, immutable'); + res.sendFile(thumbAbs); + return; + } + } + // Fall through to original if thumbnail unavailable. + } + + const localPath = path.join(uploadsRoot, photo.file_path); if (fs.existsSync(localPath)) { res.set('Cache-Control', 'public, max-age=86400'); res.sendFile(localPath); diff --git a/server/src/services/memories/thumbnailService.ts b/server/src/services/memories/thumbnailService.ts new file mode 100644 index 00000000..9a5d2278 --- /dev/null +++ b/server/src/services/memories/thumbnailService.ts @@ -0,0 +1,40 @@ +import sharp from 'sharp' +import path from 'path' +import fs from 'fs/promises' +import crypto from 'crypto' + +const THUMB_MAX = 800 +const THUMB_QUALITY = 80 + +export async function ensureLocalThumbnail( + uploadsRoot: string, + originalRelPath: string, +): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> { + const originalAbs = path.join(uploadsRoot, originalRelPath) + try { await fs.access(originalAbs) } catch { return null } + + // Deterministic name so concurrent requests don't race on the same photo. + const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16) + const thumbRel = `journey/thumbs/${hash}.webp` + const thumbAbs = path.join(uploadsRoot, thumbRel) + + try { + const [srcStat, dstStat] = await Promise.all([ + fs.stat(originalAbs), + fs.stat(thumbAbs).catch(() => null), + ]) + if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) { + const meta = await sharp(thumbAbs).metadata() + return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } + } + } catch { /* regenerate */ } + + await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) + await sharp(originalAbs) + .rotate() + .resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: THUMB_QUALITY }) + .toFile(thumbAbs) + const meta = await sharp(thumbAbs).metadata() + return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } +} From 71aa8f8051d8cd3ef9d870a149f09ab35ef1003f Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 22 Apr 2026 15:58:31 +0200 Subject: [PATCH 03/13] feat: journey gallery 1-to-N model with M:N entry-photo junction table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old model where journey_photos was keyed per-entry with a per-journey gallery table (one row per unique photo per journey) and a new junction table journey_entry_photos that links gallery photos to entries. Key changes: - Migration 121: renames old journey_photos to journey_photos_old, creates the new gallery table + junction table, backfills both from existing data, drops the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries - journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos, addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts directly into the gallery instead of a wrapper entry - journeyShareService: updates public photo and asset validation queries to join through the gallery table instead of entry_id; getPublicJourney now returns a dedicated gallery array alongside per-entry photos - journey routes: adds gallery upload, provider-photo, and delete endpoints (POST/DELETE /:id/gallery/*); adds unlink-from-entry route (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to accept journey_photo_id with a backwards-compat photo_id alias - types: adds GalleryPhoto interface - client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto, deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId - journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail, uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions - JourneyDetailPage + tests: updated to work with the new gallery model --- client/src/api/client.ts | 6 +- client/src/pages/JourneyDetailPage.test.tsx | 62 ++-- client/src/pages/JourneyDetailPage.tsx | 119 +++----- client/src/store/journeyStore.ts | 65 ++++ server/src/db/migrations.ts | 97 ++++++ server/src/routes/journey.ts | 89 ++++-- server/src/services/journeyService.ts | 288 +++++++++--------- server/src/services/journeyShareService.ts | 45 +-- .../src/services/memories/helpersService.ts | 18 +- server/src/types.ts | 18 ++ 10 files changed, 510 insertions(+), 297 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 1322b8bb..378ddeab 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -356,9 +356,13 @@ export const journeyApi = { // Photos uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), - linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), + linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), + unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data), + deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data), updatePhoto: (photoId: number, data: Record) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 3abdc2e3..abdd1443 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -177,6 +177,24 @@ const mockJourneyDetail = { }, ], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [ + { + id: 100, + journey_id: 1, + photo_id: 100, + provider: 'local', + file_path: 'photos/test.jpg', + asset_id: null, + owner_id: null, + thumbnail_path: null, + caption: 'Colosseum', + sort_order: 0, + width: 800, + height: 600, + shared: 1, + created_at: now, + }, + ], }; // ── MSW Handlers ───────────────────────────────────────────────────────────── @@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => { it('renders the empty gallery state when journey has no photos', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - // Override with entries that have no photos + // Override with entries that have no photos and empty gallery const emptyEntry = { ...mockJourneyDetail.entries[0], photos: [], }; setupDefaultHandlers({ entries: [emptyEntry], + gallery: [], stats: { entries: 1, photos: 0, places: 1 }, }); @@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => { expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); }); - // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo - // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US - const dateOverlay = document.querySelector('[class*="opacity-0"]'); - expect(dateOverlay).toBeTruthy(); + // Gallery photos render in a grid; each photo has a group container + const photos = document.querySelectorAll('[class*="aspect-square"]'); + expect(photos.length).toBeGreaterThanOrEqual(1); }); }); @@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => { setupDefaultHandlers({ entries: [immichEntry, mockJourneyDetail.entries[1]], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [{ + id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null, + asset_id: 'asset-123', owner_id: 1, thumbnail_path: null, + caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, + }], }); render(); @@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => { setupDefaultHandlers({ entries: [synologyEntry, mockJourneyDetail.entries[1]], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [{ + id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null, + asset_id: 'syn-456', owner_id: 1, thumbnail_path: null, + caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, + }], }); render(); @@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { - it('uploading files in gallery creates an entry and uploads photos', async () => { + it('uploading files in gallery calls gallery upload API', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - let createCalled = false; let uploadCalled = false; server.use( - http.post('/api/journeys/1/entries', () => { - createCalled = true; - return HttpResponse.json({ - id: 99, journey_id: 1, author_id: 1, type: 'entry', - entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null, - location_lat: null, location_lng: null, mood: null, weather: null, - tags: [], pros_cons: null, visibility: 'private', sort_order: 0, - entry_time: null, photos: [], created_at: now, updated_at: now, - }); - }), - http.post('/api/journeys/entries/99/photos', () => { + http.post('/api/journeys/1/gallery/photos', () => { uploadCalled = true; - return HttpResponse.json([]); + return HttpResponse.json({ photos: [] }); }), ); @@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => { const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); await user.upload(fileInput, testFile); - await waitFor(() => { - expect(createCalled).toBe(true); - }); await waitFor(() => { expect(uploadCalled).toBe(true); }); @@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => { let deleteCalled = false; server.use( - http.delete('/api/journeys/photos/100', () => { + http.delete('/api/journeys/1/gallery/100', () => { deleteCalled = true; - return HttpResponse.json({ success: true }); + return new HttpResponse(null, { status: 204 }); }), ); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 4c2c3643..e7aa51f9 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -27,7 +27,7 @@ import { import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' -import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' +import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' const GRADIENTS = [ @@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin } } -function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { +function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string { return `/api/photos/${p.photo_id}/${size}` } @@ -341,7 +341,7 @@ export default function JourneyDetailPage() { ) } - const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) + const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() @@ -458,7 +458,7 @@ export default function JourneyDetailPage() { className={ isMobile ? '' - : 'flex-1 overflow-y-auto journey-feed-scroll' + : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll' } >
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() { > setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onRefresh={() => loadJourney(Number(id))} />
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() { entry={editingEntry} journeyId={current.id} tripDates={tripDates} - galleryPhotos={current.entries.flatMap(e => e.photos || [])} + galleryPhotos={current.gallery || []} onClose={() => setEditingEntry(null)} onSave={async (data) => { let entryId = editingEntry.id @@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe // ── Gallery View ────────────────────────────────────────────────────────── -function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { +function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: { entries: JourneyEntry[] + gallery: GalleryPhoto[] journeyId: number userId: number trips: JourneyTrip[] - onPhotoClick: (photos: JourneyPhoto[], index: number) => void + onPhotoClick: (photos: GalleryPhoto[], index: number) => void onRefresh: () => void }) { const { t } = useTranslation() @@ -1009,19 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres })() }, []) - const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] - const seenPhotoIds = new Map() // photo_id → index in allPhotos - for (const e of entries) { - for (const p of e.photos) { - const existing = seenPhotoIds.get(p.photo_id) - if (existing === undefined) { - seenPhotoIds.set(p.photo_id, allPhotos.length) - allPhotos.push({ photo: p, entry: e }) - } else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') { - allPhotos[existing] = { photo: p, entry: e } - } - } - } + const allPhotos = gallery const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) @@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres if (!files?.length) return setGalleryUploading(true) try { - // find existing "Gallery" entry or create one. The stored title is the - // literal 'Gallery' (server-side checks look for this exact string) — - // do not send a translated label here. - let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') - let entryId = galleryEntry?.id - if (!entryId) { - const entry = await journeyApi.createEntry(journeyId, { - title: 'Gallery', - entry_date: new Date().toISOString().split('T')[0], - type: 'entry', - }) - entryId = entry.id - } const formData = new FormData() for (const f of files) formData.append('photos', f) - await journeyApi.uploadPhotos(entryId, formData) + await journeyApi.uploadGalleryPhotos(journeyId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() } catch { @@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres e.target.value = '' } - const handleDeletePhoto = async (photoId: number) => { + const handleDeletePhoto = async (galleryPhotoId: number) => { const store = useJourneyStore.getState() if (!store.current) return - const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId) - if (!target) return - const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id) - // Optimistic update — remove every row with this photo_id - const updated = { - ...store.current, - entries: store.current.entries.map(e => ({ - ...e, - photos: e.photos.filter(p => p.photo_id !== target.photo_id), - })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), - } - useJourneyStore.setState({ current: updated }) + // Optimistic update — remove from gallery and all entry photo lists + useJourneyStore.setState({ + current: { + ...store.current, + gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId), + entries: store.current.entries.map(e => ({ + ...e, + photos: e.photos.filter(p => p.id !== galleryPhotoId), + })), + }, + }) try { - await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) + await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId) } catch { toast.error(t('common.error')) onRefresh() @@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
) : (
- {allPhotos.map(({ photo, entry }, i) => ( + {allPhotos.map((photo, i) => (
onPhotoClick(allPhotos.map(a => a.photo), i)} + onClick={() => onPhotoClick(allPhotos, i)} > {photo.caption}

)} -
- - {new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })} - -
))}
@@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres userId={userId} entries={entriesWithContent} trips={trips} - existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} + existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))} onClose={() => setShowPicker(false)} onAdd={async (groups, entryId) => { - let targetId = entryId - if (!targetId) { - try { - const entry = await journeyApi.createEntry(journeyId, { - title: 'Gallery', - entry_date: new Date().toISOString().split('T')[0], - type: 'entry', - }) - targetId = entry.id - } catch { return } - } let added = 0 for (const group of groups) { try { - const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) - added += result.added || 0 + if (entryId) { + const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase) + added += result.added || 0 + } else { + const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase) + added += result.added || 0 + } } catch {} } if (added > 0) { @@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa entry: JourneyEntry journeyId: number tripDates: Set - galleryPhotos: JourneyPhoto[] + galleryPhotos: GalleryPhoto[] onClose: () => void onSave: (data: Record) => Promise onUploadPhotos: (entryId: number, formData: FormData) => Promise @@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [saving, setSaving] = useState(false) const [uploading, setUploading] = useState(false) - const [photos, setPhotos] = useState(entry.photos || []) + const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [pendingFiles, setPendingFiles] = useState([]) const [pendingLinkIds, setPendingLinkIds] = useState([]) const [showGalleryPick, setShowGalleryPick] = useState(false) @@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa pendingLinkIds.length > 0 ) - const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values()) - const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id)) + const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)) const handleClose = () => { if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return @@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa