diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 998ab45ac..2768c688a 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -1,16 +1,14 @@ import { useEffect, useState, useRef } from 'react' import { RefreshCw, Camera, X, Play } from 'lucide-react' -import { normalizeImageFiles } from '../../utils/convertHeic' -import { isVideoFile } from '../../utils/videoPoster' import { useJourneyStore } from '../../store/journeyStore' import { useTranslation } from '../../i18n' import { journeyApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { getApiErrorMessage } from '../../types' import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker' import { useConnectedPhotoProviders } from './useConnectedPhotoProviders' +import { useGalleryUpload } from './useGalleryUpload' import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import EmptyState from '../shared/EmptyState' @@ -30,8 +28,8 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot const { t } = useTranslation() const [showPicker, setShowPicker] = useState(false) const [pickerProvider, setPickerProvider] = useState(null) - const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null) - const galleryUploading = galleryProgress !== null + const { uploading: galleryUploading, handleGalleryUpload } = + useGalleryUpload({ journeyId, onUploaded: onRefresh, trackProgress: true }) const toast = useToast() // The providers enabled AND connected for the current user, handed up: the page @@ -62,33 +60,6 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot const galleryFileRef = useRef(null) useEffect(() => { onRegisterUpload?.(() => galleryFileRef.current?.click()) }, [onRegisterUpload]) - const handleGalleryUpload = async (e: React.ChangeEvent) => { - const files = e.target.files - if (!files?.length) return - setGalleryProgress({ done: 0, total: files.length }) - try { - // Videos skip HEIC normalization; only images are converted (#823). - const all = Array.from(files) - const videos = all.filter(isVideoFile) - const images = all.filter(f => !isVideoFile(f)) - const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos] - const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, { - onProgress: p => setGalleryProgress({ done: p.done, total: p.total }), - }) - if (failed.length > 0) { - toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) })) - } else { - toast.success(t('journey.photosUploaded', { count: files.length })) - } - onRefresh() - } catch (err) { - toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))) - } finally { - setGalleryProgress(null) - } - e.target.value = '' - } - const handleDeletePhoto = async (galleryPhotoId: number) => { const store = useJourneyStore.getState() if (!store.current) return diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index dae09b97d..ee68f1f44 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -1,10 +1,10 @@ -import { useRef, useState, useEffect, useCallback, useMemo } from 'react' +import { useRef, useEffect } from 'react' import { Plus } from 'lucide-react' import JourneyMap from './JourneyMap' import JourneyEntryCover from './JourneyEntryCover' import JourneyDayScrubber from './JourneyDayScrubber' -import { dayColorOf, journeyDays } from './journeyCard' -import type { JourneyMapHandle } from './JourneyMap' +import { dayColorOf } from './journeyCard' +import { useJourneyCarouselSync } from './useJourneyCarouselSync' import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyTrack } from '@trek/shared' @@ -53,24 +53,10 @@ export default function MobileMapTimeline({ showWeather = true, initialEntryId, }: Props) { - const mapRef = useRef(null) - const carouselRef = useRef(null) - const [activeIndex, setActiveIndex] = useState(0) - - const scrubberDays = useMemo(() => journeyDays(entries), [entries]) - const cardRefs = useRef>(new Map()) - // Sync map focus when carousel scrolls (with guard for uninitialized map) - const syncMapToCarousel = useCallback((index: number) => { - const entry = entries[index] - if (!entry) return - - const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id)) - if (mapEntry) { - try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {} - } else { - try { mapRef.current?.highlightMarker(null) } catch {} - } - }, [entries, mapEntries]) + const { + mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays, + syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, jumpToDay, + } = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick }) // The delayed initial focus reads both through refs so it always works off // the current map entries, not the ones from the render that armed the timer. const syncMapToCarouselRef = useRef(syncMapToCarousel) @@ -78,79 +64,6 @@ export default function MobileMapTimeline({ const activeIndexRef = useRef(activeIndex) activeIndexRef.current = activeIndex - // Pick the card that's currently closest to the carousel horizontal center. - // More stable than IntersectionObserver thresholds when the active card can - // drift toward the viewport edge with proximity snapping. - const pickNearestCard = useCallback(() => { - const el = carouselRef.current - if (!el) return - const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2 - let bestIdx = 0 - let bestDist = Infinity - cardRefs.current.forEach((node, idx) => { - const r = node.getBoundingClientRect() - const cardCenter = r.left + r.width / 2 - const d = Math.abs(cardCenter - containerCenter) - if (d < bestDist) { bestDist = d; bestIdx = idx } - }) - setActiveIndex(prev => { - if (prev !== bestIdx) syncMapToCarousel(bestIdx) - return bestIdx - }) - }, [syncMapToCarousel]) - - // Defer all state updates until scrolling settles — updating activeIndex - // mid-swipe resizes cards (240→320px), causing layout reflow every frame. - useEffect(() => { - const el = carouselRef.current - if (!el || entries.length === 0) return - let settleTimer: number | null = null - const onScroll = () => { - if (settleTimer != null) window.clearTimeout(settleTimer) - settleTimer = window.setTimeout(pickNearestCard, 150) - } - el.addEventListener('scroll', onScroll, { passive: true }) - return () => { - el.removeEventListener('scroll', onScroll) - if (settleTimer != null) window.clearTimeout(settleTimer) - } - }, [entries.length, pickNearestCard]) - - // Scroll a given card into the horizontal center of the carousel - const scrollCardIntoCenter = useCallback((idx: number) => { - const card = cardRefs.current.get(idx) - card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) - }, []) - - // Scroll carousel to entry when map marker is clicked - const handleMarkerClick = useCallback((id: string) => { - const idx = entries.findIndex((e: any) => String(e.id) === id) - if (idx === -1) return - setActiveIndex(idx) - scrollCardIntoCenter(idx) - }, [entries, scrollCardIntoCenter]) - - // Tap on a card: if it's already active, open the edit view; otherwise - // activate + center it first (don't jump straight into the editor). - const handleCardTap = useCallback((entry: any, idx: number) => { - if (idx === activeIndex) { - onEntryClick(entry) - } else { - setActiveIndex(idx) - scrollCardIntoCenter(idx) - } - }, [activeIndex, onEntryClick, scrollCardIntoCenter]) - - // The day bar lands on the first entry of that day, which is where a reader - // who asked for "day nine" means. - const jumpToDay = useCallback((date: string) => { - const idx = entries.findIndex((e: any) => e.entry_date === date) - if (idx === -1) return - setActiveIndex(idx) - syncMapToCarousel(idx) - scrollCardIntoCenter(idx) - }, [entries, scrollCardIntoCenter, syncMapToCarousel]) - // Initial map focus — delay to let Leaflet initialize and fitBounds. Also // re-runs when the markers arrive later than the entries, otherwise the // focus would fire against an empty map and never be retried. diff --git a/client/src/components/Journey/useGalleryUpload.test.ts b/client/src/components/Journey/useGalleryUpload.test.ts new file mode 100644 index 000000000..802aa0a7e --- /dev/null +++ b/client/src/components/Journey/useGalleryUpload.test.ts @@ -0,0 +1,114 @@ +// FE-JRN-GALLERYUPLOAD-001 to FE-JRN-GALLERYUPLOAD-006: the gallery upload behind both +// the desktop gallery and the phone journey screen. +import { act, renderHook } from '@testing-library/react'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { useJourneyStore } from '../../store/journeyStore'; +import { useGalleryUpload, type GalleryUploadOptions } from './useGalleryUpload'; + +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k), + }), +})); +vi.mock('../../utils/convertHeic', () => ({ + normalizeImageFiles: (files: File[]) => Promise.resolve(files.map((f) => new File([f], `${f.name}.jpg`))), +})); + +let addToast: Mock>; +let upload: Mock; +const initial = useJourneyStore.getState(); + +function setup(over: Partial = {}) { + const props: GalleryUploadOptions = { journeyId: 12, onUploaded: vi.fn(), ...over }; + const hook = renderHook((p: GalleryUploadOptions) => useGalleryUpload(p), { initialProps: props }); + return { ...hook, props }; +} + +function changeEvent(files: File[]) { + const target = { files, value: 'C:\\fake\\x' }; + return { event: { target } as unknown as React.ChangeEvent, target }; +} + +beforeEach(() => { + addToast = vi.fn>(); + window.__addToast = addToast; + upload = vi.fn(async () => ({ succeeded: [], failed: [] })); + useJourneyStore.setState({ uploadGalleryPhotos: upload } as never); +}); + +afterEach(() => { + useJourneyStore.setState(initial, true); + delete window.__addToast; +}); + +describe('useGalleryUpload', () => { + it('FE-JRN-GALLERYUPLOAD-001: normalises images, sends videos as they are, toasts and reloads', async () => { + const image = new File(['i'], 'a.heic', { type: 'image/heic' }); + const video = new File(['v'], 'b.mp4', { type: 'video/mp4' }); + const { result, props } = setup(); + const { event, target } = changeEvent([image, video]); + await act(() => result.current.handleGalleryUpload(event)); + const sent = upload.mock.calls[0][1] as File[]; + expect(upload.mock.calls[0]).toHaveLength(2); + expect(upload.mock.calls[0][0]).toBe(12); + expect(sent.map((f) => f.name)).toEqual(['a.heic.jpg', 'b.mp4']); + expect(addToast).toHaveBeenCalledWith('journey.photosUploaded:{"count":2}', 'success', undefined); + expect(props.onUploaded).toHaveBeenCalledTimes(1); + expect(result.current.uploading).toBe(false); + expect(target.value).toBe(''); + }); + + it('FE-JRN-GALLERYUPLOAD-002: the desktop gallery hands the upload a progress callback', async () => { + upload.mockImplementationOnce(async (_id: number, files: File[], cbs?: { onProgress?: (p: unknown) => void }) => { + cbs?.onProgress?.({ done: 1, total: files.length }); + return { succeeded: [], failed: [] }; + }); + const { result } = setup({ trackProgress: true }); + await act(() => result.current.handleGalleryUpload(changeEvent([new File(['i'], 'a.jpg')]).event)); + expect(upload.mock.calls[0]).toHaveLength(3); + expect(result.current.uploading).toBe(false); + }); + + it('FE-JRN-GALLERYUPLOAD-003: a partial failure says how many did not make it, and still reloads', async () => { + const a = new File(['a'], 'a.jpg'); + upload.mockResolvedValueOnce({ succeeded: [], failed: [a] }); + const { result, props } = setup(); + await act(() => result.current.handleGalleryUpload(changeEvent([a, new File(['b'], 'b.jpg')]).event)); + expect(addToast).toHaveBeenCalledWith( + 'journey.editor.uploadPartialFailed:{"failed":"1","total":"2"}', + 'error', + undefined + ); + expect(props.onUploaded).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-GALLERYUPLOAD-004: a failed upload toasts the server message and does not reload', async () => { + upload.mockRejectedValueOnce({ response: { data: { error: 'Quota' } } }); + const { result, props } = setup(); + await act(() => result.current.handleGalleryUpload(changeEvent([new File(['a'], 'a.jpg')]).event)); + expect(addToast).toHaveBeenCalledWith('Quota', 'error', undefined); + expect(props.onUploaded).not.toHaveBeenCalled(); + expect(result.current.uploading).toBe(false); + }); + + it('FE-JRN-GALLERYUPLOAD-005: nothing goes up without files or before the journey is known', async () => { + const empty = setup(); + await act(() => empty.result.current.handleGalleryUpload(changeEvent([]).event)); + const unknown = setup({ journeyId: null }); + const { event, target } = changeEvent([new File(['a'], 'a.jpg')]); + await act(() => unknown.result.current.handleGalleryUpload(event)); + expect(upload).not.toHaveBeenCalled(); + expect(target.value).toBe('C:\\fake\\x'); + }); + + it('FE-JRN-GALLERYUPLOAD-006: the phone screen hands in the toast and translate its page hook holds', async () => { + const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; + const t = vi.fn((k: string) => `view:${k}`); + const { result } = setup({ toast, t } as Partial); + await act(() => result.current.handleGalleryUpload(changeEvent([new File(['a'], 'a.jpg')]).event)); + expect(toast.success).toHaveBeenCalledWith('view:journey.photosUploaded'); + expect(addToast).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Journey/useGalleryUpload.ts b/client/src/components/Journey/useGalleryUpload.ts new file mode 100644 index 000000000..124abad5e --- /dev/null +++ b/client/src/components/Journey/useGalleryUpload.ts @@ -0,0 +1,80 @@ +import type React from 'react'; +import { useState } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useJourneyStore } from '../../store/journeyStore'; +import { getApiErrorMessage } from '../../types'; +import { normalizeImageFiles } from '../../utils/convertHeic'; +import { isVideoFile } from '../../utils/videoPoster'; +import { useToast } from '../shared/Toast'; + +export interface GalleryUploadOptions { + /** The journey the photos go to; nothing is uploaded while it is unknown. */ + journeyId: number | null; + /** Reload the journey once the upload is through, whatever came of it. */ + onUploaded: () => void; + /** + * Hand the upload a progress callback, as the desktop gallery always has. The count + * is only tracked; no view shows it. + */ + trackProgress?: boolean; + /** + * The toast and translate the view already holds. The phone screen passes the ones its + * page hook gives it, as it always did; left out, the hook takes its own. + */ + toast?: ReturnType; + t?: ReturnType['t']; +} + +/** + * Uploading device files into a journey's gallery, behind both the desktop gallery and + * the phone journey screen: images are normalised (HEIC and friends) while videos go up + * as they are (#823), a partial failure says how many did not make it, and the journey + * reloads afterwards. + */ +export function useGalleryUpload({ + journeyId, + onUploaded, + trackProgress = false, + toast: viewToast, + t: viewT, +}: GalleryUploadOptions) { + const own = useTranslation(); + const ownToast = useToast(); + const t = viewT ?? own.t; + const toast = viewToast ?? ownToast; + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + + const handleGalleryUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files?.length || journeyId == null) return; + setProgress({ done: 0, total: files.length }); + try { + const all = Array.from(files); + const videos = all.filter(isVideoFile); + const images = all.filter((f) => !isVideoFile(f)); + const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]; + const store = useJourneyStore.getState(); + const { failed } = trackProgress + ? await store.uploadGalleryPhotos(journeyId, normalized, { + onProgress: (p) => setProgress({ done: p.done, total: p.total }), + }) + : await store.uploadGalleryPhotos(journeyId, normalized); + if (failed.length > 0) { + toast.error( + t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }) + ); + } else { + toast.success(t('journey.photosUploaded', { count: files.length })); + } + onUploaded(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))); + } finally { + setProgress(null); + } + e.target.value = ''; + }; + + return { uploading: progress !== null, handleGalleryUpload }; +} diff --git a/client/src/components/Journey/useJourneyCarouselSync.test.ts b/client/src/components/Journey/useJourneyCarouselSync.test.ts new file mode 100644 index 000000000..5eec893ff --- /dev/null +++ b/client/src/components/Journey/useJourneyCarouselSync.test.ts @@ -0,0 +1,139 @@ +// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-007: the card carousel over the journey map, +// behind both the phone journey screen and the narrow desktop and public timeline. +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JourneyMapHandle } from './JourneyMap'; +import { useJourneyCarouselSync, type JourneyCarouselSyncOptions } from './useJourneyCarouselSync'; + +interface Entry { + id: number; + entry_date: string; +} + +const ENTRIES: Entry[] = [ + { id: 1, entry_date: '2026-05-01' }, + { id: 2, entry_date: '2026-05-01' }, + { id: 3, entry_date: '2026-05-02' }, +]; + +type Options = JourneyCarouselSyncOptions; + +function setup(over: Partial = {}) { + const props: Options = { + entries: ENTRIES, + mapEntries: [{ id: '1' }, { id: '3' }], + onOpenEntry: vi.fn(), + ...over, + }; + const hook = renderHook((p: Options) => useJourneyCarouselSync(p), { initialProps: props }); + const map = { focusMarker: vi.fn(), highlightMarker: vi.fn(), invalidateSize: vi.fn() }; + (hook.result.current.mapRef as { current: JourneyMapHandle | null }).current = map as unknown as JourneyMapHandle; + const cards = ENTRIES.map((_, i) => { + const node = document.createElement('div'); + node.scrollIntoView = vi.fn(); + node.getBoundingClientRect = () => ({ left: i * 100, width: 100 }) as DOMRect; + hook.result.current.cardRefs.current.set(i, node); + return node; + }); + return { ...hook, props, map, cards }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useJourneyCarouselSync', () => { + it('FE-JRN-CAROUSEL-001: groups the scrubber days by date', () => { + const { result } = setup(); + expect(result.current.scrubberDays.map((d) => d.date)).toEqual(['2026-05-01', '2026-05-02']); + }); + + it('FE-JRN-CAROUSEL-002: the map focuses a card with a marker and clears the highlight for one without', () => { + const { result, map } = setup(); + result.current.syncMapToCard(2); + expect(map.focusMarker).toHaveBeenCalledWith('3'); + result.current.syncMapToCard(1); + expect(map.highlightMarker).toHaveBeenCalledWith(null); + result.current.syncMapToCard(9); + expect(map.focusMarker).toHaveBeenCalledTimes(1); + }); + + it('FE-JRN-CAROUSEL-003: a map that is not ready yet is ignored', () => { + const { result, map } = setup(); + map.focusMarker.mockImplementation(() => { + throw new Error('not ready'); + }); + expect(() => result.current.syncMapToCard(0)).not.toThrow(); + }); + + it('FE-JRN-CAROUSEL-004: a marker brings its card to the centre', () => { + const { result, cards } = setup(); + act(() => result.current.handleMarkerClick('3')); + expect(result.current.activeIndex).toBe(2); + expect(cards[2].scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + act(() => result.current.handleMarkerClick('99')); + expect(result.current.activeIndex).toBe(2); + }); + + it('FE-JRN-CAROUSEL-005: a tap opens the active card and centres another, moving the map only on the phone', () => { + const desktop = setup(); + act(() => desktop.result.current.handleCardTap(ENTRIES[0], 0)); + expect(desktop.props.onOpenEntry).toHaveBeenCalledWith(ENTRIES[0]); + act(() => desktop.result.current.handleCardTap(ENTRIES[2], 2)); + expect(desktop.result.current.activeIndex).toBe(2); + expect(desktop.map.focusMarker).not.toHaveBeenCalled(); + + const phone = setup({ syncMapOnTap: true }); + act(() => phone.result.current.handleCardTap(ENTRIES[2], 2)); + expect(phone.map.focusMarker).toHaveBeenCalledWith('3'); + expect(phone.props.onOpenEntry).not.toHaveBeenCalled(); + }); + + it('FE-JRN-CAROUSEL-006: a day on the scrubber lands on its first entry', () => { + const { result, map, cards } = setup(); + act(() => result.current.jumpToDay('2026-05-02')); + expect(result.current.activeIndex).toBe(2); + expect(map.focusMarker).toHaveBeenCalledWith('3'); + expect(cards[2].scrollIntoView).toHaveBeenCalled(); + act(() => result.current.jumpToDay('2030-01-01')); + expect(result.current.activeIndex).toBe(2); + }); + + it('FE-JRN-CAROUSEL-007: once scrolling settles the card nearest the centre becomes active', () => { + const carousel = document.createElement('div'); + carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect; + Object.defineProperty(carousel, 'clientWidth', { value: 500 }); + const hook = renderHook( + (p: Options) => { + const sync = useJourneyCarouselSync(p); + (sync.carouselRef as { current: HTMLDivElement | null }).current = carousel; + return sync; + }, + { initialProps: { entries: [], mapEntries: [{ id: '3' }], onOpenEntry: vi.fn() } } + ); + const map = { focusMarker: vi.fn(), highlightMarker: vi.fn(), invalidateSize: vi.fn() }; + (hook.result.current.mapRef as { current: JourneyMapHandle | null }).current = map as unknown as JourneyMapHandle; + // The listener attaches once there are cards. + hook.rerender({ entries: ENTRIES, mapEntries: [{ id: '3' }], onOpenEntry: vi.fn() }); + ENTRIES.forEach((_, i) => { + const node = document.createElement('div'); + node.getBoundingClientRect = () => ({ left: i * 200, width: 100 }) as DOMRect; + hook.result.current.cardRefs.current.set(i, node); + }); + + carousel.dispatchEvent(new Event('scroll')); + act(() => vi.advanceTimersByTime(100)); + carousel.dispatchEvent(new Event('scroll')); + act(() => vi.advanceTimersByTime(149)); + expect(hook.result.current.activeIndex).toBe(0); + act(() => vi.advanceTimersByTime(1)); + // Centre 250: card 1 spans 200 to 300. + expect(hook.result.current.activeIndex).toBe(1); + expect(map.highlightMarker).toHaveBeenCalledWith(null); + }); +}); diff --git a/client/src/components/Journey/useJourneyCarouselSync.ts b/client/src/components/Journey/useJourneyCarouselSync.ts new file mode 100644 index 000000000..6a8b052e2 --- /dev/null +++ b/client/src/components/Journey/useJourneyCarouselSync.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { JourneyMapHandle } from './JourneyMap'; +import { journeyDays } from './journeyCard'; + +interface CarouselEntry { + id: number | string; + entry_date: string; +} + +export interface JourneyCarouselSyncOptions { + /** The cards in carousel order. */ + entries: E[]; + /** The entries that have a marker on the map. */ + mapEntries: { id: number | string }[]; + /** A tap on the card that is already active opens it. */ + onOpenEntry: (entry: E) => void; + /** A tap on another card also moves the map to it (the phone screen). */ + syncMapOnTap?: boolean; +} + +/** + * The card carousel over the journey map, behind both the phone journey screen and the + * narrow desktop and public timeline: the active card is the one nearest the centre + * once scrolling settles, and the map follows it; a marker, a day on the scrubber or a + * tap brings its card to the centre. + */ +export function useJourneyCarouselSync({ + entries, + mapEntries, + onOpenEntry, + syncMapOnTap = false, +}: JourneyCarouselSyncOptions) { + const mapRef = useRef(null); + const carouselRef = useRef(null); + const cardRefs = useRef>(new Map()); + const [activeIndex, setActiveIndex] = useState(0); + + const scrubberDays = useMemo(() => journeyDays(entries), [entries]); + + // Guarded: the map may not be initialised yet. + const syncMapToCard = useCallback( + (index: number) => { + const entry = entries[index]; + if (!entry) return; + const mapEntry = mapEntries.find((m) => String(m.id) === String(entry.id)); + try { + if (mapEntry) mapRef.current?.focusMarker(String(mapEntry.id)); + else mapRef.current?.highlightMarker(null); + } catch { + /* map not initialised yet */ + } + }, + [entries, mapEntries] + ); + + // Pick the card closest to the horizontal centre. More stable than + // IntersectionObserver thresholds when the active card can drift toward the + // viewport edge with proximity snapping. + const pickNearestCard = useCallback(() => { + const el = carouselRef.current; + if (!el) return; + const center = el.getBoundingClientRect().left + el.clientWidth / 2; + let bestIdx = 0; + let bestDist = Infinity; + cardRefs.current.forEach((node, idx) => { + const r = node.getBoundingClientRect(); + const d = Math.abs(r.left + r.width / 2 - center); + if (d < bestDist) { + bestDist = d; + bestIdx = idx; + } + }); + setActiveIndex((prev) => { + if (prev !== bestIdx) syncMapToCard(bestIdx); + return bestIdx; + }); + }, [syncMapToCard]); + + // Defer every state update until scrolling settles: updating the active card + // mid swipe resizes the cards and reflows the layout on every frame. + useEffect(() => { + const el = carouselRef.current; + if (!el || entries.length === 0) return; + let settleTimer: number | null = null; + const onScroll = () => { + if (settleTimer != null) window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(pickNearestCard, 150); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + if (settleTimer != null) window.clearTimeout(settleTimer); + }; + }, [entries.length, pickNearestCard]); + + const scrollCardIntoCenter = useCallback((idx: number) => { + cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + }, []); + + const handleMarkerClick = useCallback( + (markerId: string) => { + const idx = entries.findIndex((e) => String(e.id) === markerId); + if (idx === -1) return; + setActiveIndex(idx); + scrollCardIntoCenter(idx); + }, + [entries, scrollCardIntoCenter] + ); + + // A tap on the active card opens it; any other card is brought to the centre + // first rather than opened straight away. + const handleCardTap = useCallback( + (entry: E, idx: number) => { + if (idx === activeIndex) { + onOpenEntry(entry); + } else { + setActiveIndex(idx); + scrollCardIntoCenter(idx); + if (syncMapOnTap) syncMapToCard(idx); + } + }, + [activeIndex, onOpenEntry, scrollCardIntoCenter, syncMapOnTap, syncMapToCard] + ); + + // The day bar lands on the first entry of that day, which is where a reader who + // asked for "day nine" means. + const jumpToDay = useCallback( + (date: string) => { + const idx = entries.findIndex((e) => e.entry_date === date); + if (idx === -1) return; + setActiveIndex(idx); + syncMapToCard(idx); + scrollCardIntoCenter(idx); + }, + [entries, scrollCardIntoCenter, syncMapToCard] + ); + + return { + mapRef, + carouselRef, + cardRefs, + activeIndex, + setActiveIndex, + scrubberDays, + syncMapToCard, + handleMarkerClick, + handleCardTap, + jumpToDay, + }; +} diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 2f05ec114..865083da8 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react' import JourneyMap from '../../../components/Journey/JourneyMapAuto' -import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto' import PhotoLightbox from '../../../components/Journey/PhotoLightbox' import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog' import ConfirmDialog from '../../../components/shared/ConfirmDialog' @@ -17,16 +16,15 @@ import { FormSheetHeader } from '../trip/sheets/PlSheetChrome' import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore' import { useAuthStore } from '../../../store/authStore' import { journeyApi, addonsApi, memoriesApi } from '../../../api/client' -import { normalizeImageFiles } from '../../../utils/convertHeic' -import { isVideoFile } from '../../../utils/videoPoster' -import { getApiErrorMessage } from '../../../types' import MSheet from '../../components/MSheet' import MDancingTrek from '../../components/MDancingTrek' import MListRow from '../../components/MListRow' import MToggle from '../../components/MToggle' import JourneyEntryCover from '../../../components/Journey/JourneyEntryCover' import JourneyDayScrubber from '../../../components/Journey/JourneyDayScrubber' -import { dayColorOf, journeyDays } from '../../../components/Journey/journeyCard' +import { dayColorOf } from '../../../components/Journey/journeyCard' +import { useJourneyCarouselSync } from '../../../components/Journey/useJourneyCarouselSync' +import { useGalleryUpload } from '../../../components/Journey/useGalleryUpload' import MJourneyEntrySheet from './MJourneyEntrySheet' import MJourneySettingsSheet from './MJourneySettingsSheet' @@ -61,60 +59,17 @@ export default function MJourneyDetail() { return () => setMobileGalleryOpen(false) }, [view, setMobileGalleryOpen]) - const mapRef = useRef(null) - const carouselRef = useRef(null) - const cardRefs = useRef>(new Map()) - const [activeIndex, setActiveIndex] = useState(0) - - // Stable identity: the scroll effect below re-attaches on every change and - // would otherwise drop the pending settle timer on any unrelated render. + // Stable identity: the scroll effect in useJourneyCarouselSync re-attaches on every + // change and would otherwise drop the pending settle timer on any unrelated render. const entries = useMemo( () => (current?.entries || []).filter(e => !hideSkeletons || e.type !== 'skeleton'), [current?.entries, hideSkeletons], ) - const syncMapToCard = useCallback((index: number) => { - const entry = entries[index] - if (!entry) return - const mapEntry = sidebarMapItems.find(m => String(m.id) === String(entry.id)) - try { - if (mapEntry) mapRef.current?.focusMarker(String(mapEntry.id)) - else mapRef.current?.highlightMarker(null) - } catch { /* map not initialised yet */ } - }, [entries, sidebarMapItems]) - - // Pick the card closest to the horizontal center once scrolling settles. - const pickNearestCard = useCallback(() => { - const el = carouselRef.current - if (!el) return - const center = el.getBoundingClientRect().left + el.clientWidth / 2 - let bestIdx = 0 - let bestDist = Infinity - cardRefs.current.forEach((node, idx) => { - const r = node.getBoundingClientRect() - const d = Math.abs(r.left + r.width / 2 - center) - if (d < bestDist) { bestDist = d; bestIdx = idx } - }) - setActiveIndex(prev => { - if (prev !== bestIdx) syncMapToCard(bestIdx) - return bestIdx - }) - }, [syncMapToCard]) - - useEffect(() => { - const el = carouselRef.current - if (!el || entries.length === 0) return - let settleTimer: number | null = null - const onScroll = () => { - if (settleTimer != null) window.clearTimeout(settleTimer) - settleTimer = window.setTimeout(pickNearestCard, 150) - } - el.addEventListener('scroll', onScroll, { passive: true }) - return () => { - el.removeEventListener('scroll', onScroll) - if (settleTimer != null) window.clearTimeout(settleTimer) - } - }, [entries.length, pickNearestCard]) + const { + mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays, + syncMapToCard, handleMarkerClick, handleCardTap, jumpToDay, + } = useJourneyCarouselSync({ entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true }) // Initial focus — give Leaflet time to initialise and fit bounds first. Opens on // today when today is part of the journey (see openAtEntryId), rather than always @@ -132,12 +87,6 @@ export default function MJourneyDetail() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries.length, openAtEntryId]) - const scrollCardIntoCenter = useCallback((idx: number) => { - cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) - }, []) - - const scrubberDays = useMemo(() => journeyDays(entries), [entries]) - /** * Move an entry within its day, from the entry sheet's header. * @@ -170,31 +119,6 @@ export default function MJourneyDetail() { return { canMoveUp: index > 0, canMoveDown: index >= 0 && index < sameDay.length - 1 } }, [entries]) - /** The day bar lands on the first entry of that day. */ - const jumpToDay = useCallback((date: string) => { - const idx = entries.findIndex(e => e.entry_date === date) - if (idx === -1) return - setActiveIndex(idx) - syncMapToCard(idx) - scrollCardIntoCenter(idx) - }, [entries, scrollCardIntoCenter, syncMapToCard]) - - const handleMarkerClick = useCallback((markerId: string) => { - const idx = entries.findIndex(e => String(e.id) === markerId) - if (idx === -1) return - setActiveIndex(idx) - scrollCardIntoCenter(idx) - }, [entries, scrollCardIntoCenter]) - - const handleCardTap = (entry: JourneyEntry, idx: number) => { - if (idx === activeIndex) setEditingEntry(entry) - else { - setActiveIndex(idx) - scrollCardIntoCenter(idx) - syncMapToCard(idx) - } - } - // Gallery upload — device files plus the connected photo providers (Immich/Synology). const galleryFileRef = useRef(null) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) @@ -216,7 +140,12 @@ export default function MJourneyDetail() { */ const [railHeight, setRailHeight] = useState(0) const railRef = useRef(null) - const [uploading, setUploading] = useState(false) + const { uploading, handleGalleryUpload } = useGalleryUpload({ + journeyId: current?.id ?? null, + onUploaded: () => loadJourney(Number(id)), + toast, + t, + }) useEffect(() => { const node = railRef.current @@ -286,30 +215,6 @@ export default function MJourneyDetail() { return () => { active = false } }, []) - const handleGalleryUpload = async (e: React.ChangeEvent) => { - const files = e.target.files - if (!files?.length || !current) return - setUploading(true) - try { - const all = Array.from(files) - const videos = all.filter(isVideoFile) - const images = all.filter(f => !isVideoFile(f)) - const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos] - const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(current.id, normalized) - if (failed.length > 0) { - toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) })) - } else { - toast.success(t('journey.photosUploaded', { count: files.length })) - } - loadJourney(Number(id)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))) - } finally { - setUploading(false) - } - e.target.value = '' - } - const openLightbox = (photos: GalleryPhoto[], index: number) => { setLightbox({ photos: photos.map(p => ({