From bd6cd55a13662c3d2da15779eb9fe0a332c0f421 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 21:36:19 +0200 Subject: [PATCH 1/8] =?UTF-8?q?fix(journey):=20resolve=20issues=20#789-801?= =?UTF-8?q?=20=E2=80=94=20mobile=20layout,=20day=20colors,=20location=20fo?= =?UTF-8?q?rmatting,=20date=20picker,=20public=20share=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Journey/JourneyMap.tsx | 39 +++++-------- .../src/components/Journey/JourneyMapAuto.tsx | 2 + .../src/components/Journey/JourneyMapGL.tsx | 33 +++++------ .../components/Journey/MobileEntryCard.tsx | 12 ++-- .../components/Journey/MobileEntryView.tsx | 3 +- .../components/Journey/MobileMapTimeline.tsx | 31 ++++++++-- client/src/components/Journey/dayColors.ts | 12 ++++ .../shared/CustomDateTimePicker.tsx | 7 ++- client/src/pages/JourneyDetailPage.tsx | 58 ++++++++++++------- client/src/pages/JourneyPublicPage.tsx | 23 ++++++++ client/src/utils/formatters.ts | 33 +++++++++++ 11 files changed, 178 insertions(+), 75 deletions(-) create mode 100644 client/src/components/Journey/dayColors.ts diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 2dd1c711..f11dd8ea 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -9,6 +9,8 @@ export interface MapMarkerItem { label: string mood?: string | null time: string + dayColor: string + dayLabel: number } export interface JourneyMapHandle { @@ -24,6 +26,8 @@ interface MapEntry { title?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { @@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { label: e.title || 'Entry', mood: e.mood, time: e.entry_date, + dayColor: e.dayColor || '#52525B', + dayLabel: e.dayLabel ?? 1, }) } } @@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { const MARKER_W = 28 const MARKER_H = 36 -function markerSvg(index: number, highlighted: boolean, dark: boolean): string { - // Highlighted: inverted colors for contrast (black on light, white on dark) - const fill = dark - ? (highlighted ? '#FAFAFA' : '#A1A1AA') - : (highlighted ? '#18181B' : '#52525B') - const textColor = dark - ? (highlighted ? '#18181B' : '#18181B') - : (highlighted ? '#fff' : '#fff') - const stroke = highlighted - ? (dark ? '#fff' : '#18181B') - : (dark ? '#3F3F46' : '#fff') +function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { + const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const shadow = highlighted - ? (dark - ? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' - : 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') + ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' - const label = String(index + 1) + const label = String(dayLabel) const scale = highlighted ? 1.2 : 1 return `
- - - ${label} + + + ${label}
` } @@ -115,12 +110,11 @@ const JourneyMap = forwardRef(function JourneyMap( const marker = markersRef.current.get(prev) const item = itemsRef.current.find(i => i.id === prev) if (marker && item) { - const idx = itemsRef.current.indexOf(item) marker.setIcon(L.divIcon({ className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(idx, false, isDark), + html: markerSvg(item.dayColor, item.dayLabel, false), })) marker.setZIndexOffset(0) } @@ -130,12 +124,11 @@ const JourneyMap = forwardRef(function JourneyMap( const marker = markersRef.current.get(id) const item = itemsRef.current.find(i => i.id === id) if (marker && item) { - const idx = itemsRef.current.indexOf(item) marker.setIcon(L.divIcon({ className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(idx, true, isDark), + html: markerSvg(item.dayColor, item.dayLabel, true), })) marker.setZIndexOffset(1000) } @@ -226,7 +219,7 @@ const JourneyMap = forwardRef(function JourneyMap( className: '', iconSize: [MARKER_W, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H], - html: markerSvg(i, false, !!dark), + html: markerSvg(item.dayColor, item.dayLabel, false), }) const marker = L.marker(pos, { icon }).addTo(map) diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index 9b126535..478f5d6f 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -14,6 +14,8 @@ interface MapEntry { location_name?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index 60cef2c5..7f2dd6a9 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -18,6 +18,8 @@ interface MapEntry { location_name?: string | null mood?: string | null entry_date: string + dayColor?: string + dayLabel?: number } interface Props { @@ -39,6 +41,8 @@ interface Item { label: string locationName: string time: string + dayColor: string + dayLabel: number } const MARKER_W = 28 @@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] { label: e.title || '', locationName: e.location_name || '', time: e.entry_date, + dayColor: e.dayColor || '#52525B', + dayLabel: e.dayLabel ?? 1, }) } } @@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() { document.head.appendChild(s) } -function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement { - const fill = dark - ? (highlighted ? '#FAFAFA' : '#A1A1AA') - : (highlighted ? '#18181B' : '#52525B') - const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff' - const stroke = highlighted - ? (dark ? '#fff' : '#18181B') - : (dark ? '#3F3F46' : '#fff') +function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement { + const fill = dayColor + const textColor = '#fff' + const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const shadow = highlighted - ? (dark - ? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' - : 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') + ? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' const scale = highlighted ? 1.2 : 1 - const label = String(index + 1) + const label = String(dayLabel) // Outer wrap holds the element mapbox positions via `transform: translate(...)`. // Anything animated (scale, filter) has to live on an inner child — otherwise @@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv inner.className = 'trek-journey-marker-inner' inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` inner.innerHTML = ` - + ${label} ` @@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef(function JourneyMapGL const item = itemsRef.current.find(i => i.id === id) const marker = markersRef.current.get(id) if (!item || !marker) return - const idx = itemsRef.current.indexOf(item) const el = marker.getElement() const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null if (!currentInner) return // Only swap the inner element's styles/HTML. Touching `el.style.cssText` // would wipe mapbox's positional transform and make the marker flicker. - const next = markerHtml(idx, highlighted, !!darkRef.current) + const next = markerHtml(item.dayColor, item.dayLabel, highlighted) const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement currentInner.style.cssText = nextInner.style.cssText currentInner.innerHTML = nextInner.innerHTML @@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef(function JourneyMapGL } // markers - items.forEach((item, i) => { - const el = markerHtml(i, false, !!darkRef.current) + items.forEach((item) => { + const el = markerHtml(item.dayColor, item.dayLabel, false) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) .setLngLat([item.lng, item.lat]) .addTo(map) diff --git a/client/src/components/Journey/MobileEntryCard.tsx b/client/src/components/Journey/MobileEntryCard.tsx index 0f29f87e..9cc1a549 100644 --- a/client/src/components/Journey/MobileEntryCard.tsx +++ b/client/src/components/Journey/MobileEntryCard.tsx @@ -1,4 +1,5 @@ import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' +import { formatLocationName } from '../../utils/formatters' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' const MOOD_ICONS: Record = { @@ -37,13 +38,14 @@ function stripMarkdown(text: string): string { interface Props { entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } - index: number + dayLabel: number + dayColor: string isActive: boolean onClick: () => void publicPhotoUrl?: (photoId: number) => string } -export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { +export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) { const hasLocation = !!(entry.location_lat && entry.location_lng) const hasPhotos = entry.photos && entry.photos.length > 0 const firstPhoto = hasPhotos ? entry.photos![0] : null @@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
{/* Day number + date + mood/weather */}
- - {index + 1} + + {dayLabel} {dateStr} {entry.entry_time && ( @@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi {hasLocation ? ( - {entry.location_name || 'On the map'} + {formatLocationName(entry.location_name) || 'On the map'} ) : ( No location diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index f7a76943..50be5edf 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -6,6 +6,7 @@ import { ThumbsUp, ThumbsDown, ChevronDown, } from 'lucide-react' import JournalBody from './JournalBody' +import { formatLocationName } from '../../utils/formatters' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' const MOOD_CONFIG: Record = { @@ -130,7 +131,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
- {entry.location_name} + {formatLocationName(entry.location_name)}
)} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index 33c88e99..cd543a91 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -1,9 +1,10 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { useRef, useState, useEffect, useCallback, useMemo } from 'react' import { Plus } from 'lucide-react' import JourneyMap from './JourneyMap' import MobileEntryCard from './MobileEntryCard' import type { JourneyMapHandle } from './JourneyMap' import type { JourneyEntry } from '../../store/journeyStore' +import { DAY_COLORS } from './dayColors' interface MapEntry { id: string @@ -23,6 +24,7 @@ interface Props { onEntryClick: (entry: any) => void onAddEntry?: () => void publicPhotoUrl?: (photoId: number) => string + carouselBottom?: string } export default function MobileMapTimeline({ @@ -34,10 +36,22 @@ export default function MobileMapTimeline({ onEntryClick, onAddEntry, publicPhotoUrl, + carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)', }: Props) { const mapRef = useRef(null) const carouselRef = useRef(null) const [activeIndex, setActiveIndex] = useState(0) + + const entryDayMeta = useMemo(() => { + const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())] + const counters = new Map() + return entries.map((e: any) => { + const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 + counters.set(e.entry_date, dayLabel) + return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] } + }) + }, [entries]) const cardRefs = useRef>(new Map()) const activeIndexRef = useRef(activeIndex) useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) @@ -142,7 +156,10 @@ export default function MobileMapTimeline({ if (entries.length === 0) { return ( -
+
+
{/* Full-screen map */}
handleCardTap(entry, i)} publicPhotoUrl={publicPhotoUrl} diff --git a/client/src/components/Journey/dayColors.ts b/client/src/components/Journey/dayColors.ts new file mode 100644 index 00000000..8f009ace --- /dev/null +++ b/client/src/components/Journey/dayColors.ts @@ -0,0 +1,12 @@ +export const DAY_COLORS = [ + '#6366f1', + '#f97316', + '#14b8a6', + '#ec4899', + '#22c55e', + '#3b82f6', + '#a855f7', + '#ef4444', + '#f59e0b', + '#06b6d4', +] diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index 1b685a90..67085f93 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -119,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com ...(() => { const r = ref.current?.getBoundingClientRect() if (!r) return { top: 0, left: 0 } - const w = 268, pad = 8 + const w = 268, pad = 8, h = 360 const vw = window.innerWidth - const vh = window.innerHeight + const vh = window.visualViewport?.height ?? window.innerHeight let left = r.left let top = r.bottom + 4 if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) - if (top + 320 > vh) top = Math.max(pad, r.top - 320) + if (top + h > vh - pad) top = r.top - h - 4 + top = Math.max(pad, Math.min(top, vh - h - pad)) if (vw < 360) left = Math.max(pad, (vw - w) / 2) return { top, left } })(), diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f5287a24..41a6da26 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { formatLocationName } from '../utils/formatters' import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' @@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' import { addListener, removeListener } from '../api/websocket' import Navbar from '../components/Layout/Navbar' import JourneyMap from '../components/Journey/JourneyMapAuto' +import { DAY_COLORS } from '../components/Journey/dayColors' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import JournalBody from '../components/Journey/JournalBody' import MarkdownToolbar from '../components/Journey/MarkdownToolbar' @@ -279,16 +281,28 @@ export default function JourneyDetailPage() { [current?.entries] ) - const sidebarMapItems = useMemo(() => mapEntries.map(e => ({ - id: String(e.id), - lat: e.location_lat!, - lng: e.location_lng!, - title: e.title || '', - location_name: e.location_name || '', - mood: e.mood, - created_at: e.entry_date, - entry_date: e.entry_date, - })), [mapEntries]) + const sidebarMapItems = useMemo(() => { + const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) + const uniqueDates = [...new Set(sorted.map(e => e.entry_date))] + const dayCounters = new Map() + return sorted.map(e => { + const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1 + dayCounters.set(e.entry_date, dayLabel) + return { + id: String(e.id), + lat: e.location_lat!, + lng: e.location_lng!, + title: e.title || '', + location_name: e.location_name || '', + mood: e.mood, + created_at: e.entry_date, + entry_date: e.entry_date, + dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], + dayLabel, + } + }) + }, [mapEntries]) const tripDates = useMemo(() => { const dates = new Set() @@ -424,7 +438,7 @@ export default function JourneyDetailPage() { ? 'max-w-[1440px] mx-auto px-0 pt-0' : 'flex w-full overflow-hidden' } - style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined} + style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined} > {/* LEFT column (full width on mobile, scrollable feed on desktop) */}
{hideSkeletons ? : } - + {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
@@ -584,7 +598,7 @@ export default function JourneyDetailPage() {
-
+
{dayIdx + 1}
@@ -613,7 +627,7 @@ export default function JourneyDetailPage() { .catch(() => toast.error(t('common.errorOccurred'))) } 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}> {canReorder && (
- {e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''} + {formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
@@ -1360,7 +1375,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { {entry.location_name && ( - {entry.location_name} + {formatLocationName(entry.location_name)} )} {entry.entry_time && ( @@ -1403,7 +1418,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && ( - {entry.location_name} + {formatLocationName(entry.location_name)} )} {entry.entry_time && ( @@ -1482,7 +1497,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => {entry.title || t('journey.detail.newEntry')}
- {entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''} + {formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
@@ -2962,11 +2977,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) { ) } -function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { +function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: { journey: JourneyDetail onClose: () => void onSaved: () => void onOpenInvite: () => void + onRefresh: () => void }) { const { t } = useTranslation() const [title, setTitle] = useState(journey.title) @@ -3133,7 +3149,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { try { await journeyApi.removeContributor(journey.id, c.user_id) toast.success(t('journey.contributors.removed')) - onSaved() + onRefresh() } catch { toast.error(t('journey.contributors.removeFailed')) } diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index aa10be43..816ac6ec 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -216,6 +216,28 @@ export default function JourneyPublicPage() {
)} + {/* Floating view toggle — visible above the fullscreen map on mobile */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( +
+
+ {availableViews.map(v => ( + + ))} +
+
+ )} + {/* Mobile combined map+timeline (public, read-only) */} {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( {}} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> )} diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 980f85ac..7a6c01a2 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -1,5 +1,38 @@ import type { AssignmentsMap } from '../types' +// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood, +// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country". +// Clean short names (≤3 parts) pass through untouched. +export function formatLocationName(raw: string | null | undefined): string { + if (!raw) return '' + const parts = raw.split(',').map(p => p.trim()).filter(Boolean) + if (parts.length <= 3) return raw.trim() + + // Dedup preserving insertion order + const seen = new Set() + const unique: string[] = [] + for (const p of parts) { + if (!seen.has(p.toLowerCase())) { seen.add(p.toLowerCase()); unique.push(p) } + } + if (unique.length <= 3) return unique.join(', ') + + const name = unique[0] + const last = unique[unique.length - 1] + const secondLast = unique.length >= 2 ? unique[unique.length - 2] : null + + // Detect postcode at tail: short alphanumeric with at least one digit, ≤10 chars + const postalRe = /^[A-Z0-9][A-Z0-9\s\-]{1,8}$/i + const isLastPostal = postalRe.test(last) && /\d/.test(last) && last.length <= 10 + const postcode = isLastPostal ? last : null + const country = isLastPostal ? secondLast : last + + const result: string[] = [name] + if (postcode && postcode !== name) result.push(postcode) + if (country && country !== name && country !== postcode) result.push(country) + + return result.join(', ') +} + const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF']) export function currencyDecimals(currency: string): number { From c912ad4b0194873b1cb4aa3b8398ffb354a3281b Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 21:40:17 +0200 Subject: [PATCH 2/8] fix(journey): expand DAY_COLORS to 30 unique colors to cover a full month --- client/src/components/Journey/dayColors.ts | 40 ++++++++++++++++------ 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/client/src/components/Journey/dayColors.ts b/client/src/components/Journey/dayColors.ts index 8f009ace..9dca98b9 100644 --- a/client/src/components/Journey/dayColors.ts +++ b/client/src/components/Journey/dayColors.ts @@ -1,12 +1,32 @@ export const DAY_COLORS = [ - '#6366f1', - '#f97316', - '#14b8a6', - '#ec4899', - '#22c55e', - '#3b82f6', - '#a855f7', - '#ef4444', - '#f59e0b', - '#06b6d4', + '#6366f1', // indigo + '#f97316', // orange + '#14b8a6', // teal + '#ec4899', // pink + '#22c55e', // green + '#3b82f6', // blue + '#a855f7', // purple + '#ef4444', // red + '#f59e0b', // amber + '#06b6d4', // cyan + '#84cc16', // lime + '#f43f5e', // rose + '#8b5cf6', // violet + '#10b981', // emerald + '#fb923c', // orange-400 + '#60a5fa', // blue-400 + '#c084fc', // purple-400 + '#34d399', // emerald-400 + '#fbbf24', // amber-400 + '#e879f9', // fuchsia + '#4ade80', // green-400 + '#f87171', // red-400 + '#38bdf8', // sky-400 + '#a3e635', // lime-400 + '#fb7185', // rose-400 + '#818cf8', // indigo-400 + '#2dd4bf', // teal-400 + '#facc15', // yellow + '#c026d3', // fuchsia-600 + '#0ea5e9', // sky-500 ] From 7d5dadc44195cef4f050860fa10d7f587d3fa1f1 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 21:55:45 +0200 Subject: [PATCH 3/8] feat(journey/public): match desktop timeline view to in-app experience --- client/src/pages/JourneyPublicPage.tsx | 548 +++++++++++++++++-------- 1 file changed, 370 insertions(+), 178 deletions(-) diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 816ac6ec..f98af73e 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -3,12 +3,19 @@ import { useParams } from 'react-router-dom' import { journeyApi } from '../api/client' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' import { useSettingsStore } from '../store/settingsStore' -import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react' +import { + List, Grid, MapPin, Camera, BookOpen, Image, Clock, + Laugh, Smile, Meh, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, + ThumbsUp, ThumbsDown, +} from 'lucide-react' import JourneyMap from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import { useIsMobile } from '../hooks/useIsMobile' +import { formatLocationName } from '../utils/formatters' +import { DAY_COLORS } from '../components/Journey/dayColors' interface PublicEntry { id: number @@ -36,6 +43,22 @@ interface PublicPhoto { caption?: string | null } +const MOOD_CONFIG: Record = { + amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, +} + +const WEATHER_CONFIG: Record = { + sunny: { icon: Sun, label: 'Sunny' }, + partly: { icon: CloudSun, label: 'Partly cloudy' }, + cloudy: { icon: Cloud, label: 'Cloudy' }, + rainy: { icon: CloudRain, label: 'Rainy' }, + stormy: { icon: CloudLightning, label: 'Stormy' }, + cold: { icon: Snowflake, label: 'Cold' }, +} + function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string { return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}` } @@ -84,10 +107,6 @@ export default function JourneyPublicPage() { const journey = data?.journey || {} const stats = data?.stats || {} - // `[Trip Photos]` and `Gallery` are synthetic photo-only containers - // produced by the trip→journey sync. They have no story and no - // location, and the owner view strips them from the timeline the - // same way (JourneyDetailPage.tsx). Gallery keeps their photos. const timelineEntries = useMemo( () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'), [entries], @@ -100,12 +119,42 @@ export default function JourneyPublicPage() { ) const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) + // Map entries with day color/label for colored markers + const sidebarMapItems = useMemo(() => { + const uniqueDates = [...new Set(mapEntries.map(e => e.entry_date).sort())] + const counters = new Map() + return mapEntries.map(e => { + const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 + counters.set(e.entry_date, dayLabel) + return { + id: String(e.id), + lat: e.location_lat!, + lng: e.location_lng!, + title: e.title || '', + mood: e.mood, + created_at: e.entry_date, + entry_date: e.entry_date, + dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length], + dayLabel, + } + }) + }, [mapEntries]) + + // Two-column desktop layout: timeline feed left + sticky map right + const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map + // Set default view based on permissions useEffect(() => { if (!perms.share_timeline && perms.share_gallery) setView('gallery') else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map') }, [perms]) + // When switching to desktop two-column, 'map' standalone tab no longer exists + useEffect(() => { + if (desktopTwoColumn && view === 'map') setView('timeline') + }, [desktopTwoColumn]) + if (loading) { return (
@@ -125,21 +174,252 @@ export default function JourneyPublicPage() { ) } + // In desktop two-column mode the map is always visible — exclude the standalone 'map' tab const availableViews = [ perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + !desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] + // Shared timeline renderer used in both layout modes + const renderTimeline = () => ( +
+ {sortedDates.length === 0 && ( +
+
+ +
+

No entries yet

+
+ )} + {sortedDates.map((date, dayIdx) => { + const dayEntries = groupedEntries.get(date)! + const fd = formatDate(date, locale) + const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length] + return ( +
+ {/* Day header */} +
+
+ {dayIdx + 1} +
+
+
{fd.weekday}
+
{fd.month} {fd.day}
+
+
+ + {/* Entries */} +
+ {dayEntries.map(entry => { + const photos = entry.photos || [] + const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null + const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null + const prosArr = entry.pros_cons?.pros ?? [] + const consArr = entry.pros_cons?.cons ?? [] + const hasProscons = prosArr.length > 0 || consArr.length > 0 + const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })) + + return ( +
+ + {/* Photo area */} + {photos.length === 1 && ( +
setLightbox({ photos: lightboxPhotos, index: 0 })}> + +
+ {entry.location_name && ( +
+ + + {formatLocationName(entry.location_name)} + +
+ )} + {entry.title && ( +
+

{entry.title}

+
+ )} +
+ )} + + {photos.length === 2 && ( +
+ {photos.slice(0, 2).map((p, i) => ( + setLightbox({ photos: lightboxPhotos, index: i })} + /> + ))} +
+ )} + + {photos.length >= 3 && ( +
+
setLightbox({ photos: lightboxPhotos, index: 0 })}> + +
+
+
setLightbox({ photos: lightboxPhotos, index: 1 })}> + +
+
setLightbox({ photos: lightboxPhotos, index: 2 })}> + + {photos.length > 3 && ( +
+ + +{photos.length - 3} + +
+ )} +
+
+
+ )} + + {/* Content */} +
+ {/* Title (only when no single photo — photo has it in overlay) */} + {photos.length !== 1 && entry.title && ( +

{entry.title}

+ )} + + {/* Location + time badges */} + {(entry.location_name || entry.entry_time) && photos.length !== 1 && ( +
+ {entry.location_name && ( + + + {formatLocationName(entry.location_name)} + + )} + {entry.entry_time && ( + + + {entry.entry_time.slice(0, 5)} + + )} +
+ )} + {entry.entry_time && photos.length === 1 && ( +
+ + {entry.entry_time.slice(0, 5)} +
+ )} + + {/* Story */} + {entry.story && ( +
+ +
+ )} + + {/* Pros & Cons */} + {hasProscons && ( +
+ {prosArr.length > 0 && ( +
+
+ Pros +
+ {prosArr.map((p, i) => ( +
+ {p} +
+ ))} +
+ )} + {consArr.length > 0 && ( +
+
+ Cons +
+ {consArr.map((c, i) => ( +
+ {c} +
+ ))} +
+ )} +
+ )} + + {/* Mood + weather */} + {(mood || weather) && ( +
+ {mood && ( + + {mood.label} + + )} + {weather && ( + + {weather.label} + + )} +
+ )} +
+
+ ) + })} +
+
+ ) + })} +
+ ) + + // Shared gallery renderer + const renderGallery = () => ( +
+ {allPhotos.map(({ photo }, idx) => ( +
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })} + > + +
+ ))} +
+ ) + + // Shared view tab bar + const renderTabs = (views: typeof availableViews) => views.length > 1 && ( +
+ {views.map(v => ( + + ))} +
+ ) + return (
{/* Hero */}
- {/* Cover image background */} {journey.cover_image && (
)} - {/* Decorative circles */}
@@ -194,183 +474,95 @@ export default function JourneyPublicPage() {
{/* Content */} -
- - {/* View tabs */} - {availableViews.length > 1 && ( -
- {availableViews.map(v => ( - - ))} + {desktopTwoColumn ? ( + // ── Desktop two-column: scrollable timeline feed + sticky map ────────── +
+ {/* Left: feed */} +
+ {renderTabs(availableViews)} + {view === 'timeline' && perms.share_timeline && renderTimeline()} + {view === 'gallery' && perms.share_gallery && renderGallery()}
- )} - {/* Floating view toggle — visible above the fullscreen map on mobile */} - {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( -
-
- {availableViews.map(v => ( - - ))} + {/* Right: sticky map */} +
- )} + +
+ ) : ( + // ── Single-column layout (mobile + desktop-without-map) ─────────────── +
- {/* Mobile combined map+timeline (public, read-only) */} - {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( - ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} - dark={document.documentElement.classList.contains('dark')} - readOnly - onEntryClick={() => {}} - publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} - carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" - /> - )} - - {/* Timeline (desktop, or mobile without map permission) */} - {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && ( -
- {sortedDates.map(date => { - const dayEntries = groupedEntries.get(date)! - const fd = formatDate(date, locale) - return ( -
-
-
{fd.day}
-
-
{fd.weekday}
-
{fd.month} {fd.day}
-
-
-
- {dayEntries.map(entry => ( -
- {entry.photos.length > 0 && ( -
- setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })} - /> - {entry.photos.length > 1 && ( -
- +{entry.photos.length - 1} -
- )} - {entry.title && ( -
-

{entry.title}

-
- )} -
- )} -
- {!entry.photos.length && entry.title && ( -

{entry.title}

- )} - {entry.location_name && ( -
- {entry.location_name} -
- )} - {entry.story && ( -
- -
- )} - {entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && ( -
- {(entry.pros_cons.pros?.length ?? 0) > 0 && ( -
-
{t('journey.editor.pros')}
- {entry.pros_cons.pros!.map((p, i) => ( -
- {p} -
- ))} -
- )} - {(entry.pros_cons.cons?.length ?? 0) > 0 && ( -
-
{t('journey.editor.cons')}
- {entry.pros_cons.cons!.map((c, i) => ( -
- {c} -
- ))} -
- )} -
- )} -
-
- ))} -
-
- ) - })} -
- )} - - {/* Gallery */} - {view === 'gallery' && perms.share_gallery && ( -
- {allPhotos.map(({ photo }, idx) => ( -
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })} - > - + {/* Floating view toggle — visible above the fullscreen map on mobile */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( +
+
+ {availableViews.map(v => ( + + ))}
- ))} -
- )} +
+ )} - {/* Map */} - {view === 'map' && perms.share_map && ( -
- ({ - id: String(e.id), - lat: e.location_lat!, - lng: e.location_lng!, - title: e.title || '', - mood: e.mood, - created_at: e.entry_date, - entry_date: e.entry_date, - })) as any} - height={500} + {renderTabs(availableViews)} + + {/* Mobile combined map+timeline (public, read-only) */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( + ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} + dark={document.documentElement.classList.contains('dark')} + readOnly + onEntryClick={() => {}} + publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" /> -
- )} -
+ )} + + {/* Timeline (desktop, or mobile without map permission) */} + {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()} + + {/* Gallery */} + {view === 'gallery' && perms.share_gallery && renderGallery()} + + {/* Map (standalone tab — only in single-column mode) */} + {view === 'map' && perms.share_map && ( +
+ +
+ )} +
+ )} {/* Powered by */}
From 001b2365a146748bf647fd139a56f588178d8619 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 22:21:07 +0200 Subject: [PATCH 4/8] fix(journey): correct map marker color offset and scroll-sync for unlocated entries - sidebarMapItems now derives dayIdx from all timeline dates (not just located-entry dates), so markers stay color-aligned with day headers even when some days have no location - scroll-sync no longer calls highlightMarker for unlocated entries, preventing the map from clearing or misfiring when the scroll winner has no corresponding marker - same dayIdx fix applied to JourneyPublicPage desktop two-column view --- client/src/pages/JourneyDetailPage.tsx | 20 ++++++++--- client/src/pages/JourneyPublicPage.tsx | 50 +++++++++++++++++++------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 41a6da26..16afe83e 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -190,7 +190,9 @@ export default function JourneyDetailPage() { const winner = lastPast || firstAhead if (winner) { setActiveEntryId(winner.id) - mapRef.current?.highlightMarker(winner.id) + if (locatedEntryIdsRef.current.has(winner.id)) { + mapRef.current?.highlightMarker(winner.id) + } } } const onScroll = () => { @@ -282,11 +284,16 @@ export default function JourneyDetailPage() { ) const sidebarMapItems = useMemo(() => { + const allDates = [...new Set( + (current?.entries || []) + .filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') + .map(e => e.entry_date) + .sort() + )] const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) - const uniqueDates = [...new Set(sorted.map(e => e.entry_date))] const dayCounters = new Map() return sorted.map(e => { - const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayIdx = allDates.indexOf(e.entry_date) const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1 dayCounters.set(e.entry_date, dayLabel) return { @@ -302,7 +309,12 @@ export default function JourneyDetailPage() { dayLabel, } }) - }, [mapEntries]) + }, [mapEntries, current?.entries]) + + const locatedEntryIdsRef = useRef(new Set()) + useEffect(() => { + locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id)) + }, [sidebarMapItems]) const tripDates = useMemo(() => { const dates = new Set() diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index f98af73e..b7a9772a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useParams } from 'react-router-dom' import { journeyApi } from '../api/client' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' @@ -10,6 +10,7 @@ import { ThumbsUp, ThumbsDown, } from 'lucide-react' import JourneyMap from '../components/Journey/JourneyMap' +import type { JourneyMapHandle } from '../components/Journey/JourneyMap' import JournalBody from '../components/Journey/JournalBody' import PhotoLightbox from '../components/Journey/PhotoLightbox' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' @@ -93,6 +94,15 @@ export default function JourneyPublicPage() { const { t } = useTranslation() const [showLangPicker, setShowLangPicker] = useState(false) const locale = useSettingsStore(s => s.settings.language) || 'en' + const mapRef = useRef(null) + const [activeEntryId, setActiveEntryId] = useState(null) + + const handleMarkerClick = useCallback((entryId: string) => { + setActiveEntryId(entryId) + mapRef.current?.highlightMarker(entryId) + document.querySelector(`[data-entry-id="${entryId}"]`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, []) useEffect(() => { if (!token) return @@ -119,12 +129,13 @@ export default function JourneyPublicPage() { ) const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries]) - // Map entries with day color/label for colored markers + // Map entries with day color/label for colored markers. + // dayIdx is derived from sortedDates (ALL timeline dates) so marker colors + // stay in sync with the timeline day headers even when some days have no locations. const sidebarMapItems = useMemo(() => { - const uniqueDates = [...new Set(mapEntries.map(e => e.entry_date).sort())] const counters = new Map() return mapEntries.map(e => { - const dayIdx = uniqueDates.indexOf(e.entry_date) + const dayIdx = sortedDates.indexOf(e.entry_date) const dayLabel = (counters.get(e.entry_date) ?? 0) + 1 counters.set(e.entry_date, dayLabel) return { @@ -139,7 +150,7 @@ export default function JourneyPublicPage() { dayLabel, } }) - }, [mapEntries]) + }, [mapEntries, sortedDates]) // Two-column desktop layout: timeline feed left + sticky map right const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map @@ -153,7 +164,7 @@ export default function JourneyPublicPage() { // When switching to desktop two-column, 'map' standalone tab no longer exists useEffect(() => { if (desktopTwoColumn && view === 'map') setView('timeline') - }, [desktopTwoColumn]) + }, [desktopTwoColumn, view]) if (loading) { return ( @@ -223,8 +234,18 @@ export default function JourneyPublicPage() { const hasProscons = prosArr.length > 0 || consArr.length > 0 const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })) + const isActive = activeEntryId === String(entry.id) return ( -
+
{ + if (!desktopTwoColumn) return + setActiveEntryId(String(entry.id)) + mapRef.current?.highlightMarker(String(entry.id)) + }} + style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined} + className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden"> {/* Photo area */} {photos.length === 1 && ( @@ -324,7 +345,7 @@ export default function JourneyPublicPage() { {/* Pros & Cons */} {hasProscons && ( -
+
0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}> {prosArr.length > 0 && (
@@ -476,19 +497,19 @@ export default function JourneyPublicPage() { {/* Content */} {desktopTwoColumn ? ( // ── Desktop two-column: scrollable timeline feed + sticky map ────────── -
+
{/* Left: feed */} -
+
{renderTabs(availableViews)} {view === 'timeline' && perms.share_timeline && renderTimeline()} {view === 'gallery' && perms.share_gallery && renderGallery()}
- {/* Right: sticky map */} + {/* Right: sticky map — matches auth page aside proportions */} @@ -536,7 +560,7 @@ export default function JourneyPublicPage() { {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} + mapEntries={sidebarMapItems as any} dark={document.documentElement.classList.contains('dark')} readOnly onEntryClick={() => {}} From 4d3bf390a52775db95df424cb58c360f17ca08d2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 22:26:03 +0200 Subject: [PATCH 5/8] feat(journey/settings): warn on unsaved changes before closing modal - Track dirty state (title/subtitle changed from original) - Intercept X button, backdrop click, and Cancel with handleClose - Show ConfirmDialog when dirty; proceed with onClose only on confirm - Add common.discardChanges and common.discard keys to all 15 locales --- client/src/i18n/translations/ar.ts | 2 ++ client/src/i18n/translations/br.ts | 2 ++ client/src/i18n/translations/cs.ts | 2 ++ client/src/i18n/translations/de.ts | 2 ++ client/src/i18n/translations/en.ts | 2 ++ client/src/i18n/translations/es.ts | 2 ++ client/src/i18n/translations/fr.ts | 2 ++ client/src/i18n/translations/hu.ts | 2 ++ client/src/i18n/translations/id.ts | 2 ++ client/src/i18n/translations/it.ts | 2 ++ client/src/i18n/translations/nl.ts | 2 ++ client/src/i18n/translations/pl.ts | 2 ++ client/src/i18n/translations/ru.ts | 2 ++ client/src/i18n/translations/zh.ts | 2 ++ client/src/i18n/translations/zhTw.ts | 2 ++ client/src/pages/JourneyDetailPage.tsx | 20 +++++++++++++++++--- 16 files changed, 47 insertions(+), 3 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 896f3434..77fb21df 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -34,6 +34,8 @@ const ar: Record = { 'common.none': 'لا شيء', 'common.date': 'التاريخ', 'common.rename': 'إعادة تسمية', + 'common.discardChanges': 'تجاهل التغييرات', + 'common.discard': 'تجاهل', 'common.name': 'الاسم', 'common.email': 'البريد الإلكتروني', 'common.password': 'كلمة المرور', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 40ebc591..0ee132d5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -30,6 +30,8 @@ const br: Record = { 'common.none': 'Nenhum', 'common.date': 'Data', 'common.rename': 'Renomear', + 'common.discardChanges': 'Descartar alterações', + 'common.discard': 'Descartar', 'common.name': 'Nome', 'common.email': 'E-mail', 'common.password': 'Senha', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a4881622..55f7160d 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -30,6 +30,8 @@ const cs: Record = { 'common.none': 'Žádné', 'common.date': 'Datum', 'common.rename': 'Přejmenovat', + 'common.discardChanges': 'Zahodit změny', + 'common.discard': 'Zahodit', 'common.name': 'Jméno', 'common.email': 'E-mail', 'common.password': 'Heslo', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index adb4f980..0834fc62 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -30,6 +30,8 @@ const de: Record = { 'common.none': 'Keine', 'common.date': 'Datum', 'common.rename': 'Umbenennen', + 'common.discardChanges': 'Änderungen verwerfen', + 'common.discard': 'Verwerfen', 'common.name': 'Name', 'common.email': 'E-Mail', 'common.password': 'Passwort', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 21236211..70559aca 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -30,6 +30,8 @@ const en: Record = { 'common.none': 'None', 'common.date': 'Date', 'common.rename': 'Rename', + 'common.discardChanges': 'Discard Changes', + 'common.discard': 'Discard', 'common.name': 'Name', 'common.email': 'Email', 'common.password': 'Password', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 9bf98f25..d587df5a 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -30,6 +30,8 @@ const es: Record = { 'common.none': 'Ninguno', 'common.date': 'Fecha', 'common.rename': 'Renombrar', + 'common.discardChanges': 'Descartar cambios', + 'common.discard': 'Descartar', 'common.name': 'Nombre', 'common.email': 'Correo', 'common.password': 'Contraseña', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 8f2ceff0..2d225b87 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -30,6 +30,8 @@ const fr: Record = { 'common.none': 'Aucun', 'common.date': 'Date', 'common.rename': 'Renommer', + 'common.discardChanges': 'Ignorer les modifications', + 'common.discard': 'Ignorer', 'common.name': 'Nom', 'common.email': 'E-mail', 'common.password': 'Mot de passe', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f815e7d3..9f243a25 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -30,6 +30,8 @@ const hu: Record = { 'common.none': 'Nincs', 'common.date': 'Dátum', 'common.rename': 'Átnevezés', + 'common.discardChanges': 'Változtatások elvetése', + 'common.discard': 'Elveti', 'common.name': 'Név', 'common.email': 'E-mail', 'common.password': 'Jelszó', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 58c9d3a7..84f8e8f0 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -30,6 +30,8 @@ const id: Record = { 'common.none': 'Tidak ada', 'common.date': 'Tanggal', 'common.rename': 'Ganti nama', + 'common.discardChanges': 'Buang perubahan', + 'common.discard': 'Buang', 'common.name': 'Nama', 'common.email': 'Email', 'common.password': 'Kata sandi', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 1538a096..40e8ff2f 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -30,6 +30,8 @@ const it: Record = { 'common.none': 'Nessuno', 'common.date': 'Data', 'common.rename': 'Rinomina', + 'common.discardChanges': 'Scarta modifiche', + 'common.discard': 'Scarta', 'common.name': 'Nome', 'common.email': 'Email', 'common.password': 'Password', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 54fead7f..ab8c9781 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -30,6 +30,8 @@ const nl: Record = { 'common.none': 'Geen', 'common.date': 'Datum', 'common.rename': 'Hernoemen', + 'common.discardChanges': 'Wijzigingen verwerpen', + 'common.discard': 'Verwerpen', 'common.name': 'Naam', 'common.email': 'E-mail', 'common.password': 'Wachtwoord', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index a6876784..d05a0a13 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -26,6 +26,8 @@ const pl: Record = { 'common.none': 'Brak', 'common.date': 'Data', 'common.rename': 'Zmień nazwę', + 'common.discardChanges': 'Odrzuć zmiany', + 'common.discard': 'Odrzuć', 'common.name': 'Nazwa', 'common.email': 'E-mail', 'common.password': 'Hasło', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 09527621..3995a461 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -30,6 +30,8 @@ const ru: Record = { 'common.none': 'Нет', 'common.date': 'Дата', 'common.rename': 'Переименовать', + 'common.discardChanges': 'Отменить изменения', + 'common.discard': 'Отменить', 'common.name': 'Имя', 'common.email': 'Эл. почта', 'common.password': 'Пароль', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 85e87090..096eea8d 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -30,6 +30,8 @@ const zh: Record = { 'common.none': '无', 'common.date': '日期', 'common.rename': '重命名', + 'common.discardChanges': '放弃更改', + 'common.discard': '放弃', 'common.name': '名称', 'common.email': '邮箱', 'common.password': '密码', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index a22a91a9..c7b75b8a 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -30,6 +30,8 @@ const zhTw: Record = { 'common.none': '無', 'common.date': '日期', 'common.rename': '重新命名', + 'common.discardChanges': '捨棄變更', + 'common.discard': '捨棄', 'common.name': '名稱', 'common.email': '郵箱', 'common.password': '密碼', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 16afe83e..efc1e309 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -3002,6 +3002,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr const [saving, setSaving] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false) const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null) + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) + + const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '') + const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() } const coverRef = useRef(null) const toast = useToast() const navigate = useNavigate() @@ -3060,12 +3064,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr } return ( -
{ if (e.target === e.currentTarget) e.preventDefault() }}> +
{ if (e.target === e.currentTarget) e.preventDefault() }}>
e.stopPropagation()}>

{t('journey.settings.title')}

-
@@ -3212,7 +3216,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr {journey.status === 'archived' ? : } {journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')} - + @@ -3259,6 +3263,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr confirmLabel={t('common.delete')} danger /> + + setShowDiscardConfirm(false)} + onConfirm={() => { setShowDiscardConfirm(false); onClose() }} + title={t('common.discardChanges')} + message={t('journey.editor.discardChangesConfirm')} + confirmLabel={t('common.discard')} + danger + />
) } From e7fb78dc1e58be0353ed0b836d600df0dce5a028 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 22:27:11 +0200 Subject: [PATCH 6/8] fix(journey/settings): translate 'Remove share link' button using share.deleteLink key --- client/src/pages/JourneyDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index efc1e309..f8e3db24 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -2981,7 +2981,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) { onClick={deleteLink} className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start" > - Remove share link + {t('share.deleteLink')}
)} From 288d33ba421b41a4fe3522d5da798886bc390933 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 22:33:25 +0200 Subject: [PATCH 7/8] fix(journey/mobile): eliminate carousel scroll stutter on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Defer activeIndex updates until scrolling settles (150ms debounce) instead of updating every RAF — mid-swipe card resize (240→320px) caused layout reflow on every frame, which is the main stutter source - Switch scrollSnapType from 'proximity' to 'mandatory' for reliable browser-native snapping without needing a JS re-center pass - Remove scroll-smooth CSS class (conflicts with mandatory snap) - Remove the post-settle scrollIntoView call (mandatory snap handles it) - Drop the now-unused activeIndexRef Closes #818 --- .../components/Journey/MobileMapTimeline.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index cd543a91..27ead4b0 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -53,9 +53,6 @@ export default function MobileMapTimeline({ }) }, [entries]) const cardRefs = useRef>(new Map()) - const activeIndexRef = useRef(activeIndex) - useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) - // Sync map focus when carousel scrolls (with guard for uninitialized map) const syncMapToCarousel = useCallback((index: number) => { const entry = entries[index] @@ -90,29 +87,19 @@ export default function MobileMapTimeline({ }) }, [syncMapToCarousel]) - // Track scroll; debounce to re-center the active card when the user stops. + // Defer all state updates until scrolling settles — updating activeIndex + // mid-swipe resizes cards (240→320px), causing layout reflow every frame. useEffect(() => { const el = carouselRef.current if (!el || entries.length === 0) return - let rafId: number | null = null let settleTimer: number | null = null const onScroll = () => { - if (rafId != null) return - rafId = requestAnimationFrame(() => { - pickNearestCard() - rafId = null - }) if (settleTimer != null) window.clearTimeout(settleTimer) - settleTimer = window.setTimeout(() => { - // Ensure the active card sits at the center once the user settles. - const card = cardRefs.current.get(activeIndexRef.current) - card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) - }, 180) + settleTimer = window.setTimeout(pickNearestCard, 150) } el.addEventListener('scroll', onScroll, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) - if (rafId != null) cancelAnimationFrame(rafId) if (settleTimer != null) window.clearTimeout(settleTimer) } }, [entries.length, pickNearestCard]) @@ -210,9 +197,9 @@ export default function MobileMapTimeline({ >
Date: Tue, 21 Apr 2026 22:51:48 +0200 Subject: [PATCH 8/8] test: update tests to match translated share link button and desktop two-column map layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Remove share link' → 'Delete link' (now uses share.deleteLink i18n key) - FE-PAGE-PUBLICJOURNEY-009/012: map tab no longer exists in desktop two-column layout; map is always rendered in the sidebar — tests updated to verify the journey-map testid is present without requiring a tab click --- client/src/pages/JourneyDetailPage.test.tsx | 8 ++--- client/src/pages/JourneyPublicPage.test.tsx | 36 +++++++-------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 390f1cfc..3abdc2e3 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-074 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => { - it('clicking "Remove share link" calls DELETE and returns to create state', async () => { + it('clicking "Delete link" calls DELETE and returns to create state', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); let deleteCalled = false; @@ -1493,10 +1493,10 @@ describe('JourneyDetailPage', () => { await openSettingsDialog(user); await waitFor(() => { - expect(screen.getByText('Remove share link')).toBeInTheDocument(); + expect(screen.getByText('Delete link')).toBeInTheDocument(); }); - await user.click(screen.getByText('Remove share link')); + await user.click(screen.getByText('Delete link')); await waitFor(() => { expect(deleteCalled).toBe(true); @@ -2905,7 +2905,7 @@ describe('JourneyDetailPage', () => { // The permission toggles show Timeline, Gallery, Map labels within the share section // These reuse the same i18n keys as the main tab bar - expect(screen.getByText('Remove share link')).toBeInTheDocument(); + expect(screen.getByText('Delete link')).toBeInTheDocument(); expect(screen.getByText('Copy')).toBeInTheDocument(); }); }); diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx index d831a485..1702a7d0 100644 --- a/client/src/pages/JourneyPublicPage.test.tsx +++ b/client/src/pages/JourneyPublicPage.test.tsx @@ -234,28 +234,20 @@ describe('JourneyPublicPage', () => { } }); - it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => { + it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); - const buttons = screen.getAllByRole('button'); - const mapBtn = buttons.find( - btn => btn.textContent && /map/i.test(btn.textContent), - ); - expect(mapBtn).toBeDefined(); - if (mapBtn) { - fireEvent.click(mapBtn); - // After clicking map tab, the timeline entries should no longer be visible - // and the map view content should be rendered (even if JourneyMap errors internally - // due to jsdom limitations, the tab state switches) - await waitFor(() => { - // Shibuya Crossing (timeline-only) should not appear once map is active - expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument(); - }); - } + // Desktop two-column: map sidebar is always rendered alongside the timeline; + // there is no standalone "Map" tab button on desktop. + await waitFor(() => { + expect(screen.getByTestId('journey-map')).toBeInTheDocument(); + }); + // Timeline entries remain visible (two-column shows both simultaneously) + expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); }); it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => { @@ -303,24 +295,18 @@ describe('JourneyPublicPage', () => { }); // FE-PAGE-PUBLICJOURNEY-012 - it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => { - const user = userEvent.setup(); + it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => { setupSuccess(); render(); await waitFor(() => { expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); }); - const mapBtn = screen.getAllByRole('button').find( - btn => btn.textContent && /map/i.test(btn.textContent), - ); - expect(mapBtn).toBeDefined(); - await user.click(mapBtn!); - + // Desktop two-column: map sidebar is always rendered; no tab click required. await waitFor(() => { expect(screen.getByTestId('journey-map')).toBeInTheDocument(); }); - // Map receives entries with lat/lng + // Both fixture entries have coordinates → map receives 2 located entries expect(screen.getByTestId('journey-map').textContent).toContain('2'); });