mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 |
@@ -30,3 +30,7 @@ sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -12,6 +12,8 @@ body:
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
|
||||
+2
-1
@@ -60,4 +60,5 @@ coverage
|
||||
.scannerwork
|
||||
test-data
|
||||
|
||||
.run
|
||||
.run
|
||||
.full-review
|
||||
@@ -6,7 +6,11 @@
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
### Your trips. Your plan. Your server.
|
||||
<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>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface MapMarkerItem {
|
||||
label: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
@@ -24,6 +26,8 @@ interface MapEntry {
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
label: e.title || 'Entry',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? (dark
|
||||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const label = String(dayLabel)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
return `<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="${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>
|
||||
<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>
|
||||
</svg>
|
||||
</div>`
|
||||
}
|
||||
@@ -115,12 +110,11 @@ 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(idx, false, isDark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
}))
|
||||
marker.setZIndexOffset(0)
|
||||
}
|
||||
@@ -130,12 +124,11 @@ 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(idx, true, isDark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||
}))
|
||||
marker.setZIndexOffset(1000)
|
||||
}
|
||||
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(i, false, !!dark),
|
||||
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
|
||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -39,6 +41,8 @@ interface Item {
|
||||
label: string
|
||||
locationName: string
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
||||
label: e.title || '',
|
||||
locationName: e.location_name || '',
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||
const fill = dayColor
|
||||
const textColor = '#fff'
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? (dark
|
||||
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
const label = String(index + 1)
|
||||
const label = String(dayLabel)
|
||||
|
||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
|
||||
inner.className = 'trek-journey-marker-inner'
|
||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||
inner.innerHTML = `<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="2"/>
|
||||
<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"/>
|
||||
<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,13 +273,12 @@ 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(idx, highlighted, !!darkRef.current)
|
||||
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||
currentInner.style.cssText = nextInner.style.cssText
|
||||
currentInner.innerHTML = nextInner.innerHTML
|
||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
||||
}
|
||||
|
||||
// markers
|
||||
items.forEach((item, i) => {
|
||||
const el = markerHtml(i, false, !!darkRef.current)
|
||||
items.forEach((item) => {
|
||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
index: number
|
||||
dayLabel: number
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
||||
<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 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 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>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
||||
{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">{entry.location_name || 'On the map'}</span>
|
||||
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
@@ -130,7 +131,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" />
|
||||
{entry.location_name}
|
||||
{formatLocationName(entry.location_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { DAY_COLORS } from './dayColors'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
carouselBottom?: string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
@@ -34,14 +36,23 @@ 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 cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||
|
||||
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())
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
|
||||
// Track scroll; debounce to re-center the active card when the user stops.
|
||||
// Defer all state updates until scrolling settles — updating activeIndex
|
||||
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let rafId: number | null = null
|
||||
let settleTimer: number | null = null
|
||||
const onScroll = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
pickNearestCard()
|
||||
rafId = null
|
||||
})
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(() => {
|
||||
// Ensure the active card sits at the center once the user settles.
|
||||
const card = cardRefs.current.get(activeIndexRef.current)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, 180)
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<div
|
||||
className="fixed left-0 right-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
||||
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||
style={{
|
||||
scrollSnapType: 'x proximity',
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
index={i}
|
||||
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
]
|
||||
@@ -61,11 +61,25 @@ 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(() => {})
|
||||
window.setTimeout(() => {
|
||||
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||
themeTransitionTimer.current = window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||
themeTransitionTimer.current = null
|
||||
}, 360)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* offline + N queued → amber bar "Offline — N changes queued"
|
||||
* offline + 0 queued → amber bar "Offline"
|
||||
* online + N pending → blue bar "Syncing N changes…"
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
* 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'
|
||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
||||
: `Syncing ${pendingCount}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// 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%)',
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
display: 'flex',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
||||
paddingBottom: '6px',
|
||||
paddingLeft: '16px',
|
||||
paddingRight: '16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={14} />
|
||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -208,9 +208,14 @@ 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(item.name)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||
const [showBagPicker, setShowBagPicker] = useState(false)
|
||||
@@ -223,7 +228,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(item.name); return }
|
||||
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||
catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
@@ -275,9 +280,10 @@ 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(item.name) } }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -286,7 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
style={{
|
||||
flex: 1, fontSize: 13.5,
|
||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
color: isPlaceholder ? 'var(--text-faint)' : (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 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 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 style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
|
||||
@@ -360,6 +360,25 @@ 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 */}
|
||||
@@ -613,23 +632,6 @@ 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,8 +203,10 @@ 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 submit the form directly
|
||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
||||
// 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')!;
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
@@ -271,7 +271,22 @@ 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">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
@@ -417,12 +432,17 @@ 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)
|
||||
if (p) {
|
||||
if (!form.title) set('title', p.name)
|
||||
if (!form.location && p.address) set('location', p.address)
|
||||
}
|
||||
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
|
||||
})
|
||||
}}
|
||||
placeholder={t('reservations.meta.pickHotel')}
|
||||
options={[
|
||||
@@ -617,15 +637,6 @@ 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,17 +112,30 @@ 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 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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -135,13 +148,15 @@ 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 */}
|
||||
{/* Header — wraps to a second row on narrow screens so the status/category chips
|
||||
never collide with the title. */}
|
||||
<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 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||
@@ -202,12 +217,15 @@ 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 reservations linked to a day */}
|
||||
{isTransportType && startDay && (
|
||||
{/* Day label for transport/hotel reservations linked to days */}
|
||||
{(isTransportType || isHotel) && startDay && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -237,6 +237,16 @@ 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 }}>
|
||||
|
||||
@@ -412,15 +422,6 @@ 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,7 +155,9 @@ describe('DisplaySettingsTab', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('24h (14:30)'));
|
||||
// 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/ }));
|
||||
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', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
{ value: '24h', short: '24h', example: '14:30' },
|
||||
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.short}
|
||||
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<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="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">
|
||||
{t('settings.mapExperimental')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
{field.input_type === 'checkbox' ? (
|
||||
<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"
|
||||
<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')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
|
||||
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
@@ -266,15 +267,17 @@ 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" />}
|
||||
{t('memories.testConnection')}
|
||||
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
|
||||
</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="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="basis-full sm:basis-auto 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="text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="basis-full sm:basis-auto 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,9 +2,10 @@ import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button onClick={onToggle}
|
||||
<button type="button" onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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,13 +119,14 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
...(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (!r) return { top: 0, left: 0 }
|
||||
const w = 268, pad = 8
|
||||
const w = 268, pad = 8, h = 360
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||
let left = r.left
|
||||
let top = r.bottom + 4
|
||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
||||
if (top + h > vh - pad) top = r.top - h - 4
|
||||
top = Math.max(pad, Math.min(top, vh - h - pad))
|
||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||
return { top, left }
|
||||
})(),
|
||||
|
||||
@@ -61,14 +61,15 @@ export default function Modal({
|
||||
<div
|
||||
className={`
|
||||
trek-modal-enter
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||
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)]
|
||||
`}
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
{/* 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)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
@@ -80,14 +81,14 @@ export default function Modal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* 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">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||
{footer && (
|
||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,8 @@ 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': 'كلمة المرور',
|
||||
@@ -1623,6 +1625,7 @@ 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': 'غير متصل',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1662,6 +1664,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1621,6 +1623,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1625,6 +1627,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -122,6 +124,20 @@ 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',
|
||||
@@ -1684,6 +1700,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1562,6 +1564,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1619,6 +1621,7 @@ 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é',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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ó',
|
||||
@@ -1690,6 +1692,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1682,6 +1684,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -1620,6 +1622,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ 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',
|
||||
@@ -612,8 +614,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': 'Peilingen',
|
||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
||||
'admin.collab.polls.title': 'Polls',
|
||||
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
|
||||
'admin.collab.whatsnext.title': 'Wat nu',
|
||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||
'admin.tabs.config': 'Personalisatie',
|
||||
@@ -1619,6 +1621,7 @@ 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',
|
||||
@@ -1658,7 +1661,7 @@ const nl: Record<string, string> = {
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notities',
|
||||
'collab.tabs.polls': 'Peilingen',
|
||||
'collab.tabs.polls': 'Polls',
|
||||
'collab.whatsNext.title': 'Wat komt er',
|
||||
'collab.whatsNext.today': 'Vandaag',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
@@ -1704,7 +1707,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': 'Peilingen',
|
||||
'collab.polls.title': 'Polls',
|
||||
'collab.polls.new': 'Nieuwe poll',
|
||||
'collab.polls.empty': 'Nog geen polls',
|
||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||
|
||||
@@ -26,6 +26,8 @@ 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',
|
||||
@@ -1571,6 +1573,7 @@ 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',
|
||||
|
||||
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
|
||||
'common.none': 'Нет',
|
||||
'common.date': 'Дата',
|
||||
'common.rename': 'Переименовать',
|
||||
'common.discardChanges': 'Отменить изменения',
|
||||
'common.discard': 'Отменить',
|
||||
'common.name': 'Имя',
|
||||
'common.email': 'Эл. почта',
|
||||
'common.password': 'Пароль',
|
||||
@@ -1619,6 +1621,7 @@ 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': 'Не подключено',
|
||||
|
||||
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
|
||||
'common.none': '无',
|
||||
'common.date': '日期',
|
||||
'common.rename': '重命名',
|
||||
'common.discardChanges': '放弃更改',
|
||||
'common.discard': '放弃',
|
||||
'common.name': '名称',
|
||||
'common.email': '邮箱',
|
||||
'common.password': '密码',
|
||||
@@ -1619,6 +1621,7 @@ 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': '未连接',
|
||||
|
||||
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
|
||||
'common.none': '無',
|
||||
'common.date': '日期',
|
||||
'common.rename': '重新命名',
|
||||
'common.discardChanges': '捨棄變更',
|
||||
'common.discard': '捨棄',
|
||||
'common.name': '名稱',
|
||||
'common.email': '郵箱',
|
||||
'common.password': '密碼',
|
||||
@@ -1679,6 +1681,7 @@ 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': '未連線',
|
||||
|
||||
@@ -1240,6 +1240,15 @@ 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'
|
||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
// Bucket list content
|
||||
const bucketContent = (
|
||||
<>
|
||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
|
||||
{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 }}>
|
||||
{(() => {
|
||||
@@ -1400,7 +1409,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 className="flex items-stretch justify-center">
|
||||
<div ref={statsContentRef} className="flex items-stretch justify-center">
|
||||
|
||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||
{/* Countries hero */}
|
||||
|
||||
@@ -401,6 +401,10 @@ 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();
|
||||
});
|
||||
@@ -766,6 +770,10 @@ 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);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -699,6 +700,7 @@ 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 => {
|
||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const handleCopy = async (trip: DashboardTrip) => {
|
||||
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||
|
||||
const confirmCopy = async () => {
|
||||
if (!copyTrip) return
|
||||
try {
|
||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
||||
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.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]
|
||||
@@ -1205,6 +1211,13 @@ 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 }
|
||||
|
||||
@@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
||||
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
|
||||
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let deleteCalled = false;
|
||||
|
||||
@@ -1493,10 +1493,10 @@ describe('JourneyDetailPage', () => {
|
||||
await openSettingsDialog(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Remove share link'));
|
||||
await user.click(screen.getByText('Delete link'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteCalled).toBe(true);
|
||||
@@ -2905,7 +2905,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||
// These reuse the same i18n keys as the main tab bar
|
||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||
@@ -67,11 +69,13 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
return groups
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
function formatDate(d: string, locale?: 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(undefined, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
||||
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
|
||||
const winner = lastPast || firstAhead
|
||||
if (winner) {
|
||||
setActiveEntryId(winner.id)
|
||||
mapRef.current?.highlightMarker(winner.id)
|
||||
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||
mapRef.current?.highlightMarker(winner.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
const onScroll = () => {
|
||||
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
|
||||
[current?.entries]
|
||||
)
|
||||
|
||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
location_name: e.location_name || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
})), [mapEntries])
|
||||
const sidebarMapItems = useMemo(() => {
|
||||
const 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 tripDates = useMemo(() => {
|
||||
const dates = new Set<string>()
|
||||
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
|
||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||
: 'flex w-full overflow-hidden'
|
||||
}
|
||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
||||
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
|
||||
>
|
||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||
<div
|
||||
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
|
||||
>
|
||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
|
||||
|
||||
{sortedDates.map((date, dayIdx) => {
|
||||
const entries = dayGroups.get(date)!
|
||||
const fd = formatDate(date)
|
||||
const fd = formatDate(date, locale)
|
||||
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 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
||||
<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] }}>
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
@@ -611,7 +639,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' : ''}`}>
|
||||
<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}>
|
||||
{canReorder && (
|
||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||
<button
|
||||
@@ -733,7 +761,8 @@ export default function JourneyDetailPage() {
|
||||
journey={current}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
||||
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
||||
onOpenInvite={() => { setShowInvite(true) }}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -816,7 +845,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||
onLocationClick: (id: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
// group map entries by date
|
||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||
mapEntries.forEach((e, i) => {
|
||||
@@ -872,7 +901,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)
|
||||
const fd = formatDate(date, locale)
|
||||
|
||||
return (
|
||||
<div key={date}>
|
||||
@@ -915,7 +944,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">
|
||||
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -981,9 +1010,16 @@ 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) {
|
||||
allPhotos.push({ photo: p, entry: e })
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,23 +1064,27 @@ 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) {
|
||||
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 })
|
||||
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),
|
||||
}
|
||||
useJourneyStore.setState({ current: updated })
|
||||
|
||||
try {
|
||||
await journeyApi.deletePhoto(photoId)
|
||||
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
onRefresh() // Revert on error
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,7 +1398,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">{entry.location_name}</span>
|
||||
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
@@ -1401,7 +1441,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">{entry.location_name}</span>
|
||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
@@ -1480,7 +1520,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">
|
||||
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||
@@ -1764,11 +1804,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||
</h2>
|
||||
@@ -1778,7 +1818,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">
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
@@ -1864,7 +1904,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">
|
||||
<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="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
|
||||
@@ -1917,7 +1957,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">
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
@@ -1942,7 +1982,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
{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" />
|
||||
@@ -2015,7 +2055,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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -2214,6 +2254,9 @@ 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()
|
||||
@@ -2323,7 +2366,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">
|
||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
||||
{availableGalleryPhotos.map(gp => (
|
||||
<div
|
||||
key={gp.id}
|
||||
onClick={async () => {
|
||||
@@ -2343,7 +2386,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>
|
||||
))}
|
||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2952,7 +2995,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
onClick={deleteLink}
|
||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||
>
|
||||
Remove share link
|
||||
{t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2960,11 +3003,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenInvite: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(journey.title)
|
||||
@@ -2972,6 +3016,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
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()
|
||||
@@ -3030,12 +3078,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
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="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="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={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">
|
||||
<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">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3131,7 +3179,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
try {
|
||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||
toast.success(t('journey.contributors.removed'))
|
||||
onSaved()
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.removeFailed'))
|
||||
}
|
||||
@@ -3182,7 +3230,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
{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={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={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={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>
|
||||
@@ -3229,6 +3277,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,28 +234,20 @@ describe('JourneyPublicPage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => {
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const mapBtn = buttons.find(
|
||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
||||
);
|
||||
expect(mapBtn).toBeDefined();
|
||||
if (mapBtn) {
|
||||
fireEvent.click(mapBtn);
|
||||
// After clicking map tab, the timeline entries should no longer be visible
|
||||
// and the map view content should be rendered (even if JourneyMap errors internally
|
||||
// due to jsdom limitations, the tab state switches)
|
||||
await waitFor(() => {
|
||||
// Shibuya Crossing (timeline-only) should not appear once map is active
|
||||
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
// Desktop two-column: map sidebar is always rendered alongside the timeline;
|
||||
// there is no standalone "Map" tab button on desktop.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||
});
|
||||
// Timeline entries remain visible (two-column shows both simultaneously)
|
||||
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
||||
@@ -303,24 +295,18 @@ describe('JourneyPublicPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-PUBLICJOURNEY-012
|
||||
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
|
||||
setupSuccess();
|
||||
render(<JourneyPublicPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mapBtn = screen.getAllByRole('button').find(
|
||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
||||
);
|
||||
expect(mapBtn).toBeDefined();
|
||||
await user.click(mapBtn!);
|
||||
|
||||
// Desktop two-column: map sidebar is always rendered; no tab click required.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||
});
|
||||
// Map receives entries with lat/lng
|
||||
// Both fixture entries have coordinates → map receives 2 located entries
|
||||
expect(screen.getByTestId('journey-map').textContent).toContain('2');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
||||
import {
|
||||
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import 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
|
||||
@@ -36,15 +44,31 @@ 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): { weekday: string; month: string; day: number } {
|
||||
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
||||
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
@@ -70,6 +94,15 @@ export default function JourneyPublicPage() {
|
||||
const { t } = useTranslation()
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
const mapRef = useRef<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
|
||||
@@ -84,10 +117,6 @@ export default function JourneyPublicPage() {
|
||||
const journey = data?.journey || {}
|
||||
const stats = data?.stats || {}
|
||||
|
||||
// `[Trip Photos]` and `Gallery` are synthetic photo-only containers
|
||||
// produced by the trip→journey sync. They have no story and no
|
||||
// location, and the owner view strips them from the timeline the
|
||||
// same way (JourneyDetailPage.tsx). Gallery keeps their photos.
|
||||
const timelineEntries = useMemo(
|
||||
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
||||
[entries],
|
||||
@@ -100,12 +129,43 @@ 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">
|
||||
@@ -125,21 +185,262 @@ export default function JourneyPublicPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
!desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||
|
||||
// Shared timeline renderer used in both layout modes
|
||||
const renderTimeline = () => (
|
||||
<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)' }} />
|
||||
|
||||
@@ -194,160 +495,98 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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`}
|
||||
/>
|
||||
)}
|
||||
{/* 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">
|
||||
|
||||
{/* 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" />
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
{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)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Powered by */}
|
||||
<div className="flex flex-col items-center py-8 gap-2">
|
||||
|
||||
@@ -642,6 +642,7 @@ 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(() => {})
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ 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
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import type { AssignmentsMap } from '../types'
|
||||
|
||||
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
|
||||
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
|
||||
// Clean short names (≤3 parts) pass through untouched.
|
||||
export function formatLocationName(raw: string | null | undefined): string {
|
||||
if (!raw) return ''
|
||||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean)
|
||||
if (parts.length <= 3) return raw.trim()
|
||||
|
||||
// Dedup preserving insertion order
|
||||
const seen = new Set<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 {
|
||||
|
||||
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
|
||||
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
// ResizeObserver — used by resizable panels
|
||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})) as unknown as typeof ResizeObserver;
|
||||
class _MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
}
|
||||
globalThis.ResizeObserver = _MockResizeObserver 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.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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(*) 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 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(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 = photos.length;
|
||||
const photoCount = new Set(photos.map(p => p.photo_id)).size;
|
||||
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
const userPrefs = db.prepare(
|
||||
|
||||
@@ -627,7 +627,9 @@ export async function fetchSynologyThumbnailBytes(
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
// Match the uncached streamSynologyAsset default — 'sm' (240px) looked
|
||||
// pixelated on retina.
|
||||
size: 'm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
});
|
||||
|
||||
@@ -57,7 +57,8 @@ 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.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
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
@@ -93,7 +94,8 @@ 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.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
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
@@ -276,13 +278,8 @@ 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) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
} 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 (?, ?, ?, ?, ?, ?, ?)'
|
||||
|
||||
@@ -681,6 +681,24 @@ 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -950,6 +950,52 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+11
-30
@@ -36,10 +36,7 @@ feat(component): short description of new feature
|
||||
|
||||
### PR Description
|
||||
|
||||
Include:
|
||||
1. **Summary** — What does this PR do? (1-3 bullet points)
|
||||
2. **Test plan** — How was this tested?
|
||||
3. **Related issue** — Link the issue (e.g. `Fixes #123`)
|
||||
Follow the template provided by default (.github/PULL_REQUEST_TEMPLATE.md).
|
||||
|
||||
### What Will Get Your PR Closed
|
||||
|
||||
@@ -51,32 +48,16 @@ Include:
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mauriceboe/TREK.git
|
||||
cd TREK
|
||||
|
||||
# Server
|
||||
cd server
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Client (separate terminal)
|
||||
cd client
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Server runs on `http://localhost:3001`
|
||||
- Client runs on `http://localhost:5173` (with proxy to server)
|
||||
See the [[Development Environment|Development-environment]] page for the full setup guide, including forking, remote configuration, branch conventions, and available scripts.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
|
||||
| Backend | Express, TypeScript, better-sqlite3 |
|
||||
| Real-time | WebSocket (ws) |
|
||||
| Database | SQLite with WAL mode |
|
||||
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
|
||||
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
|
||||
| i18n | 13 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, AR) |
|
||||
| Layer | Technology |
|
||||
|---|---------------------------------------------------------------------------------|
|
||||
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
|
||||
| Backend | Express, TypeScript, better-sqlite3 |
|
||||
| Real-time | WebSocket (ws) |
|
||||
| Database | SQLite with WAL mode |
|
||||
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
|
||||
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
|
||||
| i18n | 15 languages (EN, DE, ES, FR, NL, IT, PT-BR, CS, PL, HU, RU, ZH, ZH-TW, AR, ID) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Developer Setup Guide
|
||||
|
||||
> Before anything else, please read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/blob/main/CONTRIBUTING.md).
|
||||
> Before anything else, please read the [[Contributing]] guidelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -135,4 +135,4 @@ Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev`
|
||||
|
||||
- Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work.
|
||||
- Run tests before pushing: `npm run test` in both `client/` and `server/`.
|
||||
- Follow the commit message conventions described in the [Contributing Guidelines](https://github.com/mauriceboe/TREK/blob/main/CONTRIBUTING.md).
|
||||
- Follow the commit message conventions described in the [[Contributing]] guidelines.
|
||||
@@ -58,3 +58,5 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
||||
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
|
||||
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
||||
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
|
||||
| [Contributing](Contributing) | Guidelines for submitting pull requests |
|
||||
| [Development Environment](Development-environment) | Set up a local dev environment |
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Troubleshooting
|
||||
|
||||
## "Access token required" when changing password on first login
|
||||
|
||||
**Cause:** The session cookie has the `Secure` flag set, which means the browser will only send it over HTTPS. When accessing TREK over plain HTTP (e.g. `http://192.168.1.x:3000`), the browser silently drops the cookie and the server sees no session — returning "Access token required".
|
||||
|
||||
**Fix:** Choose one of the following options:
|
||||
|
||||
**Option 1 — Use HTTPS.** Access TREK via HTTPS with a valid SSL certificate.
|
||||
|
||||
**Option 2 — Disable the Secure flag.** Set `COOKIE_SECURE=false` in your Docker environment to allow the session cookie to be sent over plain HTTP:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- COOKIE_SECURE=false
|
||||
```
|
||||
|
||||
> **Note:** Option 2 is only recommended for internal/home-lab deployments that do not use HTTPS. Do not use it on a publicly accessible instance. See [Environment Variables](Environment-Variables).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket not connecting / real-time sync broken
|
||||
|
||||
**Cause:** Your reverse proxy is not forwarding WebSocket upgrade headers on the `/ws` path.
|
||||
@@ -83,3 +102,135 @@ Add this to the `location /` block (or the specific backup route). See [Reverse
|
||||
```bash
|
||||
sudo chown -R 1000:1000 ./data ./uploads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Encryption key regenerated on restart — stored secrets stop working
|
||||
|
||||
**Cause:** On every startup, TREK resolves its encryption key in this order: (1) `ENCRYPTION_KEY` env var, (2) `data/.encryption_key` file, (3) legacy `data/.jwt_secret` fallback, (4) auto-generate a fresh key. If neither the env var nor the `data/` volume is persisted — for example after recreating a container without a volume mount — a new random key is generated and all stored secrets (SMTP password, OIDC client secret, API keys, MFA TOTP seeds) become unrecoverable.
|
||||
|
||||
**Fix:** Ensure `./data:/app/data` is mounted as a persistent volume so `data/.encryption_key` survives restarts. Alternatively, pin the key explicitly:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- ENCRYPTION_KEY=<your-key>
|
||||
```
|
||||
|
||||
See [Encryption Key Rotation](Encryption-Key-Rotation) for how to retrieve or rotate the key.
|
||||
|
||||
---
|
||||
|
||||
## OIDC login returns "APP_URL is not configured"
|
||||
|
||||
**Cause:** When OIDC is enabled, TREK needs to know its own public URL to build the redirect URI. It resolves this from (1) `APP_URL` env var, (2) the first entry in `ALLOWED_ORIGINS`, (3) `http://localhost:<PORT>` as a last resort. If none of these are set and the request is not coming from localhost, TREK returns a 500 error.
|
||||
|
||||
**Fix:** Set `APP_URL` to the public URL of your instance:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- APP_URL=https://trek.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OIDC login fails with issuer mismatch
|
||||
|
||||
**Cause:** TREK validates that the `issuer` field in the provider's discovery document exactly matches the configured `OIDC_ISSUER`. A trailing-slash difference (e.g. `https://auth.example.com` vs `https://auth.example.com/`) is enough to fail.
|
||||
|
||||
**Fix:** Check the exact issuer value your provider advertises and match it:
|
||||
|
||||
```bash
|
||||
curl -s https://<your-oidc-issuer>/.well-known/openid-configuration | jq .issuer
|
||||
```
|
||||
|
||||
Set `OIDC_ISSUER` to that exact string.
|
||||
|
||||
---
|
||||
|
||||
## OIDC login fails when provider is on a private/internal network
|
||||
|
||||
**Cause:** TREK's SSRF guard blocks outbound requests to private IP ranges by default. If your OIDC provider (e.g. Keycloak, Authentik) is running on an internal address, the discovery document fetch will be blocked with: `Requests to private/internal network addresses are not allowed.`
|
||||
|
||||
**Fix:**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- ALLOW_INTERNAL_NETWORK=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password reset emails are not delivered / SMTP is silent
|
||||
|
||||
**Cause:** SMTP failures are logged but do not surface as errors to the end user — the "reset email sent" message appears regardless. Common causes: wrong `SMTP_HOST` or `SMTP_PORT`, bad credentials, firewall blocking outbound on the SMTP port, or a self-signed certificate on the SMTP server.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Check server logs for `Email send failed`:
|
||||
```bash
|
||||
docker logs <container> 2>&1 | grep "Email send failed"
|
||||
```
|
||||
2. If the error mentions TLS or certificate, set `SMTP_SKIP_TLS_VERIFY=true`.
|
||||
3. Verify the port: `587` for STARTTLS, `465` for implicit TLS, `25` for plain SMTP.
|
||||
4. Test connectivity from the container:
|
||||
```bash
|
||||
docker exec <container> nc -zv <SMTP_HOST> <SMTP_PORT>
|
||||
```
|
||||
|
||||
> **Note:** If no SMTP is configured at all, TREK prints the reset link directly to the server logs (`===== PASSWORD RESET LINK =====`). This is useful for initial setup or self-hosted installs without email.
|
||||
|
||||
---
|
||||
|
||||
## CORS error — API requests blocked in the browser
|
||||
|
||||
**Cause:** If `ALLOWED_ORIGINS` is set, only those origins are permitted. Any request from a different origin is rejected with a CORS error visible in the browser console.
|
||||
|
||||
**Fix:** Add your origin to the comma-separated list:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- ALLOWED_ORIGINS=https://trek.example.com,https://other.example.com
|
||||
```
|
||||
|
||||
If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). See [Environment Variables](Environment-Variables).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket closes immediately after connecting (codes 4001 / 4403)
|
||||
|
||||
**Cause:** The `/ws` endpoint requires an ephemeral token generated by the client immediately before connecting. If the token is missing, expired, or the user's session state changed, the server closes the connection with a specific code:
|
||||
|
||||
| Code | Reason |
|
||||
|------|--------|
|
||||
| `4001` | No token, expired/invalid token, or user not found — re-login required |
|
||||
| `4403` | MFA is required globally but the user has not enabled it |
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Code `4001`: Log out and log back in. If it persists, check that your reverse proxy is not stripping the `token` query parameter from the WebSocket upgrade request.
|
||||
- Code `4403`: The user must enable MFA in **Settings > Security**, or an admin can disable the global MFA requirement in **Admin > Settings**.
|
||||
|
||||
---
|
||||
|
||||
## Clipboard features not working (copy link, share, etc.)
|
||||
|
||||
**Cause:** The browser Clipboard API (`navigator.clipboard`) is only available in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). When accessing TREK over plain HTTP on a non-localhost address, the API is unavailable and clipboard operations silently fail or show an error.
|
||||
|
||||
**Fix:** The only supported options are:
|
||||
|
||||
- Access TREK over HTTPS with a valid SSL certificate.
|
||||
- Access TREK directly from `http://localhost:<port>` — browsers treat `localhost` as a secure context for the Clipboard API (unlike the session cookie, which always requires HTTPS regardless of hostname).
|
||||
|
||||
---
|
||||
|
||||
## MCP integration: "Too many requests" or "Session limit reached"
|
||||
|
||||
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
|
||||
|
||||
**Fix:** Increase the limits via environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- MCP_RATE_LIMIT=600 # requests per minute per user (default: 300)
|
||||
- MCP_MAX_SESSION_PER_USER=50 # concurrent sessions per user (default: 20)
|
||||
```
|
||||
|
||||
@@ -92,3 +92,7 @@
|
||||
## Help
|
||||
- [[FAQ]]
|
||||
- [[Troubleshooting]]
|
||||
|
||||
## Contributing
|
||||
- [[Contributing]]
|
||||
- [[Development Environment|Development-environment]]
|
||||
|
||||
Reference in New Issue
Block a user