feat(journey): the marker card, and what testing it turned up

Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
This commit is contained in:
Maurice
2026-09-13 19:52:14 +02:00
parent c2707b7eff
commit 15aefa449e
49 changed files with 706 additions and 267 deletions
+3
View File
@@ -1003,6 +1003,9 @@ export const journeyApi = {
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
@@ -18,12 +18,13 @@ describe('JourneyDayScrubber', () => {
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('FE-JRN-SCRUBBER-002: says which day you are on, and how far in', () => {
it('FE-JRN-SCRUBBER-002: says which day you are on, by its date', () => {
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-16" onPick={() => {}} />)
expect(screen.getByText('Day 2')).toBeInTheDocument()
// The long date is the label the reader was missing on the cards.
// The long date is the label the reader was missing on the cards. "Day 2"
// stood beside it for a while and was the same fact told twice.
expect(screen.getByText(/16/)).toBeInTheDocument()
expect(screen.queryByText(/^Day \d/)).toBeNull()
})
it('FE-JRN-SCRUBBER-003: marks the day it is on', () => {
@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { useTranslation } from '../../i18n'
import type { JourneyDay } from './journeyCard'
@@ -9,7 +10,7 @@ import type { JourneyDay } from './journeyCard'
* second day into the third, and there is no way to reach day nine except by
* swiping past days three to eight (discussion #2299). One segment per day fixes
* both — it says how long the journey is, how far in you are, and it takes you
* anywhere in one tap.
* anywhere in one tap or one drag along it.
*
* The segment colours are the same `DAY_COLORS` the map markers and the card
* edges use, so the bar is a legend for the whole screen rather than a fourth
@@ -24,6 +25,10 @@ interface Props {
export default function JourneyDayScrubber({ days, activeDate, onPick }: Props) {
const { t, locale } = useTranslation()
const barRef = useRef<HTMLDivElement>(null)
const dragging = useRef(false)
const lastPicked = useRef<string | null>(null)
// One day is not a journey to scrub through, and the bar would say nothing the
// card's own date does not already say.
if (days.length < 2) return null
@@ -34,21 +39,59 @@ export default function JourneyDayScrubber({ days, activeDate, onPick }: Props)
const activeIndex = activeDate ? days.findIndex(d => d.date === activeDate) : -1
const shown = activeIndex >= 0 ? days[activeIndex] : days[0]
/**
* The day under a given screen x, measured across the bar.
*
* Measured against the bar even while the finger is on the date above it, so a
* drag that starts on the label still lands on the right day.
*/
const pickAt = (clientX: number) => {
const rect = barRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) return
const ratio = (clientX - rect.left) / rect.width
const index = Math.min(days.length - 1, Math.max(0, Math.floor(ratio * days.length)))
const day = days[index]
if (!day || day.date === lastPicked.current) return
lastPicked.current = day.date
onPick(day.date)
}
return (
<div className="pointer-events-auto px-4 pb-[6px]">
<div className="mb-[5px] flex items-baseline gap-2">
<span className="rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px]">
// touch-none, and the events stop here: the whole thing lies on a map that
// pans, and a drag along the days must not drag the world with it.
<div
className="pointer-events-auto touch-none px-4 pb-[6px] select-none"
onPointerDown={e => {
dragging.current = true
lastPicked.current = shown.date
e.currentTarget.setPointerCapture?.(e.pointerId)
e.stopPropagation()
pickAt(e.clientX)
}}
onPointerMove={e => {
if (!dragging.current) return
e.stopPropagation()
pickAt(e.clientX)
}}
onPointerUp={e => {
dragging.current = false
e.currentTarget.releasePointerCapture?.(e.pointerId)
}}
onPointerCancel={() => { dragging.current = false }}
>
{/* The date, and the handle: press it and slide sideways to run through the
days. "Day 3" stood beside it for a while and was the same fact told
twice, since the bar below already shows how far along that is. */}
<div className="mb-[5px] flex items-baseline">
<span className="cursor-grab rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px] active:cursor-grabbing">
{long(shown.date)}
</span>
{activeIndex >= 0 && (
<span className="text-[10px] font-semibold text-white/70 [text-shadow:0_1px_3px_rgba(0,0,0,.65)]">
{t('journey.detail.day', { number: String(activeIndex + 1) })}
</span>
)}
</div>
{/* Buttons rather than one draggable rail: a 9px-wide drag target on a map that
also pans is a fight, and a tap is the whole gesture anyway. */}
<div className="flex items-end gap-[3px]">
{/* Buttons as well as the drag, because a tap is the whole gesture for most
people and a button is the only version of this a keyboard can reach.
The bar you see is a few pixels tall; the button around it is 20, because
a 3px target on a phone is not a target. */}
<div ref={barRef} className="flex items-stretch gap-[3px]">
{days.map(day => {
const on = day.date === shown.date
return (
@@ -58,14 +101,18 @@ export default function JourneyDayScrubber({ days, activeDate, onPick }: Props)
onClick={() => onPick(day.date)}
aria-label={t('journey.detail.jumpToDay', { date: long(day.date) })}
aria-current={on ? 'true' : undefined}
className="min-w-[6px] flex-1 rounded-full transition-all duration-150"
style={{
height: on ? 6 : 3,
background: day.color,
opacity: on ? 1 : 0.42,
boxShadow: on ? `0 0 0 1.5px rgba(255,255,255,.75)` : '0 0 0 1px rgba(0,0,0,.18)',
}}
/>
className="flex min-w-[6px] flex-1 items-end py-[7px]"
>
<span
className="block w-full rounded-full transition-all duration-150"
style={{
height: on ? 6 : 3,
background: day.color,
opacity: on ? 1 : 0.42,
boxShadow: on ? '0 0 0 1.5px rgba(255,255,255,.75)' : '0 0 0 1px rgba(0,0,0,.18)',
}}
/>
</button>
)
})}
</div>
@@ -8,6 +8,7 @@ import { usePluginStore } from '../../store/pluginStore'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { Tooltip } from '../shared/Tooltip'
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
@@ -233,16 +234,17 @@ export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntr
</span>
)}
{onDismiss && (
<button
type="button"
onClick={e => { e.stopPropagation(); onDismiss() }}
aria-label={t('journey.suggestions.dismiss')}
title={t('journey.suggestions.dismiss')}
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
style={{ color: 'var(--vg-ink3)' }}
>
<X size={14} strokeWidth={2.4} />
</button>
<Tooltip label={t('journey.suggestions.dismiss')} placement="top">
<button
type="button"
onClick={e => { e.stopPropagation(); onDismiss() }}
aria-label={t('journey.suggestions.dismiss')}
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
style={{ color: 'var(--vg-ink3)' }}
>
<X size={14} strokeWidth={2.4} />
</button>
</Tooltip>
)}
</div>
)
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { isVideoFile } from '../../utils/videoPoster'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi, memoriesApi, weatherApi } from '../../api/client'
@@ -355,7 +356,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
/>
<div>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={fileRef} type="file" accept="image/*,video/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button type="button"
@@ -578,7 +579,14 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
))}
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
{/* A clip in an <img> is a broken-image glyph (issue #2341). The
same object URL in a <video> shows its first frame, which is
the preview the poster frame will become after saving. */}
{isVideoFile(f) ? (
<video src={pendingUrls[i]} className="w-full h-full object-cover" muted playsInline preload="metadata" />
) : (
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
)}
<button type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
@@ -34,20 +34,21 @@ describe('JourneyEntryCover', () => {
// wrote something down without taking a picture.
const { container } = render(<JourneyEntryCover entry={buildEntry({ photos: [] })} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
expect(container.querySelector('img')).toBeNull()
// The country's flag is an <img> of its own, so the photo is queried by source.
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
})
it('FE-JRN-COVER-003: the country flag rides in the corner, and only with a country', () => {
it('FE-JRN-COVER-003: the country rides in the corner, and only with a country', () => {
const { rerender } = render(
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(screen.getByText('🇵🇹')).toBeInTheDocument()
expect(screen.getByAltText('PT')).toBeInTheDocument()
rerender(
<JourneyEntryCover entry={buildEntry({ country_code: null })} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
)
expect(screen.queryByText('🇵🇹')).toBeNull()
expect(screen.queryByAltText('PT')).toBeNull()
})
it('FE-JRN-COVER-004: a suggestion says so, so it is not mistaken for somewhere you have been', () => {
@@ -1,7 +1,8 @@
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryFlag, type CardPhoto } from './journeyCard'
import CountryFlag from '../shared/CountryFlag'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
import type { JourneyEntry } from '../../store/journeyStore'
/**
@@ -73,12 +74,15 @@ export default function JourneyEntryCover({
const title = cardTitle(entry, t)
const place = cardPlace(entry)
const flag = countryFlag(entry.country_code)
const date = cardDateLabel(entry.entry_date, locale)
const mood = showMood ? moodMeta(entry.mood) : undefined
const weather = showWeather ? weatherMeta(entry.weather) : undefined
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheet)]' : 'bg-white/90 dark:bg-zinc-800/90'
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
// One ground for every corner mark. Dark rather than a white veil: these sit on
// whatever photograph the traveller took, and a bright sky swallowed the white one.
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
const badgeDot = 'flex h-[18px] w-[18px] items-center justify-center rounded-full bg-black/45 backdrop-blur-[2px]'
return (
<button
@@ -89,13 +93,23 @@ export default function JourneyEntryCover({
// it is somewhere you planned to be, not somewhere you have written about.
className={`relative flex-none overflow-hidden rounded-[18px] text-left transition-[width,height] duration-150 ${
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${
isActive ? 'ring-2 ring-white/85 dark:ring-white/70' : 'ring-1 ring-black/10 dark:ring-white/15'
} ${isSuggestion ? 'opacity-90' : ''}`}
style={src ? undefined : { background: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }}
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
// faint wash of the day's colour.
// The day is the card's own edge rather than a bar along its foot: a hairline
// all the way round groups a day at a glance without taking a strip of the
// photograph. The active card gets a heavier one of the same colour.
//
// Inset, not an outer ring: the carousel scrolls, so it clips, and an outer
// ring lost its top edge to that clip.
style={{
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
}}
>
{src ? (
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
) : (
<span className="absolute inset-0 flex items-center justify-center">
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
@@ -107,31 +121,35 @@ export default function JourneyEntryCover({
<span className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/45 to-transparent" />
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/80 via-black/45 to-transparent" />
{/* Top left: where in the world, and whether this has happened yet. */}
<span className="absolute left-2 top-2 flex items-center gap-1">
{flag && <span className="text-[15px] leading-none drop-shadow-[0_1px_2px_rgba(0,0,0,.5)]">{flag}</span>}
{isSuggestion && (
<span className="rounded-full bg-white/22 px-[7px] py-[2px] text-[9px] font-bold uppercase tracking-[0.06em] text-white backdrop-blur-[2px]">
{t('journey.entry.suggestion')}
</span>
)}
</span>
{/* Just the flag: it is already a coloured mark of its own, and a chip behind
it only added a second shape to read. A drop shadow does the work the chip
was doing, against a bright sky. Sized and placed to sit on the same line
as the date opposite it. */}
{entry.country_code && (
<span className="absolute left-2 top-2 flex h-[18px] items-center drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<CountryFlag code={entry.country_code} size={14} />
</span>
)}
{isSuggestion && (
<span className={`absolute top-2 ${entry.country_code ? 'left-[30px]' : 'left-2'} ${badge} uppercase tracking-[0.06em]`}>
{t('journey.entry.suggestion')}
</span>
)}
{/* Top right: when, plus whatever the journey still keeps of mood and weather. */}
{/* Top right: when, and how it was. They fit beside each other now that the
flag on the left is a bare mark rather than a chip of its own. */}
<span className="absolute right-2 top-2 flex items-center gap-1">
{mood && (
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-white/22 backdrop-blur-[2px]" style={{ color: mood.color }}>
<span className={badgeDot} style={{ color: mood.color }}>
<mood.icon size={11} strokeWidth={2.4} />
</span>
)}
{weather && (
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-white/22 text-white backdrop-blur-[2px]">
<span className={`${badgeDot} text-white`}>
<weather.icon size={11} strokeWidth={2.4} />
</span>
)}
<span className="rounded-full bg-white/22 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]">
{date}
</span>
<span className={badge}>{date}</span>
</span>
{/* Bottom: the name gets the room the thumbnail layout never had. */}
@@ -146,9 +164,6 @@ export default function JourneyEntryCover({
)}
</span>
{/* The day, as a colour. Sits under the text rather than beside it so it costs
the card no width, and reads as one bar across the carousel per day. */}
<span className="absolute inset-x-0 bottom-0 h-[3px]" style={{ background: dayColor }} />
</button>
)
}
+31 -8
View File
@@ -7,16 +7,19 @@ import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapD
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
export interface MapMarkerItem {
id: string
lat: number
lng: number
label: string
locationName: string
mood?: string | null
time: string
dayColor: string
dayLabel: number
photoUrls: string[]
}
/**
@@ -77,10 +80,13 @@ interface MapEntry {
lat: number
lng: number
title?: string | null
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
interface Props {
@@ -122,10 +128,12 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
lat: e.lat,
lng: e.lng,
label: e.title || 'Entry',
locationName: e.location_name || '',
mood: e.mood,
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
photoUrls: e.photoUrls ?? [],
})
}
}
@@ -268,11 +276,14 @@ function JourneyMap(
const map = L.map(containerRef.current, {
...(crs ? { crs } : {}),
zoomControl: false,
attributionControl: true,
// Added below with `prefix: false` so it collapses to the credit alone; see
// the GL twin for why it is not a strip of text across the bottom.
attributionControl: false,
scrollWheelZoom: fullScreen ? true : false,
dragging: true,
touchZoom: true,
})
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
mapRef.current = map
cancelledRef.current = false
@@ -370,14 +381,26 @@ function JourneyMap(
})
const marker = L.marker(pos, { icon }).addTo(map)
// Escaped for the same reason as the track tooltip above: the label is an
// entry title, and this map is what the public journey page renders.
// The same card the GL renderer shows, from the same builder: which map
// engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came
// from a person, which matters most here — this map is what the public
// journey page renders.
if (!hideMarkerTooltipRef.current) {
marker.bindTooltip(escapeHtml(item.label), {
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip',
})
ensureJourneyPopupStyle()
marker.bindTooltip(
journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
}),
{
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip trek-journey-tooltip',
},
)
}
marker.on('click', () => {
@@ -35,6 +35,8 @@ interface Props {
fullScreen?: boolean
paddingBottom?: number
hideMarkerTooltip?: boolean
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
}
function JourneyMapAuto({ ref, ...props }: Props) {
+44 -124
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
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'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -21,6 +22,8 @@ interface MapEntry {
entry_date: string
dayColor?: string
dayLabel?: number
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
photoUrls?: string[]
}
interface Props {
@@ -37,6 +40,13 @@ interface Props {
fullScreen?: boolean
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
hideMarkerTooltip?: boolean
/**
* Open the entry's photos from the card's thumbnail strip.
*
* Wiring it is what makes the strip take the pointer at all; without it the
* card stays the inert label it has always been.
*/
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
paddingBottom?: number
glProvider?: GlMapProvider
/**
@@ -57,6 +67,7 @@ interface Item {
time: string
dayColor: string
dayLabel: number
photoUrls: string[]
}
const MARKER_W = 28
@@ -75,6 +86,7 @@ function buildItems(entries: MapEntry[]): Item[] {
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
photoUrls: e.photoUrls ?? [],
})
}
}
@@ -82,109 +94,6 @@ function buildItems(entries: MapEntry[]): Item[] {
return items
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function formatEntryDate(iso: string): string {
if (!iso) return ''
try {
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
if (Number.isNaN(d.getTime())) return iso
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
} catch {
return iso
}
}
// Inject the popup styles once per document. Two-line frosted-glass card in
// the Apple/Google Maps idiom — title on top, location / date subtly below.
function ensureJourneyPopupStyle() {
if (document.getElementById('trek-journey-popup-style')) return
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
font-family:var(--font-system);
min-width: 160px;
max-width: 280px;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: #18181B;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
.trek-journey-popup-sub {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 3px;
font-size: 11.5px;
color: #71717A;
line-height: 1.35;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
.trek-journey-popup-place {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.trek-journey-popup-sep {
flex: 0 0 auto;
opacity: 0.55;
font-weight: 500;
}
.trek-journey-popup-date { flex: 0 0 auto; }
@keyframes trek-journey-popup-in {
from { opacity: 0; }
to { opacity: 1; }
}
`
document.head.appendChild(s)
}
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
const fill = dayColor
const textColor = '#fff'
@@ -219,10 +128,14 @@ 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, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, ref }: Props,
) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
onMarkerPhotoClickRef.current = onMarkerPhotoClick
/** Which entry the card is currently describing, for the delegated photo click. */
const popupItemIdRef = useRef<string | null>(null)
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
@@ -259,33 +172,27 @@ function JourneyMapGL(
ensureJourneyPopupStyle()
// Primary line: user-given title. If none, fall back to the location
// name so we always show *something* useful on the top line.
const primaryRaw = item.label || item.locationName || 'Entry'
const secondaryPlace = item.label ? item.locationName : ''
const dateStr = formatEntryDate(item.time)
const primary = escapeHtml(primaryRaw)
const place = escapeHtml(secondaryPlace)
const date = escapeHtml(dateStr)
const html = journeyPopupHtml({
title: item.label || item.locationName || 'Entry',
place: item.label ? item.locationName : '',
date: formatMarkerDate(item.time),
photoUrls: item.photoUrls,
})
const subParts: string[] = []
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
const subline = subParts.length === 2
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
: subParts.join('')
const html = `
<div class="trek-journey-popup-title">${primary}</div>
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
`
// Marker is bottom-anchored with a visible height of 36px (1.2× on
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
const offset: [number, number] = [0, -46]
popupItemIdRef.current = item.id
const interactive = !!onMarkerPhotoClickRef.current && item.photoUrls.length > 0
if (popupRef.current) {
popupRef.current.setLngLat([item.lng, item.lat])
popupRef.current.setHTML(html)
popupRef.current.setOffset(offset)
const el = popupRef.current.getElement()
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
if (el) {
el.classList.toggle('trek-dark', !!darkRef.current)
el.classList.toggle('is-interactive', interactive)
}
} else {
popupRef.current = new gl.Popup({
closeButton: false,
@@ -293,12 +200,22 @@ function JourneyMapGL(
closeOnMove: false,
anchor: 'bottom',
offset,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}`,
maxWidth: '280px',
})
.setLngLat([item.lng, item.lat])
.setHTML(html)
.addTo(mapRef.current)
// Delegated, and bound once for the popup's whole life: setHTML replaces the
// strip on every entry, so a listener on the thumbnails themselves would have
// to be rebound each time and would leak the ones it forgot.
popupRef.current.getElement()?.addEventListener('click', (ev: Event) => {
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
const entryId = popupItemIdRef.current
if (!shot || !entryId) return
ev.stopPropagation()
onMarkerPhotoClickRef.current?.(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
})
}
}, [gl])
@@ -377,7 +294,10 @@ function JourneyMapGL(
center: hasPoints ? bounds.getCenter() : [0, 30],
zoom: hasPoints ? 2 : 1,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
attributionControl: true,
// Collapsed to its ⓘ button rather than a strip of text: the phone lays a card
// carousel across the bottom of this map and the strip read through it. The
// credit stays one tap away, which is what the OSM licence asks for.
attributionControl: { compact: true },
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
@@ -122,7 +122,7 @@ describe('MobileMapTimeline', () => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument()
// The strip below the map grew with the cover cards and the day bar (#2299), so
// the map keeps that much clear when it frames the journey.
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 250, hideMarkerTooltip: true })
expect(lastMapProps.current).toMatchObject({ height: 9999, fullScreen: true, paddingBottom: 250 })
expect(screen.getByText('Louvre')).toBeInTheDocument()
expect(screen.getByText('Museumsinsel')).toBeInTheDocument()
expect(screen.getByText('Reichstag')).toBeInTheDocument()
@@ -134,11 +134,11 @@ describe('MobileMapTimeline', () => {
// survives is the colour, on a hairline at the foot of each card.
const { container } = renderTimeline()
const { cards } = carouselOf(container)
const dayBar = (card: HTMLElement) => [...card.querySelectorAll('span')].at(-1) as HTMLElement
const ring = (card: HTMLElement) => (card.querySelector('button') as HTMLElement).style.boxShadow
expect(dayBar(cards[0])).toHaveStyle({ background: DAY_COLORS[0] })
expect(dayBar(cards[1])).toHaveStyle({ background: DAY_COLORS[1] })
expect(dayBar(cards[2])).toHaveStyle({ background: DAY_COLORS[1] })
expect(ring(cards[0])).toContain(DAY_COLORS[0])
expect(ring(cards[1])).toContain(DAY_COLORS[1])
expect(ring(cards[2])).toContain(DAY_COLORS[1])
})
it('FE-COMP-JMAPTL-002b: the day bar offers one segment per day and jumps to it', async () => {
@@ -228,7 +228,6 @@ export default function MobileMapTimeline({
fullScreen
paddingBottom={250}
cartoApiKey={cartoApiKey}
hideMarkerTooltip
/>
{/* Day bar + card carousel, as one block at the bottom of the map */}
@@ -1,26 +1,25 @@
// FE-JRN-CARDMODEL-001 to FE-JRN-CARDMODEL-010
import { describe, it, expect } from 'vitest'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryFlag, dayColorOf, journeyDays } from './journeyCard'
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, countryBadge, dayColorOf, journeyDays } from './journeyCard'
import { DAY_COLORS } from './dayColors'
const t = (key: string) => key
describe('countryFlag', () => {
it('FE-JRN-CARDMODEL-001: turns an ISO code into the flag', () => {
expect(countryFlag('DE')).toBe('🇩🇪')
expect(countryFlag('jp')).toBe('🇯🇵')
describe('countryBadge', () => {
it('FE-JRN-CARDMODEL-001: stamps the ISO code, upper case', () => {
// Letters, not the flag emoji: Windows has no flag glyphs and drew the bare
// code anyway, which read as something broken.
expect(countryBadge('DE')).toBe('DE')
expect(countryBadge('jp')).toBe('JP')
})
it('FE-JRN-CARDMODEL-002: draws nothing rather than two stray letters', () => {
// Everything that is not a plain alpha-2 pair, plus Kosovo, which has no flag
// on any platform and would render as the letters XK in a corner.
expect(countryFlag(null)).toBe('')
expect(countryFlag(undefined)).toBe('')
expect(countryFlag('')).toBe('')
expect(countryFlag('DEU')).toBe('')
expect(countryFlag('D1')).toBe('')
expect(countryFlag('XK')).toBe('')
it('FE-JRN-CARDMODEL-002: stamps nothing when there is no country to stamp', () => {
expect(countryBadge(null)).toBe('')
expect(countryBadge(undefined)).toBe('')
expect(countryBadge('')).toBe('')
expect(countryBadge('DEU')).toBe('')
expect(countryBadge('D1')).toBe('')
})
})
+8 -10
View File
@@ -23,19 +23,17 @@ export function cardPhotoId(photo: CardPhoto | undefined): number | undefined {
}
/**
* The flag of the country an entry happened in.
* The country an entry happened in, as the two letters to stamp on its card.
*
* Two regional-indicator code points, which every platform we ship to renders as
* a flag and every other one renders as the country's two letters — a fair
* fallback either way. Anything that is not a plain ISO 3166-1 alpha-2 pair
* (including the UN's `XK` for Kosovo, which has no flag) draws nothing rather
* than two stray letters in a corner.
* Letters rather than the flag emoji: the two regional-indicator code points
* that make a flag are only drawn as one on Apple platforms and Android. Windows
* has never shipped the glyphs, so a card there showed a bare "DE" where a flag
* was clearly meant — which reads as something broken rather than as a country.
* The code reads the same everywhere, and in a chip it looks deliberate.
*/
export function countryFlag(code: string | null | undefined): string {
export function countryBadge(code: string | null | undefined): string {
if (!code || !/^[A-Za-z]{2}$/.test(code)) return ''
const upper = code.toUpperCase()
if (upper === 'XK') return ''
return String.fromCodePoint(...[...upper].map(c => 0x1f1e6 + c.charCodeAt(0) - 65))
return code.toUpperCase()
}
/** Short date for the card corner: "12 Sep", in the reader's locale and order. */
@@ -0,0 +1,217 @@
import { escapeHtml } from '@trek/shared'
/**
* The card a journey marker shows: what the entry is called, where and when, and
* a glimpse of its photos.
*
* One module for both renderers. Leaflet had a plain one-line label and the GL
* map a two-line card, so the same marker said different things depending on a
* setting nobody links to the journey — and adding the photo strip to only one
* of them would have widened that split (discussion #2299).
*
* It is an HTML string rather than a component because both map libraries take
* markup, not React: MapLibre through `Popup.setHTML`, Leaflet through
* `bindTooltip`. Everything that comes from a person — a title, a place — is
* escaped here, once, because this markup is also what the public journey page
* hands to strangers.
*/
export interface JourneyPopupContent {
title: string
place?: string
date?: string
/** Already-resolved thumbnail URLs. The share view signs its own, so the caller builds them. */
photoUrls?: string[]
}
/**
* The attribute a clickable thumbnail carries, holding its index in the entry.
*
* A delegated listener on the popup reads it: the card is an HTML string handed
* to a map library, so there is no React tree to hang an onClick on.
*/
export const SHOT_INDEX_ATTR = 'data-shot'
/** At most this many thumbnails; beyond three the strip stops being a glimpse. */
const MAX_PHOTOS = 3
/**
* The short date for a marker card: "12 Sep", in the reader's own order.
*
* Takes the entry's stored date, which is a plain day with no clock, and pins it
* to local midnight — parsed bare, a `YYYY-MM-DD` is UTC and slides a day back
* for every reader west of Greenwich.
*/
export function formatMarkerDate(iso: string): string {
if (!iso) return ''
try {
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
if (Number.isNaN(d.getTime())) return iso
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
} catch {
return iso
}
}
export function journeyPopupHtml({ title, place, date, photoUrls = [] }: JourneyPopupContent): string {
const shots = photoUrls.slice(0, MAX_PHOTOS)
// Columns from the count, not a fixed three: two pictures should fill the card,
// not sit next to a gap where a third would have gone.
const photos = shots.length
? `<div class="trek-journey-popup-shots${shots.length === 1 ? ' is-single' : ''}" style="grid-template-columns:repeat(${shots.length},1fr)">${shots
.map((url, i) => `<span class="trek-journey-popup-shot" ${SHOT_INDEX_ATTR}="${i}" style="background-image:url('${escapeHtml(url)}')"></span>`)
.join('')}</div>`
: ''
const chips: string[] = []
if (place) chips.push(`<span class="trek-journey-popup-chip trek-journey-popup-place">${PIN_ICON}<span>${escapeHtml(place)}</span></span>`)
if (date) chips.push(`<span class="trek-journey-popup-chip trek-journey-popup-date">${CAL_ICON}<span>${escapeHtml(date)}</span></span>`)
return `${photos}
<div class="trek-journey-popup-title">${escapeHtml(title)}</div>
${chips.length ? `<div class="trek-journey-popup-sub">${chips.join('')}</div>` : ''}`
}
// Inline rather than the lucide components: this markup lives outside React's tree.
const PIN_ICON =
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>'
const CAL_ICON =
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
/**
* Inject the card's styles once per document.
*
* A stylesheet rather than inline styles because the card has states (dark, the
* single-photo strip) and two hosts. `.trek-journey-popup` is MapLibre's popup
* shell; `.trek-journey-tooltip` is Leaflet's, whose own chrome is stripped back
* so the card inside provides all of it.
*/
export function ensureJourneyPopupStyle(): void {
if (document.getElementById('trek-journey-popup-style')) return
const s = document.createElement('style')
s.id = 'trek-journey-popup-style'
s.textContent = `
.mapboxgl-popup.trek-journey-popup,
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
/* The card as a whole never takes the pointer — it sits over the map and would
swallow drags. The photo strip is the one exception, and only where the host
wired a handler for it. */
.trek-journey-popup.is-interactive .trek-journey-popup-shots { pointer-events: auto; cursor: zoom-in; }
.trek-journey-popup.is-interactive .trek-journey-popup-shot:hover { filter: brightness(1.06); }
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content,
.leaflet-tooltip.trek-journey-tooltip {
padding: 9px 14px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
font-family: var(--font-system);
min-width: 160px;
max-width: 280px;
white-space: normal;
}
/* Leaflet paints its own tip and font on the tooltip; the card supplies both. */
.leaflet-tooltip.trek-journey-tooltip { color: #18181B; font-weight: 400; }
.leaflet-tooltip.trek-journey-tooltip::before { display: none; }
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content,
.dark .leaflet-tooltip.trek-journey-tooltip {
background: rgba(24, 24, 27, 0.88);
border-color: rgba(255, 255, 255, 0.08);
color: #FAFAFA;
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
border-top-color: rgba(255, 255, 255, 0.94);
border-bottom-color: rgba(255, 255, 255, 0.94);
}
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
.maplibregl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
border-top-color: rgba(24, 24, 27, 0.88);
border-bottom-color: rgba(24, 24, 27, 0.88);
}
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
/* A glimpse of the entry's own pictures, above its name. Backgrounds rather
than <img>: no layout shift while they load, and no broken-image glyph if
one 404s — the strip just stays the placeholder grey.
Out to the card's edges via negative margins, and the rounding lives on the
strip rather than on each tile: the two corners that meet the card's top are
round, the edges where two pictures touch stay square. */
.trek-journey-popup-shots {
display: grid;
gap: 2px;
margin: -9px -14px 9px;
border-radius: 13px 13px 0 0;
overflow: hidden;
}
.trek-journey-popup-shot {
display: block;
height: 62px;
background-color: rgba(0, 0, 0, 0.06);
background-size: cover;
background-position: center;
}
.trek-journey-popup-shots.is-single .trek-journey-popup-shot { height: 96px; }
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-shot,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-shot,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-shot {
background-color: rgba(255, 255, 255, 0.08);
}
.trek-journey-popup-title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: #18181B;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-title { color: #FAFAFA; }
/* Place and date as their own chips rather than one grey line divided by a
middle dot: two different kinds of fact should not read as one sentence. */
.trek-journey-popup-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
line-height: 1;
}
.trek-journey-popup-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.05);
color: #52525B;
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
}
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-chip,
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-chip,
.dark .leaflet-tooltip.trek-journey-tooltip .trek-journey-popup-chip {
background: rgba(255, 255, 255, 0.1);
color: #D4D4D8;
}
.trek-journey-popup-chip svg { flex: 0 0 auto; opacity: 0.7; }
.trek-journey-popup-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.trek-journey-popup-place { min-width: 0; }
.trek-journey-popup-date { flex: 0 0 auto; }
@keyframes trek-journey-popup-in {
from { opacity: 0; }
to { opacity: 1; }
}
`
document.head.appendChild(s)
}
@@ -0,0 +1,47 @@
import { useState } from 'react'
import { countryCodeToFlag } from '../../pages/atlas/atlasModel'
/**
* A country's flag, drawn on every platform.
*
* The emoji alone is not enough. A flag is two regional-indicator code points,
* and Windows has never shipped glyphs for the pairs — Chrome and Edge there
* fall back to drawing the two letters, so `DE` appears where a flag was meant
* and reads as something broken. Atlas has always had that, and the journey
* cards inherited it.
*
* So the emoji is turned into its Twemoji image, the same way the collaboration
* chat draws emoji (`CollabChatTwemojiImg`). When that image cannot be fetched —
* offline, or an install that blocks the CDN — the emoji itself is rendered
* instead, which is the old behaviour: a flag on Apple and Android, two letters
* on Windows. Nothing is worse than it was, and on most screens it is a flag.
*/
export default function CountryFlag({ code, size = 14, title }: { code: string | null | undefined; size?: number; title?: string }) {
const [failed, setFailed] = useState(false)
if (!code || !/^[A-Za-z]{2}$/.test(code)) return null
const emoji = countryCodeToFlag(code)
if (!emoji) return null
if (failed) {
return (
<span aria-hidden style={{ fontSize: size, lineHeight: 1 }} title={title}>
{emoji}
</span>
)
}
const cp = [...code.toUpperCase()]
.map(c => (0x1f1e6 + c.charCodeAt(0) - 65).toString(16))
.join('-')
return (
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
alt={code.toUpperCase()}
title={title}
draggable={false}
loading="lazy"
onError={() => setFailed(true)}
style={{ width: size, height: size, display: 'block', borderRadius: 2 }}
/>
)
}
+8 -3
View File
@@ -308,15 +308,20 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
}
/* ── Press-Feedback + bessere Easings (Emil Kowalski) ─────────── */
/* Buttons sollen antworten wenn sie gedrückt werden. */
/* Buttons sollen antworten wenn sie gedrückt werden.
Map-Marker sind ausgenommen: MapLibre und Mapbox geben ihrem Marker-Element
role="button" und schreiben dessen `transform` bei jedem Frame einer Bewegung
neu. Mit einem Übergang darauf hinkt die Nadel dem Kartenzug hinterher und
springt am Ende an ihren Ort zurück, und ein Klick würde den scale(0.97) über
die Positionierung der Bibliothek legen. */
button:not(:disabled):not([data-no-press]),
[role="button"]:not([aria-disabled="true"]):not([data-no-press]) {
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):not(.maplibregl-marker):not(.mapboxgl-marker) {
transition-property: transform, color, background-color, border-color, box-shadow, opacity, filter !important;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
button:not(:disabled):not([data-no-press]):active,
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active {
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):not(.maplibregl-marker):not(.mapboxgl-marker):active {
transform: scale(0.97);
transition-duration: 80ms;
}
@@ -345,7 +345,6 @@ export default function MJourneyDetail() {
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={250}
hideMarkerTooltip
/>
</div>
@@ -483,9 +482,12 @@ export default function MJourneyDetail() {
</MSheet>
)}
{/* Day bar + horizontal card timeline */}
{/* Day bar + horizontal card timeline. The dock is a floating pill 62px tall
inside the 84px the variable reserves, so +16 left a band of map doing
nothing between the cards and it; two pixels keeps a hair of daylight and
gives the strip the rest. */}
{view === 'timeline' && entries.length > 0 && (
<div className="absolute left-0 right-0 z-[8] bottom-[calc(var(--bottom-nav-h,84px)+16px)]">
<div className="absolute left-0 right-0 z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
@@ -520,7 +522,10 @@ export default function MJourneyDetail() {
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={handleGalleryUpload} />
{/* Upload source chooser (device / providers) */}
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" ariaLabel={t('common.upload')}>
{/* Opaque, not the glass default: these two sit over a full-screen map whose
card carousel is bright and busy, and the frosted material let the titles
behind read straight through the menu. */}
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="opaque" ariaLabel={t('common.upload')}>
<div className="flex flex-col gap-2 p-[10px]">
<MListRow
icon={Camera}
@@ -539,7 +544,7 @@ export default function MJourneyDetail() {
</MSheet>
{/* Journey actions: book export, suggestions switch, settings */}
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" ariaLabel={t('files.menu')}>
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="opaque" ariaLabel={t('files.menu')}>
<div className="flex flex-col gap-1 p-[10px]">
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
@@ -11,6 +11,7 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
import { isVideoFile } from '../../../utils/videoPoster'
import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
@@ -430,7 +431,7 @@ export default function MJourneyEntrySheet({
{!readOnly && (
<>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} />
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} />
<input ref={fileRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} />
<div className="flex gap-2">
<button
type="button"
@@ -656,9 +657,14 @@ export default function MJourneyEntrySheet({
)}
</div>
))}
{pendingFiles.map((_, i) => (
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="relative h-16 w-16 overflow-hidden rounded-[13px]">
<img src={pendingPreviews[i]} alt="" className="h-full w-full object-cover" />
{/* A clip in an <img> is a broken-image glyph (issue #2341). */}
{isVideoFile(f) ? (
<video src={pendingPreviews[i]} className="h-full w-full object-cover" muted playsInline preload="metadata" />
) : (
<img src={pendingPreviews[i]} alt="" className="h-full w-full object-cover" />
)}
<button
type="button"
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
@@ -984,9 +990,9 @@ export default function MJourneyEntrySheet({
<button
type="button"
onClick={() => setCaptureOnly(false)}
className="rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
className="whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
>
{t('collections.addDetails')}
{t('journey.editor.addDetails')}
</button>
)}
<button
+54 -22
View File
@@ -6,6 +6,7 @@ import { DAY_COLORS } from '../components/Journey/dayColors'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { Tooltip } from '../components/shared/Tooltip'
import EmptyState from '../components/shared/EmptyState'
import { Outlet } from 'react-router'
import {
@@ -344,7 +345,7 @@ function JourneyDetailPageDesktop() {
<div className={isMobile ? 'px-4' : ''}>
<div>
{/* View Controls — hidden on mobile (floating top bar has them) */}
<div className={`flex items-center justify-between mt-5 mb-5 ${isMobileChromeless ? 'hidden' : ''}`}>
<div className={`flex items-center justify-between gap-3 mt-5 mb-5 ${isMobileChromeless ? 'hidden' : ''}`}>
<div className="flex items-center gap-1 p-1 rounded-full" style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}>
{(isMobile
? [
@@ -370,13 +371,14 @@ function JourneyDetailPageDesktop() {
))}
</div>
{/* Search. A journey kept over a season is a very long scroll, and the
wheel was the only way through it (discussion #2299). Grows on focus
rather than standing open at full width, so it costs the row nothing
until it is used. */}
wheel was the only way through it (discussion #2299). It takes the gap
the row already had between the tabs and the Add button rather than a
width of its own, so the placeholder is never clipped and the two
things either side of it stay where they were. */}
{view === 'timeline' && !isMobile && (
<div
className="inline-flex items-center gap-1.5 h-9 rounded-full px-3 transition-[width] duration-200"
style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)', width: query ? 240 : 150 }}
className="inline-flex h-9 min-w-[150px] max-w-[420px] flex-1 items-center gap-1.5 rounded-full px-3.5"
style={{ background: 'var(--vg-surf2)', border: '1px solid var(--vg-line)' }}
>
<Search size={14} style={{ color: 'var(--vg-ink3)' }} className="flex-shrink-0" />
<input
@@ -451,15 +453,16 @@ function JourneyDetailPageDesktop() {
started on today, so putting something into an earlier day
meant correcting the date by hand (discussion #2299). */}
{canEditEntries && (
<button type="button"
onClick={() => setEditingEntry(createDraftJourneyEntry(current.id, new Date(), date))}
aria-label={t('journey.detail.addOnThisDay')}
title={t('journey.detail.addOnThisDay')}
className="w-7 h-7 rounded-full flex items-center justify-center transition-colors hover:bg-surface-hover"
style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
>
<Plus size={14} strokeWidth={2.6} />
</button>
<Tooltip label={t('journey.detail.addOnThisDay')} placement="top">
<button type="button"
onClick={() => setEditingEntry(createDraftJourneyEntry(current.id, new Date(), date))}
aria-label={t('journey.detail.addOnThisDay')}
className="w-7 h-7 rounded-full flex items-center justify-center transition-colors hover:bg-surface-hover"
style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
>
<Plus size={14} strokeWidth={2.6} />
</button>
</Tooltip>
)}
</div>
</div>
@@ -480,31 +483,40 @@ function JourneyDetailPageDesktop() {
reorderEntries(current.id, reordered.map(e => e.id))
.catch(() => toast.error(t('common.errorOccurred')))
}
// The active outline traces the card, so it has to know which
// card: the three kinds round their corners differently, and a
// 12px outline around a 20px card reads as a mistake.
const cardRadius = entry.type === 'skeleton' ? 18 : entry.type === 'checkin' ? 12 : 20
const arrowBtn = 'w-6 h-6 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors'
return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
<div key={entry.id} data-entry-id={String(entry.id)} className="group relative" onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }}>
{/* Out in the column's own padding rather than in a track of
its own: a track pushed every card in by 36px and left the
feed misaligned with the day header above it. Shown on
hover, since they are for the one card you are working on. */}
{canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<div className="absolute right-full top-1/2 mr-1.5 flex -translate-y-1/2 flex-col gap-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => move(-1)}
disabled={idx === 0}
aria-label={t('dayplan.moveUp')}
className="w-7 h-7 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className={arrowBtn}
>
<ChevronUp size={14} />
<ChevronUp size={13} />
</button>
<button
type="button"
onClick={() => move(1)}
disabled={idx === entries.length - 1}
aria-label={t('dayplan.moveDown')}
className="w-7 h-7 rounded-full bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className={arrowBtn}
>
<ChevronDown size={14} />
<ChevronDown size={13} />
</button>
</div>
)}
<div className={canReorder ? 'flex-1 min-w-0' : ''}>
<div>
{entry.type === 'skeleton' ? (
<SkeletonCard
entry={entry}
@@ -523,6 +535,18 @@ function JourneyDetailPageDesktop() {
/>
)}
</div>
{/* The day-coloured ring for the card the map is showing.
Drawn as a layer over the card rather than as an outline
on the row: an outline pulled in tight enough to sit on
the card's edge lands behind the card's own background
and disappears. */}
{String(entry.id) === activeEntryId && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-20"
style={{ border: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, borderRadius: cardRadius }}
/>
)}
</div>
)
})}
@@ -608,6 +632,14 @@ function JourneyDetailPageDesktop() {
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
onMarkerPhotoClick={(entryId, index) => {
const entry = current.entries.find(e => String(e.id) === entryId)
if (!entry?.photos?.length) return
setLightbox({
photos: entry.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,
})
}}
/>
</div>
</aside>
@@ -376,6 +376,9 @@ export function useJourneyDetail() {
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
// A glimpse of the entry's own pictures on its marker card. Three is what
// the card has room for; the rest are one tap away in the entry itself.
photoUrls: (e.photos ?? []).slice(0, 3).map(p => `/api/photos/${p.photo_id}/thumbnail`),
}
})
}, [mapEntries, current?.entries])
@@ -85,9 +85,14 @@ export function useJourneyPublic() {
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
// Through the share token, like every other picture on this page: the
// reader has no session, so `/api/photos/:id` would answer 401.
photoUrls: (e.photos ?? [])
.slice(0, 3)
.map((p: { photo_id: number }) => `/api/public/journey/${token}/photos/${p.photo_id}/original`),
}
})
}, [mapEntries, sortedDates])
}, [mapEntries, sortedDates, token])
// The same number the marker carries, so the timeline is the key to the map
// rather than a second, unrelated numbering.
+13 -2
View File
@@ -314,8 +314,19 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
files,
async (file, opts) => {
const fd = new FormData()
fd.append('photos', file)
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
let data: { photos?: JourneyPhoto[]; gallery?: GalleryPhoto[] }
if (isVideoFile(file)) {
// The same two-part upload the gallery has done since #823. Without it
// a clip went to the images-only route and came back 400 (issue #2341).
const { poster, durationMs } = await captureVideoPoster(file)
fd.append('video', file)
if (poster) fd.append('poster', poster, 'poster.jpg')
if (durationMs != null) fd.append('duration_ms', String(durationMs))
data = await journeyApi.uploadEntryVideo(entryId, fd, opts)
} else {
fd.append('photos', file)
data = await journeyApi.uploadPhotos(entryId, fd, opts)
}
const photos: JourneyPhoto[] = data.photos || []
set(s => {
if (!s.current) return s
@@ -1465,18 +1465,35 @@ export class JourneyDomainService {
.get(entryId, galleryId) as JourneyPhoto | null;
}
/**
* Attach an uploaded file to an entry.
*
* `media` carries what a clip needs beyond a picture: the type, so the viewer
* plays it instead of trying to draw it, and the duration the browser measured
* while it took the poster frame. The gallery route has taken both since #823;
* the entry route could not, which is why a video dropped on an entry came
* back as a 400 (issue #2341).
*/
addPhoto(
entryId: number,
userId: number,
filePath: string,
thumbnailPath?: string,
caption?: string,
media?: { mediaType?: string; durationMs?: number | null },
): JourneyPhoto | null {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = this.photos.getOrCreateLocal(filePath, thumbnailPath);
const trekPhotoId = this.photos.getOrCreateLocal(
filePath,
thumbnailPath,
null,
null,
media?.mediaType || 'image',
media?.durationMs ?? null,
);
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = this.linkGalleryPhotoToEntry(galleryId, entryId);
this.promoteSkeletonIfNeeded(entry);
@@ -267,6 +267,56 @@ export class JourneyController {
return { photos: results };
}
/**
* A clip on an entry.
*
* The gallery has taken video since #823 and an entry could not: everything an
* entry uploaded went through the images-only filter above, so picking an mp4
* in the editor came back 400 and the save reported "1 of 1 photos failed"
* (issue #2341). Same two parts as the gallery route — the clip plus the poster
* frame the browser grabbed — and the same absence of transcoding.
*/
@Post('entries/:entryId/video')
@UseInterceptors(FileFieldsInterceptor(
[{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }],
{ limits: { fileSize: MAX_VIDEO_SIZE }, fileFilter: VIDEO_FILE_FILTER },
))
async uploadEntryVideo(
@CurrentUser() user: User,
@Param('entryId') entryId: string,
@UploadedFiles() files: { video?: Express.Multer.File[]; poster?: Express.Multer.File[] } | undefined,
@Body() body: JourneyGalleryVideoDto,
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
const cleanup = () => {
for (const f of [video, poster]) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
}
};
if (!video) {
cleanup();
throw new HttpException({ error: 'No video uploaded' }, 400);
}
await this.commitJourneyUploads(poster ? [video, poster] : [video]);
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photo = this.journey.addPhoto(
Number(entryId),
user.id,
`journey/${video.filename}`,
poster ? `journey/${poster.filename}` : undefined,
undefined,
{ mediaType: 'video', durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null },
);
if (!photo) {
// Committed already, so the pre-commit unlink would orphan the bytes.
await this.storage.delete('journey', video.filename).catch(() => {});
if (poster) await this.storage.delete('journey', poster.filename).catch(() => {});
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { photos: [photo] };
}
@Post('entries/:entryId/provider-photos')
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
+1 -1
View File
@@ -62,7 +62,7 @@ export class JourneyService {
reorderEntries(id: number, userId: number, orderedIds: number[], sid?: string) { return this.journey.reorderEntries(id, userId, orderedIds, sid); }
// Photos
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined) { return this.journey.addPhoto(entryId, userId, filePath, thumbnailPath, caption); }
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined, media?: { mediaType?: string; durationMs?: number | null }) { return this.journey.addPhoto(entryId, userId, filePath, thumbnailPath, caption, media); }
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return this.journey.setPhotoProvider(photoId, provider, assetId, ownerId); }
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return this.journey.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase, mediaType); }
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return this.journey.linkPhotoToEntry(entryId, journeyPhotoId, userId); }
+1
View File
@@ -638,5 +638,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'المزاج',
'journey.settings.showWeather': 'الطقس',
'journey.entry.suggestion': 'اقتراح',
'journey.editor.addDetails': '+ تفاصيل',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Humor',
'journey.settings.showWeather': 'Clima',
'journey.entry.suggestion': 'Sugestão',
'journey.editor.addDetails': '+ Detalhes',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Estat d\'ànim',
'journey.settings.showWeather': 'Temps',
'journey.entry.suggestion': 'Suggeriment',
'journey.editor.addDetails': '+ Detalls',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Nálada',
'journey.settings.showWeather': 'Počasí',
'journey.entry.suggestion': 'Návrh',
'journey.editor.addDetails': '+ Podrobnosti',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Stimmung',
'journey.settings.showWeather': 'Wetter',
'journey.entry.suggestion': 'Vorschlag',
'journey.editor.addDetails': '+ Details',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Mood',
'journey.settings.showWeather': 'Weather',
'journey.entry.suggestion': 'Suggestion',
'journey.editor.addDetails': '+ Details',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Ánimo',
'journey.settings.showWeather': 'Tiempo',
'journey.entry.suggestion': 'Sugerencia',
'journey.editor.addDetails': '+ Detalles',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Humeur',
'journey.settings.showWeather': 'Météo',
'journey.entry.suggestion': 'Suggestion',
'journey.editor.addDetails': '+ Détails',
};
export default journey;
+1
View File
@@ -643,5 +643,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Διάθεση',
'journey.settings.showWeather': 'Καιρός',
'journey.entry.suggestion': 'Πρόταση',
'journey.editor.addDetails': '+ Λεπτομέρειες',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Hangulat',
'journey.settings.showWeather': 'Időjárás',
'journey.entry.suggestion': 'Javaslat',
'journey.editor.addDetails': '+ Részletek',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Suasana hati',
'journey.settings.showWeather': 'Cuaca',
'journey.entry.suggestion': 'Saran',
'journey.editor.addDetails': '+ Detail',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Umore',
'journey.settings.showWeather': 'Meteo',
'journey.entry.suggestion': 'Suggerimento',
'journey.editor.addDetails': '+ Dettagli',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': '気分',
'journey.settings.showWeather': '天気',
'journey.entry.suggestion': '提案',
'journey.editor.addDetails': '+詳細',
};
export default journey;
+1
View File
@@ -639,5 +639,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': '기분',
'journey.settings.showWeather': '날씨',
'journey.entry.suggestion': '제안',
'journey.editor.addDetails': '+ 세부 정보',
};
export default journey;
+1
View File
@@ -642,5 +642,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Stemming',
'journey.settings.showWeather': 'Weer',
'journey.entry.suggestion': 'Suggestie',
'journey.editor.addDetails': '+ Details',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Nastrój',
'journey.settings.showWeather': 'Pogoda',
'journey.entry.suggestion': 'Propozycja',
'journey.editor.addDetails': '+ Szczegóły',
};
export default journey;
+1
View File
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Настроение',
'journey.settings.showWeather': 'Погода',
'journey.entry.suggestion': 'Предложение',
'journey.editor.addDetails': '+ Подробности',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Humör',
'journey.settings.showWeather': 'Väder',
'journey.entry.suggestion': 'Förslag',
'journey.editor.addDetails': '+ Detaljer',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Ruh hâli',
'journey.settings.showWeather': 'Hava durumu',
'journey.entry.suggestion': 'Öneri',
'journey.editor.addDetails': '+ Ayrıntılar',
};
export default journey;
+1
View File
@@ -642,5 +642,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Настрій',
'journey.settings.showWeather': 'Погода',
'journey.entry.suggestion': 'Пропозиція',
'journey.editor.addDetails': '+ Деталі',
};
export default journey;
+1
View File
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': 'Tâm trạng',
'journey.settings.showWeather': 'Thời tiết',
'journey.entry.suggestion': 'Gợi ý',
'journey.editor.addDetails': '+ Chi tiết',
};
export default journey;
+1
View File
@@ -636,5 +636,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': '心情',
'journey.settings.showWeather': '天氣',
'journey.entry.suggestion': '建議',
'journey.editor.addDetails': '+詳情',
};
export default journey;
+1
View File
@@ -636,5 +636,6 @@ const journey: TranslationStrings = {
'journey.settings.showMood': '心情',
'journey.settings.showWeather': '天气',
'journey.entry.suggestion': '建议',
'journey.editor.addDetails': '+详情',
};
export default journey;