mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
refactor(journey): share the card carousel and the gallery upload between desktop and phone
useJourneyCarouselSync keeps the map on the card nearest the centre once scrolling settles and brings a card to the centre for a marker, a day or a tap, for both the phone journey screen and the narrow timeline of the desktop and public pages. useGalleryUpload normalises and uploads gallery files for the desktop gallery and the phone screen. The phone still moves the map on a tap of another card, the desktop gallery still counts the files up while they go.
This commit is contained in:
@@ -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<string | null>(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<HTMLInputElement>(null)
|
||||
useEffect(() => { onRegisterUpload?.(() => galleryFileRef.current?.click()) }, [onRegisterUpload])
|
||||
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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
|
||||
|
||||
@@ -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<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const scrubberDays = useMemo(() => journeyDays(entries), [entries])
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(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.
|
||||
|
||||
@@ -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<string, unknown>) => (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<NonNullable<Window['__addToast']>>;
|
||||
let upload: Mock;
|
||||
const initial = useJourneyStore.getState();
|
||||
|
||||
function setup(over: Partial<GalleryUploadOptions> = {}) {
|
||||
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<HTMLInputElement>, target };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<NonNullable<Window['__addToast']>>();
|
||||
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<GalleryUploadOptions>);
|
||||
await act(() => result.current.handleGalleryUpload(changeEvent([new File(['a'], 'a.jpg')]).event));
|
||||
expect(toast.success).toHaveBeenCalledWith('view:journey.photosUploaded');
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useToast>;
|
||||
t?: ReturnType<typeof useTranslation>['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<HTMLInputElement>) => {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Entry>;
|
||||
|
||||
function setup(over: Partial<Options> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<E extends CarouselEntry> {
|
||||
/** 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<E extends CarouselEntry>({
|
||||
entries,
|
||||
mapEntries,
|
||||
onOpenEntry,
|
||||
syncMapOnTap = false,
|
||||
}: JourneyCarouselSyncOptions<E>) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null);
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<JourneyMapAutoHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(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<JourneyEntry>({ 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<HTMLInputElement>(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<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 => ({
|
||||
|
||||
Reference in New Issue
Block a user