diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d55a3be27..4f7a8ab63 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/components/Journey/JourneyDayScrubber.test.tsx b/client/src/components/Journey/JourneyDayScrubber.test.tsx index 661371ec8..8e5d82d45 100644 --- a/client/src/components/Journey/JourneyDayScrubber.test.tsx +++ b/client/src/components/Journey/JourneyDayScrubber.test.tsx @@ -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( {}} />) - 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', () => { diff --git a/client/src/components/Journey/JourneyDayScrubber.tsx b/client/src/components/Journey/JourneyDayScrubber.tsx index 562aceacc..f0f95d38f 100644 --- a/client/src/components/Journey/JourneyDayScrubber.tsx +++ b/client/src/components/Journey/JourneyDayScrubber.tsx @@ -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(null) + const dragging = useRef(false) + const lastPicked = useRef(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 ( -
-
- + // 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. +
{ + 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. */} +
+ {long(shown.date)} - {activeIndex >= 0 && ( - - {t('journey.detail.day', { number: String(activeIndex + 1) })} - - )}
- {/* 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. */} -
+ {/* 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. */} +
{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]" + > + + ) })}
diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index f2d0cc21a..f12d42e88 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx @@ -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 )} {onDismiss && ( - + + + )}
) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 9d0870811..894381d37 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -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, />
- { (e.target as HTMLInputElement).value = '' }} className="hidden" /> + { (e.target as HTMLInputElement).value = '' }} className="hidden" /> { (e.target as HTMLInputElement).value = '' }} className="hidden" />
) } diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index c2a1fd5ce..cd340717e 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -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', () => { diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index e5ac331dd..24d0a486f 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -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) { diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index e84cec2a4..c8bfd46bf 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -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, ''') -} - -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(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(`${place}`) - if (date) subParts.push(`${date}`) - const subline = subParts.length === 2 - ? `${subParts[0]}\u00B7${subParts[1]}` - : subParts.join('') - - const html = ` -
${primary}
- ${subline ? `
${subline}
` : ''} - ` // 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' diff --git a/client/src/components/Journey/MobileMapTimeline.test.tsx b/client/src/components/Journey/MobileMapTimeline.test.tsx index 7cb454163..f35817bbd 100644 --- a/client/src/components/Journey/MobileMapTimeline.test.tsx +++ b/client/src/components/Journey/MobileMapTimeline.test.tsx @@ -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 () => { diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index 8b95c429f..a42e7a7c7 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -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 */} diff --git a/client/src/components/Journey/journeyCard.test.ts b/client/src/components/Journey/journeyCard.test.ts index 4497c0df2..e4d3043a4 100644 --- a/client/src/components/Journey/journeyCard.test.ts +++ b/client/src/components/Journey/journeyCard.test.ts @@ -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('') }) }) diff --git a/client/src/components/Journey/journeyCard.ts b/client/src/components/Journey/journeyCard.ts index 6037f5c29..ffb8530eb 100644 --- a/client/src/components/Journey/journeyCard.ts +++ b/client/src/components/Journey/journeyCard.ts @@ -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. */ diff --git a/client/src/components/Journey/journeyMapPopup.ts b/client/src/components/Journey/journeyMapPopup.ts new file mode 100644 index 000000000..94412931e --- /dev/null +++ b/client/src/components/Journey/journeyMapPopup.ts @@ -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 + ? `
${shots + .map((url, i) => ``) + .join('')}
` + : '' + + const chips: string[] = [] + if (place) chips.push(`${PIN_ICON}${escapeHtml(place)}`) + if (date) chips.push(`${CAL_ICON}${escapeHtml(date)}`) + + return `${photos} +
${escapeHtml(title)}
+ ${chips.length ? `
${chips.join('')}
` : ''}` +} + +// Inline rather than the lucide components: this markup lives outside React's tree. +const PIN_ICON = + '' +const CAL_ICON = + '' + +/** + * 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 : 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) +} diff --git a/client/src/components/shared/CountryFlag.tsx b/client/src/components/shared/CountryFlag.tsx new file mode 100644 index 000000000..9d488e341 --- /dev/null +++ b/client/src/components/shared/CountryFlag.tsx @@ -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 ( + + {emoji} + + ) + } + + const cp = [...code.toUpperCase()] + .map(c => (0x1f1e6 + c.charCodeAt(0) - 65).toString(16)) + .join('-') + return ( + {code.toUpperCase()} setFailed(true)} + style={{ width: size, height: size, display: 'block', borderRadius: 2 }} + /> + ) +} diff --git a/client/src/index.css b/client/src/index.css index 77178ad1b..ff326b7f3 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; } diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 86266a192..51e68317a 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -345,7 +345,6 @@ export default function MJourneyDetail() { onMarkerClick={handleMarkerClick} fullScreen paddingBottom={250} - hideMarkerTooltip />
@@ -483,9 +482,12 @@ export default function MJourneyDetail() { )} - {/* 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 && ( -
+
{/* Upload source chooser (device / providers) */} - 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. */} + setShowUploadMenu(false)} variant="bottom" material="opaque" ariaLabel={t('common.upload')}>
{/* Journey actions: book export, suggestions switch, settings */} - setShowActionMenu(false)} variant="bottom" ariaLabel={t('files.menu')}> + setShowActionMenu(false)} variant="bottom" material="opaque" ariaLabel={t('files.menu')}>
diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index 8af53a955..5d2462b27 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -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 && ( <> { (e.target as HTMLInputElement).value = '' }} /> - { (e.target as HTMLInputElement).value = '' }} /> + { (e.target as HTMLInputElement).value = '' }} />
)} + + + )}
@@ -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 ( -
{ 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}> +
{ 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 && ( -
+
)} -
+
{entry.type === 'skeleton' ? ( )}
+ {/* 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 && ( + + )}
) })} @@ -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, + }) + }} />
diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index d905ec2e1..04bf79c38 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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]) diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index 91e7a07a7..fb76184fa 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -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. diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 3fc6c6db2..9c75f5248 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -314,8 +314,19 @@ export const useJourneyStore = create((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 diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index bfa9660df..a16562bac 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -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); diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 8f202456d..64ad4e4ac 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -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; diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 79f78c3d8..4c2f64b6b 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -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); } diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 0f8aabe54..9922270ff 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -638,5 +638,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': 'المزاج', 'journey.settings.showWeather': 'الطقس', 'journey.entry.suggestion': 'اقتراح', + 'journey.editor.addDetails': '+ تفاصيل', }; export default journey; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 28ba7f85b..f76eb7569 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -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; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index baea47698..f1cfa3bab 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -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; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index ae680e033..a943097a2 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -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; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index dc0221505..f7bbb3170 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -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; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index 724675e14..02f806899 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -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; diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 10f3f511b..9e33d3270 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -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; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 086a23274..fb19cf834 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -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; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 20a19022f..763740461 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -643,5 +643,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': 'Διάθεση', 'journey.settings.showWeather': 'Καιρός', 'journey.entry.suggestion': 'Πρόταση', + 'journey.editor.addDetails': '+ Λεπτομέρειες', }; export default journey; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 53a412a82..d22c74d3b 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -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; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 46bc60849..a67a9d60b 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -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; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 1e6ca4af0..c20e6432b 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -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; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 7c14956c7..27a323a10 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -640,5 +640,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': '気分', 'journey.settings.showWeather': '天気', 'journey.entry.suggestion': '提案', + 'journey.editor.addDetails': '+詳細', }; export default journey; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index eaeebcf94..c914cceda 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -639,5 +639,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': '기분', 'journey.settings.showWeather': '날씨', 'journey.entry.suggestion': '제안', + 'journey.editor.addDetails': '+ 세부 정보', }; export default journey; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 351a18afc..38e4f99fa 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -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; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index f823f5aa5..5f5a680a9 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -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; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 589f51c01..fce49cdb6 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -641,5 +641,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': 'Настроение', 'journey.settings.showWeather': 'Погода', 'journey.entry.suggestion': 'Предложение', + 'journey.editor.addDetails': '+ Подробности', }; export default journey; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index eb4a81d94..aad4274e3 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -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; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 0e4dd59c9..eeb2349a2 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -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; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 257553c77..2fa3824e6 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -642,5 +642,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': 'Настрій', 'journey.settings.showWeather': 'Погода', 'journey.entry.suggestion': 'Пропозиція', + 'journey.editor.addDetails': '+ Деталі', }; export default journey; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index 044f32323..e2cb9492b 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index e584f814c..b620e95bf 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -636,5 +636,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': '心情', 'journey.settings.showWeather': '天氣', 'journey.entry.suggestion': '建議', + 'journey.editor.addDetails': '+詳情', }; export default journey; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index 320594365..dce403a4a 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -636,5 +636,6 @@ const journey: TranslationStrings = { 'journey.settings.showMood': '心情', 'journey.settings.showWeather': '天气', 'journey.entry.suggestion': '建议', + 'journey.editor.addDetails': '+详情', }; export default journey;