Compare commits

..

1 Commits

Author SHA1 Message Date
Julien G. 9e1982a57c Merge 881b9d0939 into 6df5edfbdb 2026-04-21 17:26:11 +02:00
57 changed files with 459 additions and 1152 deletions
+1 -5
View File
@@ -6,11 +6,7 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
### Your trips. Your plan. Your server.
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
+23 -16
View File
@@ -9,8 +9,6 @@ export interface MapMarkerItem {
label: string
mood?: string | null
time: string
dayColor: string
dayLabel: number
}
export interface JourneyMapHandle {
@@ -26,8 +24,6 @@ interface MapEntry {
title?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
@@ -53,8 +49,6 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry',
mood: e.mood,
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
})
}
}
@@ -65,19 +59,30 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
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')
const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? (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 2px 4px rgba(0,0,0,0.25))'
const label = String(dayLabel)
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
}
@@ -110,11 +115,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(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(item.dayColor, item.dayLabel, false),
html: markerSvg(idx, false, isDark),
}))
marker.setZIndexOffset(0)
}
@@ -124,11 +130,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(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(item.dayColor, item.dayLabel, true),
html: markerSvg(idx, true, isDark),
}))
marker.setZIndexOffset(1000)
}
@@ -219,7 +226,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
html: markerSvg(i, false, !!dark),
})
const marker = L.marker(pos, { icon }).addTo(map)
@@ -14,8 +14,6 @@ interface MapEntry {
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
+17 -16
View File
@@ -18,8 +18,6 @@ interface MapEntry {
location_name?: string | null
mood?: string | null
entry_date: string
dayColor?: string
dayLabel?: number
}
interface Props {
@@ -41,8 +39,6 @@ interface Item {
label: string
locationName: string
time: string
dayColor: string
dayLabel: number
}
const MARKER_W = 28
@@ -59,8 +55,6 @@ function buildItems(entries: MapEntry[]): Item[] {
label: e.title || '',
locationName: e.location_name || '',
time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
})
}
}
@@ -163,15 +157,21 @@ function ensureJourneyPopupStyle() {
document.head.appendChild(s)
}
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)'
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')
const shadow = highlighted
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
? (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 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1
const label = String(dayLabel)
const label = String(index + 1)
// 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(dayColor: string, dayLabel: number, highlighted: boolean): H
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 = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>`
@@ -273,12 +273,13 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(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(item.dayColor, item.dayLabel, highlighted)
const next = markerHtml(idx, highlighted, !!darkRef.current)
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML
@@ -381,8 +382,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
}
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
items.forEach((item, i) => {
const el = markerHtml(i, false, !!darkRef.current)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat])
.addTo(map)
@@ -1,5 +1,4 @@
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<string, typeof Smile> = {
@@ -38,14 +37,13 @@ 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 }
dayLabel: number
dayColor: string
index: number
isActive: boolean
onClick: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
export default function MobileEntryCard({ entry, index, 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
@@ -100,8 +98,8 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
<div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
{dayLabel}
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{index + 1}
</span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
@@ -143,7 +141,7 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
<span className="truncate">{entry.location_name || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,7 +6,6 @@ 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<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -131,7 +130,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{formatLocationName(entry.location_name)}
{entry.location_name}
</span>
</div>
)}
@@ -1,10 +1,9 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { useRef, useState, useEffect, useCallback } 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
@@ -24,7 +23,6 @@ interface Props {
onEntryClick: (entry: any) => void
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
}
export default function MobileMapTimeline({
@@ -36,23 +34,14 @@ export default function MobileMapTimeline({
onEntryClick,
onAddEntry,
publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(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<string, number>()
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<Map<number, HTMLDivElement>>(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]
@@ -87,19 +76,29 @@ export default function MobileMapTimeline({
})
}, [syncMapToCarousel])
// Defer all state updates until scrolling settles — updating activeIndex
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
// Track scroll; debounce to re-center the active card when the user stops.
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(pickNearestCard, 150)
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)
}
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])
@@ -143,10 +142,7 @@ export default function MobileMapTimeline({
if (entries.length === 0) {
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
<JourneyMap
ref={mapRef}
entries={mapEntries}
@@ -172,10 +168,7 @@ export default function MobileMapTimeline({
}
return (
<div
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
{/* Full-screen map */}
<JourneyMap
ref={mapRef}
@@ -193,13 +186,13 @@ export default function MobileMapTimeline({
{/* Bottom carousel */}
<div
className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
scrollSnapType: 'x proximity',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
@@ -214,8 +207,7 @@ export default function MobileMapTimeline({
>
<MobileEntryCard
entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
index={i}
isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl}
@@ -1,32 +0,0 @@
export const DAY_COLORS = [
'#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
]
+1 -15
View File
@@ -61,25 +61,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } })
}
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null)
useEffect(() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = null
}
}, [])
const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = window.setTimeout(() => {
window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning')
themeTransitionTimer.current = null
}, 360)
}
+20 -26
View File
@@ -1,15 +1,11 @@
/**
* OfflineBanner — connectivity + sync state indicator.
* OfflineBanner — persistent top bar indicating connectivity + sync state.
*
* States:
* offline + N queued → amber pill "Offline · N queued"
* offline + 0 queued → amber pill "Offline"
* online + N pending → blue pill "Syncing N…"
* offline + N queued → amber bar "Offline N changes queued"
* offline + 0 queued → amber bar "Offline"
* online + N pending → blue bar "Syncing N changes…"
* online + 0 pending → hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
@@ -52,9 +48,9 @@ export default function OfflineBanner(): React.ReactElement | null {
const label = offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline'
: `Syncing ${pendingCount}`
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return (
<div
@@ -62,29 +58,27 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite"
style={{
position: 'fixed',
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
// so the pill sits 16px from the bottom.
bottom: 'calc(var(--bottom-nav-h) + 16px)',
left: '50%',
transform: 'translateX(-50%)',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: bg,
color: text,
display: 'inline-flex',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
fontSize: 12,
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
}}
>
{offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
@@ -208,14 +208,9 @@ interface ArtikelZeileProps {
canEdit?: boolean
}
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
const PACKING_PLACEHOLDER_NAME = '...'
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false)
@@ -228,7 +223,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -280,10 +275,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{editing && canEdit ? (
<input
type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
@@ -292,7 +286,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{
flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text',
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -168,7 +168,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
@@ -360,25 +360,6 @@ export default function PlaceFormModal({
onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */}
@@ -632,6 +613,23 @@ export default function PlaceFormModal({
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -203,10 +203,8 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled();
@@ -271,22 +271,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
@@ -432,17 +417,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<CustomSelect
value={form.hotel_place_id}
onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, hotel_place_id: value }
if (!value) {
next.location = ''
} else if (p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
if (p) {
if (!form.title) set('title', p.name)
if (!form.location && p.address) set('location', p.address)
}
}}
placeholder={t('reservations.meta.pickHotel')}
options={[
@@ -637,6 +617,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</>
)}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -112,30 +112,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
const name = day.title || t('dayplan.dayN', { n: day.day_number })
const badge = day.date
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span>{name}</span>
{badge && (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
)}
</span>
)
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
const dayLabel = (day: typeof startDay): string => {
if (!day) return ''
const base = day.title || t('dayplan.dayN', { n: day.day_number })
if (day.date) {
const d = new Date(day.date + 'T00:00:00Z')
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
return `${base} · ${dateStr}`
}
return base
}
return (
@@ -148,15 +135,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* Header wraps to a second row on narrow screens so the status/category chips
never collide with the title. */}
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
flexWrap: 'wrap',
padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
@@ -217,15 +202,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport/hotel reservations linked to days */}
{(isTransportType || isHotel) && startDay && (
{/* Day label for transport reservations linked to a day */}
{isTransportType && startDay && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
<DayLabel day={startDay} />
{endDay && endDay.id !== startDay.id && (
<><span style={{ color: 'var(--text-faint)' }}></span><DayLabel day={endDay} /></>
)}
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''}
</div>
</div>
)}
@@ -237,16 +237,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -422,6 +412,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
@@ -155,9 +155,7 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render(<DisplaySettingsTab />);
// The label is split across a text node ('24h') and a responsive span (' (14:30)').
// Click the button that contains the 24h text instead of matching the full string.
await user.click(screen.getByRole('button', { name: /24h/ }));
await user.click(screen.getByText('24h (14:30)'));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
});
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3">
{[
{ value: '24h', short: '24h', example: '14:30' },
{ value: '12h', short: '12h', example: '2:30 PM' },
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
].map(opt => (
<button
key={opt.value}
@@ -207,8 +207,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s',
}}
>
{opt.short}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
{opt.label}
</button>
))}
</div>
@@ -240,18 +240,14 @@ export default function MapSettingsTab(): React.ReactElement {
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div>
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
@@ -5,7 +5,6 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
interface ProviderField {
key: string
@@ -223,13 +222,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => (
<div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? (
<div className="flex items-center gap-3">
<ToggleSwitch
on={values[field.key] === 'true'}
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={values[field.key] === 'true'}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/>
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</div>
</label>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
@@ -247,9 +248,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
)}
</div>
))}
{/* Wraps on mobile so the connection badge drops to its own row
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
@@ -267,17 +266,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />}
<span className="sm:hidden">{t('memories.testShort')}</span>
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
{t('memories.testConnection')}
</button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
@@ -2,10 +2,9 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button type="button" onClick={onToggle}
<button onClick={onToggle}
style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
@@ -1,108 +0,0 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
@@ -119,14 +119,13 @@ 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, h = 360
const w = 268, pad = 8
const vw = window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
const vh = 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 + h > vh - pad) top = r.top - h - 4
top = Math.max(pad, Math.min(top, vh - h - pad))
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left }
})(),
+8 -9
View File
@@ -61,15 +61,14 @@ export default function Modal({
<div
className={`
trek-modal-enter
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
`}
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
{/* Header — stays put even while the body scrolls */}
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
{/* Header */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && (
<button
@@ -81,14 +80,14 @@ export default function Modal({
)}
</div>
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
<div className="flex-1 overflow-y-auto p-6 min-h-0">
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
{/* Footer — sticky at the bottom of the modal, never compressed */}
{/* Footer */}
{footer && (
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer}
</div>
)}
-3
View File
@@ -34,8 +34,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء',
'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور',
@@ -1625,7 +1623,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
-3
View File
@@ -30,8 +30,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1664,7 +1662,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão',
'memories.testShort': 'Testar',
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
-3
View File
@@ -30,8 +30,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1623,7 +1621,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení',
'memories.testShort': 'Otestovat',
'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno',
-3
View File
@@ -30,8 +30,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1627,7 +1625,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden',
-17
View File
@@ -30,8 +30,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -124,20 +122,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.confirm.copy.title': 'Copy this trip?',
'dashboard.confirm.copy.willCopy': 'Will be copied',
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
'dashboard.confirm.copy.will3': 'Budget items & category order',
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
'dashboard.confirm.copy.will6': 'Day notes',
'dashboard.confirm.copy.wontCopy': "Won't be copied",
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
'dashboard.confirm.copy.wont3': 'Files & photos',
'dashboard.confirm.copy.wont4': 'Share tokens',
'dashboard.confirm.copy.confirm': 'Copy trip',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title',
@@ -1700,7 +1684,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection',
'memories.testShort': 'Test',
'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected',
'memories.disconnected': 'Not connected',
-3
View File
@@ -30,8 +30,6 @@ const es: Record<string, string> = {
'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',
@@ -1564,7 +1562,6 @@ const es: Record<string, string> = {
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión',
'memories.testShort': 'Probar',
'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado',
-3
View File
@@ -30,8 +30,6 @@ const fr: Record<string, string> = {
'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',
@@ -1621,7 +1619,6 @@ const fr: Record<string, string> = {
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion',
'memories.testShort': 'Tester',
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté',
-3
View File
@@ -30,8 +30,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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ó',
@@ -1692,7 +1690,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testShort': 'Teszt',
'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva',
'memories.disconnected': 'Nincs csatlakoztatva',
-3
View File
@@ -30,8 +30,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1684,7 +1682,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
'memories.testConnection': 'Uji koneksi',
'memories.testShort': 'Uji',
'memories.testFirst': 'Uji koneksi terlebih dahulu',
'memories.connected': 'Terhubung',
'memories.disconnected': 'Tidak terhubung',
-3
View File
@@ -30,8 +30,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1622,7 +1620,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione',
'memories.testShort': 'Prova',
'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso',
+4 -7
View File
@@ -30,8 +30,6 @@ const nl: Record<string, string> = {
'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',
@@ -614,8 +612,8 @@ const nl: Record<string, string> = {
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
'admin.collab.notes.title': 'Notities',
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
'admin.collab.polls.title': 'Polls',
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
'admin.collab.polls.title': 'Peilingen',
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
'admin.collab.whatsnext.title': 'Wat nu',
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
'admin.tabs.config': 'Personalisatie',
@@ -1621,7 +1619,6 @@ const nl: Record<string, string> = {
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen',
'memories.testShort': 'Testen',
'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden',
@@ -1661,7 +1658,7 @@ const nl: Record<string, string> = {
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls',
'collab.tabs.polls': 'Peilingen',
'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen',
@@ -1707,7 +1704,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Polls',
'collab.polls.title': 'Peilingen',
'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
-3
View File
@@ -26,8 +26,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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',
@@ -1573,7 +1571,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test',
'memories.testShort': 'Test',
'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono',
'memories.connectionSuccess': 'Połączono z Immich',
-3
View File
@@ -30,8 +30,6 @@ const ru: Record<string, string> = {
'common.none': 'Нет',
'common.date': 'Дата',
'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.name': 'Имя',
'common.email': 'Эл. почта',
'common.password': 'Пароль',
@@ -1621,7 +1619,6 @@ const ru: Record<string, string> = {
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение',
'memories.testShort': 'Проверить',
'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено',
-3
View File
@@ -30,8 +30,6 @@ const zh: Record<string, string> = {
'common.none': '无',
'common.date': '日期',
'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.name': '名称',
'common.email': '邮箱',
'common.password': '密码',
@@ -1621,7 +1619,6 @@ const zh: Record<string, string> = {
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接',
'memories.testShort': '测试',
'memories.testFirst': '请先测试连接',
'memories.connected': '已连接',
'memories.disconnected': '未连接',
-3
View File
@@ -30,8 +30,6 @@ const zhTw: Record<string, string> = {
'common.none': '無',
'common.date': '日期',
'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.name': '名稱',
'common.email': '郵箱',
'common.password': '密碼',
@@ -1681,7 +1679,6 @@ const zhTw: Record<string, string> = {
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線',
'memories.testShort': '測試',
'memories.testFirst': '請先測試連線',
'memories.connected': '已連線',
'memories.disconnected': '未連線',
+2 -11
View File
@@ -1240,15 +1240,6 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
ro.observe(el)
return () => ro.disconnect()
}, [])
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
@@ -1299,7 +1290,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// Bucket list content
const bucketContent = (
<>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
{bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => {
@@ -1409,7 +1400,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div ref={statsContentRef} className="flex items-stretch justify-center">
<div className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */}
-8
View File
@@ -401,10 +401,6 @@ describe('DashboardPage', () => {
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
});
@@ -770,10 +766,6 @@ describe('DashboardPage', () => {
expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
});
+2 -15
View File
@@ -12,7 +12,6 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp'
import {
@@ -700,7 +699,6 @@ export default function DashboardPage(): React.ReactElement {
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => {
setViewMode(prev => {
@@ -817,18 +815,14 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update))
}
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
const confirmCopy = async () => {
if (!copyTrip) return
const handleCopy = async (trip: DashboardTrip) => {
try {
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied'))
} catch {
toast.error(t('dashboard.toast.copyError'))
}
setCopyTrip(null)
}
const today = new Date().toISOString().split('T')[0]
@@ -1211,13 +1205,6 @@ export default function DashboardPage(): React.ReactElement {
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/>
<CopyTripDialog
isOpen={!!copyTrip}
tripTitle={copyTrip?.title || ''}
onClose={() => setCopyTrip(null)}
onConfirm={confirmCopy}
/>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1 }
+4 -4
View File
@@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
it('clicking "Remove share 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('Delete link')).toBeInTheDocument();
expect(screen.getByText('Remove share link')).toBeInTheDocument();
});
await user.click(screen.getByText('Delete link'));
await user.click(screen.getByText('Remove share 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('Delete link')).toBeInTheDocument();
expect(screen.getByText('Remove share link')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument();
});
});
+56 -114
View File
@@ -1,5 +1,4 @@
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'
@@ -9,7 +8,6 @@ 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'
@@ -69,13 +67,11 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
return groups
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
// instead of the browser's navigator.language.
return {
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
month: date.toLocaleDateString(locale, { month: 'long' }),
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(),
}
}
@@ -88,7 +84,7 @@ export default function JourneyDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
const { t } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
@@ -190,9 +186,7 @@ export default function JourneyDetailPage() {
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
mapRef.current?.highlightMarker(winner.id)
}
}
const onScroll = () => {
@@ -283,38 +277,16 @@ export default function JourneyDetailPage() {
[current?.entries]
)
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 dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.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, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
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 tripDates = useMemo(() => {
const dates = new Set<string>()
@@ -450,7 +422,7 @@ export default function JourneyDetailPage() {
? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden'
}
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
>
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div
@@ -510,7 +482,7 @@ export default function JourneyDetailPage() {
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
@@ -603,14 +575,14 @@ export default function JourneyDetailPage() {
{sortedDates.map((date, dayIdx) => {
const entries = dayGroups.get(date)!
const fd = formatDate(date, locale)
const fd = formatDate(date)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return (
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
{dayIdx + 1}
</div>
<div>
@@ -639,7 +611,7 @@ export default function JourneyDetailPage() {
.catch(() => toast.error(t('common.errorOccurred')))
}
return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
{canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button
@@ -761,8 +733,7 @@ export default function JourneyDetailPage() {
journey={current}
onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
/>
)}
@@ -845,7 +816,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
fullMapRef: React.RefObject<JourneyMapHandle | null>
onLocationClick: (id: string) => void
}) {
const { t, locale } = useTranslation()
const { t } = useTranslation()
// group map entries by date
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
mapEntries.forEach((e, i) => {
@@ -901,7 +872,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div className="px-5 pb-5">
{dates.map((date, dayIdx) => {
const items = byDate.get(date)!
const fd = formatDate(date, locale)
const fd = formatDate(date)
return (
<div key={date}>
@@ -944,7 +915,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div>
<div className="text-[11px] text-zinc-500 truncate">
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div>
</div>
@@ -1010,16 +981,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
}, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
for (const e of entries) {
for (const p of e.photos) {
const existing = seenPhotoIds.get(p.photo_id)
if (existing === undefined) {
seenPhotoIds.set(p.photo_id, allPhotos.length)
allPhotos.push({ photo: p, entry: e })
} else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
allPhotos[existing] = { photo: p, entry: e }
}
allPhotos.push({ photo: p, entry: e })
}
}
@@ -1064,27 +1028,23 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
}
const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState()
if (!store.current) return
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
if (!target) return
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
// Optimistic update — remove every row with this photo_id
const updated = {
...store.current,
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.photo_id !== target.photo_id),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
if (store.current) {
const updated = {
...store.current,
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== photoId),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
}
useJourneyStore.setState({ current: updated })
}
useJourneyStore.setState({ current: updated })
try {
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
await journeyApi.deletePhoto(photoId)
} catch {
toast.error(t('common.error'))
onRefresh()
onRefresh() // Revert on error
}
}
@@ -1398,7 +1358,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name)}</span>
<span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
@@ -1441,7 +1401,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
@@ -1520,7 +1480,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
{entry.title || t('journey.detail.newEntry')}
</div>
<div className="text-[11px] text-zinc-500 mt-0.5">
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div>
</div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
@@ -1804,11 +1764,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2>
@@ -1818,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
{[
@@ -1904,7 +1864,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
@@ -1957,7 +1917,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button
onClick={() => {
if (allSelected) {
@@ -1982,7 +1942,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
@@ -2055,7 +2015,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
@@ -2254,9 +2214,6 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0
)
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
@@ -2366,7 +2323,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{availableGalleryPhotos.map(gp => (
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
<div
key={gp.id}
onClick={async () => {
@@ -2386,7 +2343,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{availableGalleryPhotos.length === 0 && (
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)}
</div>
@@ -2995,7 +2952,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
>
{t('share.deleteLink')}
Remove share link
</button>
</div>
)}
@@ -3003,12 +2960,11 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
)
}
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
journey: JourneyDetail
onClose: () => void
onSaved: () => void
onOpenInvite: () => void
onRefresh: () => void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(journey.title)
@@ -3016,10 +2972,6 @@ 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<HTMLInputElement>(null)
const toast = useToast()
const navigate = useNavigate()
@@ -3078,12 +3030,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
}
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
@@ -3179,7 +3131,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onRefresh()
onSaved()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
@@ -3230,7 +3182,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')}
</button>
@@ -3277,16 +3229,6 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
confirmLabel={t('common.delete')}
danger
/>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div>
)
}
+25 -11
View File
@@ -234,20 +234,28 @@ describe('JourneyPublicPage', () => {
}
});
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).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();
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();
});
}
});
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
@@ -295,18 +303,24 @@ describe('JourneyPublicPage', () => {
});
// FE-PAGE-PUBLICJOURNEY-012
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
const user = userEvent.setup();
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// Desktop two-column: map sidebar is always rendered; no tab click required.
const mapBtn = screen.getAllByRole('button').find(
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
});
// Both fixture entries have coordinates → map receives 2 located entries
// Map receives entries with lat/lng
expect(screen.getByTestId('journey-map').textContent).toContain('2');
});
+160 -399
View File
@@ -1,22 +1,14 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
import { useEffect, useState, useMemo } from 'react'
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, Clock,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import { List, Grid, MapPin, Camera, BookOpen, Image } 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'
import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry {
id: number
@@ -44,31 +36,15 @@ interface PublicPhoto {
caption?: string | null
}
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
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<string, { icon: typeof Sun; label: string }> = {
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}`
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
return {
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }),
day: date.getDate(),
}
}
@@ -94,15 +70,6 @@ export default function JourneyPublicPage() {
const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(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
@@ -117,6 +84,10 @@ 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],
@@ -129,43 +100,12 @@ 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.
// 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 counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.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, sortedDates])
// 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, view])
if (loading) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
@@ -185,262 +125,21 @@ 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') },
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
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 = () => (
<div className="flex flex-col gap-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<BookOpen size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
</div>
)}
{sortedDates.map((date, dayIdx) => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date, locale)
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
return (
<div key={date}>
{/* Day header */}
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
style={{ background: dayColor }}
>
{dayIdx + 1}
</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
{/* Entries */}
<div className="flex flex-col gap-4 pl-[52px]">
{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 }))
const isActive = activeEntryId === String(entry.id)
return (
<div
key={entry.id}
data-entry-id={String(entry.id)}
onMouseEnter={() => {
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 && (
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
{entry.location_name && (
<div className="absolute top-3 left-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
</span>
</div>
)}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
</div>
)}
</div>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<img
key={p.id}
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className="w-full h-52 object-cover cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
{photos.length > 3 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
<Image size={13} /> +{photos.length - 3}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Content */}
<div className="px-5 pt-4 pb-5">
{/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
)}
{/* Location + time badges */}
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
<MapPin size={11} className="flex-shrink-0" />
{formatLocationName(entry.location_name)}
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
)}
{entry.entry_time && photos.length === 1 && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{prosArr.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
<ThumbsUp size={10} /> Pros
</div>
{prosArr.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{consArr.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
<ThumbsDown size={10} /> Cons
</div>
{consArr.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
{/* Mood + weather */}
{(mood || weather) && (
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
{mood && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
<mood.icon size={11} /> {mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={11} /> {weather.label}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
// Shared gallery renderer
const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
)
// Shared view tab bar
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{/* Cover image background */}
{journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)}
{/* Decorative circles */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
@@ -495,98 +194,160 @@ export default function JourneyPublicPage() {
</div>
{/* Content */}
{desktopTwoColumn ? (
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
{/* Left: feed */}
<div className="flex-1 min-w-0 px-8 py-6">
{renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* View tabs */}
{availableViews.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{availableViews.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)}
{/* Right: sticky map — matches auth page aside proportions */}
<aside
className="flex-shrink-0"
style={{
width: '44%', minWidth: 420, maxWidth: 760,
position: 'sticky', top: 0, height: '100dvh',
padding: '16px 16px 16px 0',
alignSelf: 'flex-start',
}}
>
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
/>
</div>
</aside>
</div>
) : (
// ── Single-column layout (mobile + desktop-without-map) ───────────────
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={mapEntries.map(e => ({ 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`}
/>
)}
{/* Floating view toggle — visible above the fullscreen map on mobile */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
{availableViews.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
{/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
<div className="flex flex-col gap-6">
{sortedDates.map(date => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date)
return (
<div key={date}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => (
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{entry.photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(entry.photos[0], token!)}
className="w-full h-52 object-cover cursor-pointer"
alt=""
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
/>
{entry.photos.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} /> +{entry.photos.length - 1}
</div>
)}
{entry.title && (
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
)}
<div className="px-5 py-4">
{!entry.photos.length && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
)}
{entry.location_name && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
<MapPin size={11} /> {entry.location_name}
</div>
)}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-3 mt-4">
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
{entry.pros_cons.pros!.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
{entry.pros_cons.cons!.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
</div>
)}
))}
</div>
)}
{renderTabs(availableViews)}
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems as any}
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)"
{/* Map */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={mapEntries.map(e => ({
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}
/>
)}
{/* 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 && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={sidebarMapItems as any}
height={500}
/>
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2">
-1
View File
@@ -642,7 +642,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
-2
View File
@@ -171,8 +171,6 @@ export interface Reservation {
place_id?: number | null
assignment_id?: number | null
accommodation_id?: number | null
accommodation_start_day_id?: number | null
accommodation_end_day_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
needs_review?: number
-33
View File
@@ -1,38 +1,5 @@
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<string>()
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 {
+5 -7
View File
@@ -64,13 +64,11 @@ class _MockIntersectionObserver {
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
// ResizeObserver — used by resizable panels
class _MockResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
}
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
})) as unknown as typeof ResizeObserver;
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

+2 -2
View File
@@ -58,7 +58,7 @@ export function listJourneys(userId: number) {
return db.prepare(`
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(DISTINCT jp.photo_id) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -160,7 +160,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = new Set(photos.map(p => p.photo_id)).size;
const photoCount = photos.length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
+9 -6
View File
@@ -57,8 +57,7 @@ const saveEndpoints = db.transaction((reservationId: number, endpoints: Endpoint
export function listReservations(tripId: string | number) {
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
@@ -94,8 +93,7 @@ export function listReservations(tripId: string | number) {
export function getReservationWithJoins(id: string | number) {
const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name,
ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
@@ -278,8 +276,13 @@ export function updateReservation(id: string | number, tripId: string | number,
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) {
if (resolvedAccId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
if (accPlaceId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
}
} else if (accPlaceId) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
-18
View File
@@ -681,24 +681,6 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
const oldTodos = db.prepare('SELECT * FROM todo_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertTodo = db.prepare(`
INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority)
VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)
`);
for (const t of oldTodos) {
insertTodo.run(newTripId, t.name, t.category, t.sort_order, t.due_date, t.description, t.priority);
}
const oldCategoryOrder = db.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?').all(sourceTripId) as any[];
const insertCategoryOrder = db.prepare(`
INSERT INTO budget_category_order (trip_id, category, sort_order)
VALUES (?, ?, ?)
`);
for (const o of oldCategoryOrder) {
insertCategoryOrder.run(newTripId, o.category, o.sort_order);
}
return Number(newTripId);
});
-46
View File
@@ -950,52 +950,6 @@ describe('Copy trip with data', () => {
expect(newNotes).toHaveLength(1);
expect(newNotes[0].text).toBe('Pack early!');
});
it('TRIP-027 — copy preserves todos (unchecked, unassigned) and budget category order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
testDb.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
// Two budget category order rows
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
insOrder.run(trip.id, 'Transport', 0);
insOrder.run(trip.id, 'Accommodation', 1);
const res = await request(app)
.post(`/api/trips/${trip.id}/copy`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Todo Trip (Copy)' });
expect(res.status).toBe(201);
const newId = res.body.trip.id;
// Todos copied with checked reset and assigned_user_id nulled
const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newTodos).toHaveLength(2);
expect(newTodos[0].name).toBe('Buy tickets');
expect(newTodos[0].category).toBe('Transport');
expect(newTodos[0].checked).toBe(0);
expect(newTodos[0].assigned_user_id).toBeNull();
expect(newTodos[0].due_date).toBe('2026-06-01');
expect(newTodos[0].description).toBe('Check Ryanair');
expect(newTodos[0].priority).toBe(1);
expect(newTodos[1].name).toBe('Book hotel');
expect(newTodos[1].checked).toBe(0);
expect(newTodos[1].assigned_user_id).toBeNull();
// Budget category order copied
const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
expect(newOrder).toHaveLength(2);
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
});
});
// ─────────────────────────────────────────────────────────────────────────────