mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -638,5 +638,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': 'المزاج',
|
||||
'journey.settings.showWeather': 'الطقس',
|
||||
'journey.entry.suggestion': 'اقتراح',
|
||||
'journey.editor.addDetails': '+ تفاصيل',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -643,5 +643,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': 'Διάθεση',
|
||||
'journey.settings.showWeather': 'Καιρός',
|
||||
'journey.entry.suggestion': 'Πρόταση',
|
||||
'journey.editor.addDetails': '+ Λεπτομέρειες',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -640,5 +640,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': '気分',
|
||||
'journey.settings.showWeather': '天気',
|
||||
'journey.entry.suggestion': '提案',
|
||||
'journey.editor.addDetails': '+詳細',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -639,5 +639,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': '기분',
|
||||
'journey.settings.showWeather': '날씨',
|
||||
'journey.entry.suggestion': '제안',
|
||||
'journey.editor.addDetails': '+ 세부 정보',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -641,5 +641,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': 'Настроение',
|
||||
'journey.settings.showWeather': 'Погода',
|
||||
'journey.entry.suggestion': 'Предложение',
|
||||
'journey.editor.addDetails': '+ Подробности',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -642,5 +642,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': 'Настрій',
|
||||
'journey.settings.showWeather': 'Погода',
|
||||
'journey.entry.suggestion': 'Пропозиція',
|
||||
'journey.editor.addDetails': '+ Деталі',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -636,5 +636,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': '心情',
|
||||
'journey.settings.showWeather': '天氣',
|
||||
'journey.entry.suggestion': '建議',
|
||||
'journey.editor.addDetails': '+詳情',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
@@ -636,5 +636,6 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.showMood': '心情',
|
||||
'journey.settings.showWeather': '天气',
|
||||
'journey.entry.suggestion': '建议',
|
||||
'journey.editor.addDetails': '+详情',
|
||||
};
|
||||
export default journey;
|
||||
|
||||
Reference in New Issue
Block a user