mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(journey): geotagged photos on the journey's own map, both renderers (#2453)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
@@ -22,44 +23,6 @@ export interface MapMarkerItem {
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid clustering in screen space.
|
||||
*
|
||||
* The Journey maps have never had clustering, and the library the planner uses
|
||||
* hangs off react-leaflet while this map drives Leaflet directly. Bucketing by
|
||||
* rounded pixel position is a few lines, is deterministic, and is enough for the
|
||||
* job: photos of one place collapse into one thumbnail with a count, and pulling
|
||||
* the map apart separates them again.
|
||||
*/
|
||||
const PHOTO_CLUSTER_PX = 64
|
||||
|
||||
function clusterPhotos(
|
||||
map: L.Map,
|
||||
photos: MapPhoto[],
|
||||
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
||||
const buckets = new Map<string, MapPhoto[]>()
|
||||
for (const photo of photos) {
|
||||
const pt = map.latLngToContainerPoint([photo.lat, photo.lng])
|
||||
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
|
||||
const list = buckets.get(key)
|
||||
if (list) list.push(photo)
|
||||
else buckets.set(key, [photo])
|
||||
}
|
||||
return [...buckets.values()].map(members => ({
|
||||
// Anchor on the first member rather than the centroid: the thumbnail shown is
|
||||
// that photo's, so the pin should point where that picture was taken.
|
||||
lat: members[0].lat,
|
||||
lng: members[0].lng,
|
||||
members,
|
||||
}))
|
||||
}
|
||||
|
||||
function photoMarkerHtml(thumbUrl: string, count: number): string {
|
||||
const badge = count > 1
|
||||
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>`
|
||||
: ''
|
||||
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}`
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -67,13 +30,7 @@ export interface JourneyMapHandle {
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
/** A photo that knows where it was taken (#1614). */
|
||||
export interface MapPhoto {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
thumbUrl: string
|
||||
}
|
||||
export type { MapPhoto } from './journeyPhotoLayer'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
@@ -490,7 +447,7 @@ function JourneyMap(
|
||||
}
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(map, photos)) {
|
||||
for (const cluster of clusterPhotos(photos, (lat, lng) => map.latLngToContainerPoint([lat, lng]))) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { MapPhoto } from './journeyPhotoLayer'
|
||||
|
||||
// Unified handle — both providers expose the same three methods.
|
||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||
@@ -37,6 +38,9 @@ interface Props {
|
||||
hideMarkerTooltip?: boolean
|
||||
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
|
||||
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
|
||||
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
}
|
||||
|
||||
function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026, FE-COMP-JMAPGL-2453
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -32,6 +32,11 @@ interface BoundsStub {
|
||||
const gl = vi.hoisted(() => {
|
||||
const map = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn(() => true),
|
||||
// Container pixels from lng/lat, ten per degree: enough to tell near from far.
|
||||
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
|
||||
remove: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
@@ -424,7 +429,7 @@ describe('JourneyMapGL', () => {
|
||||
|
||||
const firstInner = gl.markers[0].element.querySelector('.trek-journey-marker-inner') as HTMLElement
|
||||
expect(firstInner.style.transform).toBe('scale(1)')
|
||||
expect(gl.markers[0].element.style.zIndex).toBe('0')
|
||||
expect(gl.markers[0].element.style.zIndex).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-019: dark mode tags the popup class on creation', () => {
|
||||
@@ -450,7 +455,7 @@ describe('JourneyMapGL', () => {
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
|
||||
act(() => { ref.current!.highlightMarker('ghost') })
|
||||
expect(gl.popups).toHaveLength(0)
|
||||
expect(gl.markers[0].element.style.zIndex).toBe('')
|
||||
expect(gl.markers[0].element.style.zIndex).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-022: the popup stylesheet is injected exactly once', () => {
|
||||
@@ -500,6 +505,24 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-2453: geotagged photos become clustered thumbnails under the pins and open on a tap', () => {
|
||||
withToken()
|
||||
const onPhotoClick = vi.fn()
|
||||
const photos = [
|
||||
{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' },
|
||||
{ id: 'b', lat: 45.1, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
|
||||
{ id: 'c', lat: 48, lng: 11, thumbUrl: '/api/photos/3/thumbnail' },
|
||||
]
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} onPhotoClick={onPhotoClick} />)
|
||||
const thumbs = gl.markers.filter(m => m.element.innerHTML.includes('/api/photos/'))
|
||||
// a and b fall in one 64px bucket at ten pixels a degree, c in its own.
|
||||
expect(thumbs).toHaveLength(2)
|
||||
expect(thumbs[0].element.innerHTML).toContain('>2<')
|
||||
thumbs[0].element.click()
|
||||
expect(onPhotoClick).toHaveBeenCalledWith(['a', 'b'])
|
||||
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-026: focusMarker on an unknown id is a no-op', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings,
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -47,6 +48,9 @@ interface Props {
|
||||
* card stays the inert label it has always been.
|
||||
*/
|
||||
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
|
||||
/** Photos placed by their own capture coordinates, clustered by proximity (#2453). */
|
||||
photos?: MapPhoto[]
|
||||
onPhotoClick?: (photoIds: string[]) => void
|
||||
paddingBottom?: number
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -128,7 +132,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props,
|
||||
) {
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
@@ -158,6 +162,9 @@ function JourneyMapGL(
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const onPhotoClickRef = useRef(onPhotoClick)
|
||||
onPhotoClickRef.current = onPhotoClick
|
||||
const photoMarkersRef = useRef<{ remove: () => void }[]>([])
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
const mapLangRef = useRef(mapLang)
|
||||
@@ -239,7 +246,7 @@ function JourneyMapGL(
|
||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||
currentInner.style.cssText = nextInner.style.cssText
|
||||
currentInner.innerHTML = nextInner.innerHTML
|
||||
el.style.zIndex = highlighted ? '1000' : '0'
|
||||
el.style.zIndex = highlighted ? '1000' : '1'
|
||||
}, [])
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
@@ -390,6 +397,8 @@ function JourneyMapGL(
|
||||
// markers
|
||||
items.forEach((item) => {
|
||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
|
||||
el.style.zIndex = '1'
|
||||
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
@@ -427,6 +436,42 @@ function JourneyMapGL(
|
||||
}
|
||||
}, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// Photo layer (#2453), the GL twin of the Leaflet one: its own effect so photos
|
||||
// arriving do not rebuild the map, redrawn on every move because the clustering
|
||||
// is done in screen space. Same dependencies as the build effect above, so it
|
||||
// attaches to the map that effect just made.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const clear = () => {
|
||||
photoMarkersRef.current.forEach(m => m.remove())
|
||||
photoMarkersRef.current = []
|
||||
}
|
||||
const draw = () => {
|
||||
clear()
|
||||
if (!photos?.length) return
|
||||
for (const cluster of clusterPhotos(photos, (lat, lng) => map.project([lng, lat]))) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
|
||||
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onPhotoClickRef.current?.(cluster.members.map(m => m.id))
|
||||
})
|
||||
photoMarkersRef.current.push(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([cluster.lng, cluster.lat]).addTo(map))
|
||||
}
|
||||
}
|
||||
if (map.loaded()) draw()
|
||||
else map.once('load', draw)
|
||||
map.on('moveend', draw)
|
||||
return () => {
|
||||
map.off('moveend', draw)
|
||||
map.off('load', draw)
|
||||
clear()
|
||||
}
|
||||
// gl is the injected engine and does not change for the life of a map.
|
||||
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// Switching the UI language has to repin the basemap labels without tearing
|
||||
// the map down. The load handler covers the initial run.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// FE-JOURNEY-PHOTOLAYER-001
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clusterPhotos, photoMarkerHtml } from './journeyPhotoLayer'
|
||||
|
||||
describe('journey photo layer (#2453)', () => {
|
||||
it('FE-JOURNEY-PHOTOLAYER-001: buckets by screen position, anchors on the first photo, badges a count', () => {
|
||||
const photos = [
|
||||
{ id: '1', lat: 1, lng: 1, thumbUrl: '/a.jpg' },
|
||||
{ id: '2', lat: 1.2, lng: 1.2, thumbUrl: '/b.jpg' },
|
||||
{ id: '3', lat: 50, lng: 50, thumbUrl: '/c.jpg' },
|
||||
]
|
||||
const clusters = clusterPhotos(photos, (lat, lng) => ({ x: lng * 10, y: lat * 10 }))
|
||||
expect(clusters.map(c => c.members.map(m => m.id))).toEqual([['1', '2'], ['3']])
|
||||
expect(clusters[0]).toMatchObject({ lat: 1, lng: 1 })
|
||||
expect(photoMarkerHtml('/a b.jpg', 1)).toContain("url('/a%20b.jpg')")
|
||||
expect(photoMarkerHtml('/a.jpg', 1)).not.toContain('</span>')
|
||||
expect(photoMarkerHtml('/a.jpg', 3)).toContain('>3<')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/** A photo that knows where it was taken (#1614). */
|
||||
export interface MapPhoto {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
thumbUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid clustering in screen space, shared by the Leaflet and the GL journey map.
|
||||
*
|
||||
* The Journey maps have never had clustering, and the library the planner uses
|
||||
* hangs off react-leaflet while these maps drive their engines directly.
|
||||
* Bucketing by rounded pixel position is a few lines, is deterministic, and is
|
||||
* enough for the job: photos of one place collapse into one thumbnail with a
|
||||
* count, and pulling the map apart separates them again. `project` is the
|
||||
* engine's own lat/lng to container-pixel conversion.
|
||||
*/
|
||||
export const PHOTO_CLUSTER_PX = 64
|
||||
|
||||
export function clusterPhotos(
|
||||
photos: MapPhoto[],
|
||||
project: (lat: number, lng: number) => { x: number; y: number },
|
||||
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
||||
const buckets = new Map<string, MapPhoto[]>()
|
||||
for (const photo of photos) {
|
||||
const pt = project(photo.lat, photo.lng)
|
||||
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
|
||||
const list = buckets.get(key)
|
||||
if (list) list.push(photo)
|
||||
else buckets.set(key, [photo])
|
||||
}
|
||||
return [...buckets.values()].map(members => ({
|
||||
// Anchor on the first member rather than the centroid: the thumbnail shown is
|
||||
// that photo's, so the pin should point where that picture was taken.
|
||||
lat: members[0].lat,
|
||||
lng: members[0].lng,
|
||||
members,
|
||||
}))
|
||||
}
|
||||
|
||||
export function photoMarkerHtml(thumbUrl: string, count: number): string {
|
||||
const badge = count > 1
|
||||
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>` // theme-lint-disable: map marker chrome, drawn over any basemap
|
||||
: ''
|
||||
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}` // theme-lint-disable: map marker chrome, drawn over any basemap
|
||||
}
|
||||
@@ -57,7 +57,7 @@ function JourneyDetailPageDesktop() {
|
||||
dawarichByDate, dawarichBusyId, acceptDawarich, dismissDawarich,
|
||||
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
|
||||
activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
@@ -668,6 +668,8 @@ function JourneyDetailPageDesktop() {
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
tracks={tracks}
|
||||
photos={mapPhotos}
|
||||
onPhotoClick={openMapPhotos}
|
||||
height={9999}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
|
||||
@@ -336,6 +336,23 @@ describe('useJourneyDetail', () => {
|
||||
expect(latest.sidebarMapItems[0].dayColor).toBe(latest.sidebarMapItems[1].dayColor);
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-2453: geotagged gallery photos become the map layer, and a tap opens them', async () => {
|
||||
serveJourney(buildDetail({
|
||||
gallery: [
|
||||
{ id: 3, photo_id: 30, lat: 48.1, lng: 11.5, caption: 'Marienplatz' },
|
||||
{ id: 4, photo_id: 40, lat: null, lng: null },
|
||||
] as never,
|
||||
}));
|
||||
setup();
|
||||
await waitFor(() => expect(latest.mapPhotos).toHaveLength(1));
|
||||
expect(latest.mapPhotos[0]).toEqual({ id: '3', lat: 48.1, lng: 11.5, thumbUrl: '/api/photos/30/thumbnail' });
|
||||
act(() => latest.openMapPhotos(['3']));
|
||||
expect(latest.lightbox?.photos).toEqual([expect.objectContaining({ id: 3, src: '/api/photos/30/original', caption: 'Marienplatz' })]);
|
||||
act(() => latest.setLightbox(null));
|
||||
act(() => latest.openMapPhotos(['999']));
|
||||
expect(latest.lightbox).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-019: tripDates expands linked trips and skips half-dated ones', async () => {
|
||||
serveJourney(buildDetail({
|
||||
trips: [
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useToast } from '../../components/shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
|
||||
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionModel'
|
||||
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
@@ -419,6 +419,25 @@ export function useJourneyDetail() {
|
||||
})
|
||||
}, [mapEntries, current?.entries])
|
||||
|
||||
/**
|
||||
* The journey's geotagged photos as thumbnails on its map (#2453), the same
|
||||
* layer the shared view has had since #1614. A tap opens the photos of that
|
||||
* cluster in the lightbox.
|
||||
*/
|
||||
const mapPhotos = useMemo(() => (current?.gallery ?? [])
|
||||
.filter(p => typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||
.map(p => ({ id: String(p.id), lat: p.lat as number, lng: p.lng as number, thumbUrl: photoUrl(p, 'thumbnail') })),
|
||||
[current?.gallery])
|
||||
const openMapPhotos = useCallback((ids: string[]) => {
|
||||
const wanted = new Set(ids)
|
||||
const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id)))
|
||||
if (!photos.length) return
|
||||
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, mediaType: p.media_type })),
|
||||
index: 0,
|
||||
})
|
||||
}, [current?.gallery])
|
||||
|
||||
/**
|
||||
* Where a journey should open: on today, when today is part of it.
|
||||
*
|
||||
@@ -544,6 +563,7 @@ export function useJourneyDetail() {
|
||||
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
|
||||
activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
|
||||
mapPhotos, openMapPhotos,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos: providerPhotos.addPickedPhotos,
|
||||
|
||||
@@ -113,6 +113,10 @@ export interface GalleryPhoto {
|
||||
// 'image' (default) or 'video' (#823)
|
||||
media_type?: string | null
|
||||
duration_ms?: number | null
|
||||
/** Where and when it was taken, from EXIF or the provider (#1614); null when unknown. */
|
||||
taken_at?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
|
||||
Reference in New Issue
Block a user