mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31: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/tests/
|
||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
|
**/*.test.ts
|
||||||
|
wiki/
|
||||||
|
scripts/
|
||||||
|
charts/
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I am running the latest available version of TREK
|
- label: I am running the latest available version of TREK
|
||||||
required: true
|
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
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
+2
-1
@@ -60,4 +60,5 @@ coverage
|
|||||||
.scannerwork
|
.scannerwork
|
||||||
test-data
|
test-data
|
||||||
|
|
||||||
.run
|
.run
|
||||||
|
.full-review
|
||||||
@@ -6,7 +6,11 @@
|
|||||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
</picture>
|
</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.
|
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
|
label: string
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JourneyMapHandle {
|
export interface JourneyMapHandle {
|
||||||
@@ -24,6 +26,8 @@ interface MapEntry {
|
|||||||
title?: string | null
|
title?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,6 +53,8 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
label: e.title || 'Entry',
|
label: e.title || 'Entry',
|
||||||
mood: e.mood,
|
mood: e.mood,
|
||||||
time: e.entry_date,
|
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_W = 28
|
||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const fill = dark
|
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
|
||||||
const textColor = dark
|
|
||||||
? (highlighted ? '#18181B' : '#18181B')
|
|
||||||
: (highlighted ? '#fff' : '#fff')
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? '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 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
|
||||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
|
||||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
: '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
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
|
||||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
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">
|
<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="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<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>
|
<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>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -115,12 +110,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(prev)
|
const marker = markersRef.current.get(prev)
|
||||||
const item = itemsRef.current.find(i => i.id === prev)
|
const item = itemsRef.current.find(i => i.id === prev)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, false, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(0)
|
marker.setZIndexOffset(0)
|
||||||
}
|
}
|
||||||
@@ -130,12 +124,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
if (marker && item) {
|
if (marker && item) {
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
marker.setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||||
html: markerSvg(idx, true, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(1000)
|
marker.setZIndexOffset(1000)
|
||||||
}
|
}
|
||||||
@@ -226,7 +219,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
className: '',
|
className: '',
|
||||||
iconSize: [MARKER_W, MARKER_H],
|
iconSize: [MARKER_W, MARKER_H],
|
||||||
iconAnchor: [MARKER_W / 2, 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)
|
const marker = L.marker(pos, { icon }).addTo(map)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,6 +41,8 @@ interface Item {
|
|||||||
label: string
|
label: string
|
||||||
locationName: string
|
locationName: string
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
|||||||
label: e.title || '',
|
label: e.title || '',
|
||||||
locationName: e.location_name || '',
|
locationName: e.location_name || '',
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
|||||||
document.head.appendChild(s)
|
document.head.appendChild(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
const fill = dark
|
const fill = dayColor
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
const textColor = '#fff'
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? '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 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
|
||||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
|
||||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const scale = highlighted ? 1.2 : 1
|
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(...)`.
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
// 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.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.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">
|
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}"/>
|
<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>
|
<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>`
|
</svg>`
|
||||||
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
if (!item || !marker) return
|
if (!item || !marker) return
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
const el = marker.getElement()
|
const el = marker.getElement()
|
||||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
if (!currentInner) return
|
if (!currentInner) return
|
||||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
// would wipe mapbox's positional transform and make the marker flicker.
|
// 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
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
currentInner.style.cssText = nextInner.style.cssText
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
currentInner.innerHTML = nextInner.innerHTML
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// markers
|
// markers
|
||||||
items.forEach((item, i) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(i, false, !!darkRef.current)
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
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'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||||
@@ -37,13 +38,14 @@ function stripMarkdown(text: string): string {
|
|||||||
|
|
||||||
interface Props {
|
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 }
|
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
|
isActive: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
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 hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
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">
|
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||||
{/* Day number + date + mood/weather */}
|
{/* Day number + date + mood/weather */}
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<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">
|
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||||
{index + 1}
|
{dayLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
{hasLocation ? (
|
{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">
|
<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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ThumbsUp, ThumbsDown, ChevronDown,
|
ThumbsUp, ThumbsDown, ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import JournalBody from './JournalBody'
|
import JournalBody from './JournalBody'
|
||||||
|
import { formatLocationName } from '../../utils/formatters'
|
||||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
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">
|
<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">
|
<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" />
|
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||||
{entry.location_name}
|
{formatLocationName(entry.location_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 { Plus } from 'lucide-react'
|
||||||
import JourneyMap from './JourneyMap'
|
import JourneyMap from './JourneyMap'
|
||||||
import MobileEntryCard from './MobileEntryCard'
|
import MobileEntryCard from './MobileEntryCard'
|
||||||
import type { JourneyMapHandle } from './JourneyMap'
|
import type { JourneyMapHandle } from './JourneyMap'
|
||||||
import type { JourneyEntry } from '../../store/journeyStore'
|
import type { JourneyEntry } from '../../store/journeyStore'
|
||||||
|
import { DAY_COLORS } from './dayColors'
|
||||||
|
|
||||||
interface MapEntry {
|
interface MapEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +24,7 @@ interface Props {
|
|||||||
onEntryClick: (entry: any) => void
|
onEntryClick: (entry: any) => void
|
||||||
onAddEntry?: () => void
|
onAddEntry?: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
|
carouselBottom?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMapTimeline({
|
export default function MobileMapTimeline({
|
||||||
@@ -34,14 +36,23 @@ export default function MobileMapTimeline({
|
|||||||
onEntryClick,
|
onEntryClick,
|
||||||
onAddEntry,
|
onAddEntry,
|
||||||
publicPhotoUrl,
|
publicPhotoUrl,
|
||||||
|
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const carouselRef = useRef<HTMLDivElement>(null)
|
const carouselRef = useRef<HTMLDivElement>(null)
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
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)
|
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||||
const syncMapToCarousel = useCallback((index: number) => {
|
const syncMapToCarousel = useCallback((index: number) => {
|
||||||
const entry = entries[index]
|
const entry = entries[index]
|
||||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
|||||||
})
|
})
|
||||||
}, [syncMapToCarousel])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = carouselRef.current
|
const el = carouselRef.current
|
||||||
if (!el || entries.length === 0) return
|
if (!el || entries.length === 0) return
|
||||||
let rafId: number | null = null
|
|
||||||
let settleTimer: number | null = null
|
let settleTimer: number | null = null
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId != null) return
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
pickNearestCard()
|
|
||||||
rafId = null
|
|
||||||
})
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
settleTimer = window.setTimeout(() => {
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
// Ensure the active card sits at the center once the user settles.
|
|
||||||
const card = cardRefs.current.get(activeIndexRef.current)
|
|
||||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
|
||||||
}, 180)
|
|
||||||
}
|
}
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('scroll', onScroll)
|
el.removeEventListener('scroll', onScroll)
|
||||||
if (rafId != null) cancelAnimationFrame(rafId)
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
}
|
}
|
||||||
}, [entries.length, pickNearestCard])
|
}, [entries.length, pickNearestCard])
|
||||||
@@ -142,7 +143,10 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
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
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
entries={mapEntries}
|
entries={mapEntries}
|
||||||
@@ -168,7 +172,10 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Full-screen map */}
|
||||||
<JourneyMap
|
<JourneyMap
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
|||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 right-0 z-40"
|
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
|
<div
|
||||||
ref={carouselRef}
|
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={{
|
style={{
|
||||||
scrollSnapType: 'x proximity',
|
scrollSnapType: 'x mandatory',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
@@ -207,7 +214,8 @@ export default function MobileMapTimeline({
|
|||||||
>
|
>
|
||||||
<MobileEntryCard
|
<MobileEntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
index={i}
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||||
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||||
isActive={i === activeIndex}
|
isActive={i === activeIndex}
|
||||||
onClick={() => handleCardTap(entry, i)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
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 } })
|
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 = () => {
|
const toggleDarkMode = () => {
|
||||||
document.documentElement.classList.add('trek-theme-transitioning')
|
document.documentElement.classList.add('trek-theme-transitioning')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
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')
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
themeTransitionTimer.current = null
|
||||||
}, 360)
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
* offline + N queued → amber bar "Offline — N changes queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber bar "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue bar "Syncing N changes…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
* online + 0 pending → hidden
|
* 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 React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
const label = offline
|
const label = offline
|
||||||
? pendingCount > 0
|
? pendingCount > 0
|
||||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
? `Offline · ${pendingCount} queued`
|
||||||
: 'Offline'
|
: 'Offline'
|
||||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||||
left: 0,
|
// so the pill sits 16px from the bottom.
|
||||||
right: 0,
|
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
background: bg,
|
background: bg,
|
||||||
color: text,
|
color: text,
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
gap: 8,
|
padding: '6px 14px',
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
borderRadius: 999,
|
||||||
paddingBottom: '6px',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||||
paddingLeft: '16px',
|
fontSize: 12,
|
||||||
paddingRight: '16px',
|
fontWeight: 600,
|
||||||
fontSize: 13,
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 500,
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{offline
|
||||||
? <WifiOff size={14} />
|
? <WifiOff size={12} />
|
||||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -208,9 +208,14 @@ interface ArtikelZeileProps {
|
|||||||
canEdit?: boolean
|
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) {
|
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 [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
const [showCatPicker, setShowCatPicker] = useState(false)
|
||||||
const [showBagPicker, setShowBagPicker] = 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 handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
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) }
|
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||||
catch { toast.error(t('packing.toast.saveError')) }
|
catch { toast.error(t('packing.toast.saveError')) }
|
||||||
}
|
}
|
||||||
@@ -275,9 +280,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
{editing && canEdit ? (
|
{editing && canEdit ? (
|
||||||
<input
|
<input
|
||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
|
placeholder={isPlaceholder ? '...' : undefined}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
onBlur={handleSaveName}
|
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' }}
|
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={{
|
style={{
|
||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
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)',
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
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" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
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={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export default function PlaceFormModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||||
size="lg"
|
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}>
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{/* Place Search */}
|
{/* Place Search */}
|
||||||
@@ -613,23 +632,6 @@ export default function PlaceFormModal({
|
|||||||
</div>
|
</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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -203,8 +203,10 @@ describe('ReservationModal', () => {
|
|||||||
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
|
||||||
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
|
||||||
|
|
||||||
// When isEndBeforeStart=true the submit button is disabled, so submit the form directly
|
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
|
||||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
// 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);
|
fireEvent.submit(form);
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
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' }
|
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||||
|
|
||||||
return (
|
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 }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
|
||||||
{/* Type selector */}
|
{/* Type selector */}
|
||||||
@@ -417,12 +432,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.hotel_place_id}
|
value={form.hotel_place_id}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
set('hotel_place_id', value)
|
|
||||||
const p = places.find(pl => pl.id === value)
|
const p = places.find(pl => pl.id === value)
|
||||||
if (p) {
|
setForm(prev => {
|
||||||
if (!form.title) set('title', p.name)
|
const next = { ...prev, hotel_place_id: value }
|
||||||
if (!form.location && p.address) set('location', p.address)
|
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')}
|
placeholder={t('reservations.meta.pickHotel')}
|
||||||
options={[
|
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>
|
</form>
|
||||||
</Modal>
|
</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 TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
const isHotel = r.type === 'hotel'
|
||||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||||
const dayLabel = (day: typeof startDay): string => {
|
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||||
if (!day) return ''
|
: undefined
|
||||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||||
if (day.date) {
|
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||||
const d = new Date(day.date + 'T00:00:00Z')
|
: undefined
|
||||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const DayLabel = ({ day }: { day: typeof startDay }) => {
|
||||||
return `${base} · ${dateStr}`
|
if (!day) return null
|
||||||
}
|
const name = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
return base
|
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 (
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
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={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
|
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={{
|
<span style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
|
||||||
@@ -202,12 +217,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||||
{/* Day label for transport reservations linked to a day */}
|
{/* Day label for transport/hotel reservations linked to days */}
|
||||||
{isTransportType && startDay && (
|
{(isTransportType || isHotel) && startDay && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
<DayLabel day={startDay} />
|
||||||
|
{endDay && endDay.id !== startDay.id && (
|
||||||
|
<><span style={{ color: 'var(--text-faint)' }}>–</span><DayLabel day={endDay} /></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -237,6 +237,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||||
size="2xl"
|
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 }}>
|
<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 }} />
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ describe('DisplaySettingsTab', () => {
|
|||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
|
||||||
render(<DisplaySettingsTab />);
|
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');
|
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>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: '24h', label: '24h (14:30)' },
|
{ value: '24h', short: '24h', example: '14:30' },
|
||||||
{ value: '12h', label: '12h (2:30 PM)' },
|
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -207,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.short}
|
||||||
|
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
: '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" />
|
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
<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 className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<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 apiClient from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
|
|
||||||
interface ProviderField {
|
interface ProviderField {
|
||||||
key: string
|
key: string
|
||||||
@@ -222,15 +223,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
<div key={`${provider.id}-${field.key}`}>
|
<div key={`${provider.id}-${field.key}`}>
|
||||||
{field.input_type === 'checkbox' ? (
|
{field.input_type === 'checkbox' ? (
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
on={values[field.key] === 'true'}
|
||||||
checked={values[field.key] === 'true'}
|
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
|
||||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
|
|
||||||
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
<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
|
<button
|
||||||
onClick={() => handleSaveProvider(provider)}
|
onClick={() => handleSaveProvider(provider)}
|
||||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||||
@@ -266,15 +267,17 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
{testing
|
{testing
|
||||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
? <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" />}
|
: <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>
|
</button>
|
||||||
|
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
|
||||||
{connected ? (
|
{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" />
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
{t('memories.connected')}
|
{t('memories.connected')}
|
||||||
</span>
|
</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" />
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||||
{t('memories.disconnected')}
|
{t('memories.disconnected')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React from 'react'
|
|||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onToggle}
|
<button type="button" onClick={onToggle}
|
||||||
style={{
|
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)',
|
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||||
transition: 'background 0.2s',
|
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()
|
const r = ref.current?.getBoundingClientRect()
|
||||||
if (!r) return { top: 0, left: 0 }
|
if (!r) return { top: 0, left: 0 }
|
||||||
const w = 268, pad = 8
|
const w = 268, pad = 8, h = 360
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||||
let left = r.left
|
let left = r.left
|
||||||
let top = r.bottom + 4
|
let top = r.bottom + 4
|
||||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
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)
|
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||||
return { top, left }
|
return { top, left }
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -61,14 +61,15 @@ export default function Modal({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
trek-modal-enter
|
trek-modal-enter
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
flex flex-col
|
||||||
|
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||||
`}
|
`}
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — stays put even while the body scrolls */}
|
||||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<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>
|
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||||
{!hideCloseButton && (
|
{!hideCloseButton && (
|
||||||
<button
|
<button
|
||||||
@@ -80,14 +81,14 @@ export default function Modal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* 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">
|
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||||
{footer && (
|
{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}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'لا شيء',
|
'common.none': 'لا شيء',
|
||||||
'common.date': 'التاريخ',
|
'common.date': 'التاريخ',
|
||||||
'common.rename': 'إعادة تسمية',
|
'common.rename': 'إعادة تسمية',
|
||||||
|
'common.discardChanges': 'تجاهل التغييرات',
|
||||||
|
'common.discard': 'تجاهل',
|
||||||
'common.name': 'الاسم',
|
'common.name': 'الاسم',
|
||||||
'common.email': 'البريد الإلكتروني',
|
'common.email': 'البريد الإلكتروني',
|
||||||
'common.password': 'كلمة المرور',
|
'common.password': 'كلمة المرور',
|
||||||
@@ -1623,6 +1625,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||||
'memories.testConnection': 'اختبار الاتصال',
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
|
'memories.testShort': 'اختبار',
|
||||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
'memories.connected': 'متصل',
|
'memories.connected': 'متصل',
|
||||||
'memories.disconnected': 'غير متصل',
|
'memories.disconnected': 'غير متصل',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nenhum',
|
'common.none': 'Nenhum',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Renomear',
|
'common.rename': 'Renomear',
|
||||||
|
'common.discardChanges': 'Descartar alterações',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Senha',
|
'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.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.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Testar conexão',
|
'memories.testConnection': 'Testar conexão',
|
||||||
|
'memories.testShort': 'Testar',
|
||||||
'memories.testFirst': 'Teste a conexão primeiro',
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'Não conectado',
|
'memories.disconnected': 'Não conectado',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Žádné',
|
'common.none': 'Žádné',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Přejmenovat',
|
'common.rename': 'Přejmenovat',
|
||||||
|
'common.discardChanges': 'Zahodit změny',
|
||||||
|
'common.discard': 'Zahodit',
|
||||||
'common.name': 'Jméno',
|
'common.name': 'Jméno',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Heslo',
|
'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.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.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Otestovat připojení',
|
'memories.testConnection': 'Otestovat připojení',
|
||||||
|
'memories.testShort': 'Otestovat',
|
||||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
'memories.connected': 'Připojeno',
|
'memories.connected': 'Připojeno',
|
||||||
'memories.disconnected': 'Nepřipojeno',
|
'memories.disconnected': 'Nepřipojeno',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Keine',
|
'common.none': 'Keine',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Umbenennen',
|
'common.rename': 'Umbenennen',
|
||||||
|
'common.discardChanges': 'Änderungen verwerfen',
|
||||||
|
'common.discard': 'Verwerfen',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'E-Mail',
|
'common.email': 'E-Mail',
|
||||||
'common.password': 'Passwort',
|
'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.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.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbindung testen',
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Verbindung zuerst testen',
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
'memories.connected': 'Verbunden',
|
'memories.connected': 'Verbunden',
|
||||||
'memories.disconnected': 'Nicht verbunden',
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'None',
|
'common.none': 'None',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Rename',
|
'common.rename': 'Rename',
|
||||||
|
'common.discardChanges': 'Discard Changes',
|
||||||
|
'common.discard': 'Discard',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'common.password': 'Password',
|
||||||
@@ -122,6 +124,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.toast.copied': 'Trip copied!',
|
'dashboard.toast.copied': 'Trip copied!',
|
||||||
'dashboard.toast.copyError': 'Failed to copy trip',
|
'dashboard.toast.copyError': 'Failed to copy trip',
|
||||||
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
|
'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.editTrip': 'Edit Trip',
|
||||||
'dashboard.createTrip': 'Create New Trip',
|
'dashboard.createTrip': 'Create New Trip',
|
||||||
'dashboard.tripTitle': 'Title',
|
'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.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.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connection',
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.testFirst': 'Test connection first',
|
'memories.testFirst': 'Test connection first',
|
||||||
'memories.connected': 'Connected',
|
'memories.connected': 'Connected',
|
||||||
'memories.disconnected': 'Not connected',
|
'memories.disconnected': 'Not connected',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const es: Record<string, string> = {
|
|||||||
'common.none': 'Ninguno',
|
'common.none': 'Ninguno',
|
||||||
'common.date': 'Fecha',
|
'common.date': 'Fecha',
|
||||||
'common.rename': 'Renombrar',
|
'common.rename': 'Renombrar',
|
||||||
|
'common.discardChanges': 'Descartar cambios',
|
||||||
|
'common.discard': 'Descartar',
|
||||||
'common.name': 'Nombre',
|
'common.name': 'Nombre',
|
||||||
'common.email': 'Correo',
|
'common.email': 'Correo',
|
||||||
'common.password': 'Contraseña',
|
'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.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.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Probar conexión',
|
'memories.testConnection': 'Probar conexión',
|
||||||
|
'memories.testShort': 'Probar',
|
||||||
'memories.testFirst': 'Probar conexión primero',
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'No conectado',
|
'memories.disconnected': 'No conectado',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const fr: Record<string, string> = {
|
|||||||
'common.none': 'Aucun',
|
'common.none': 'Aucun',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.rename': 'Renommer',
|
'common.rename': 'Renommer',
|
||||||
|
'common.discardChanges': 'Ignorer les modifications',
|
||||||
|
'common.discard': 'Ignorer',
|
||||||
'common.name': 'Nom',
|
'common.name': 'Nom',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Mot de passe',
|
'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.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.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Tester la connexion',
|
'memories.testConnection': 'Tester la connexion',
|
||||||
|
'memories.testShort': 'Tester',
|
||||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
'memories.connected': 'Connecté',
|
'memories.connected': 'Connecté',
|
||||||
'memories.disconnected': 'Non connecté',
|
'memories.disconnected': 'Non connecté',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nincs',
|
'common.none': 'Nincs',
|
||||||
'common.date': 'Dátum',
|
'common.date': 'Dátum',
|
||||||
'common.rename': 'Átnevezés',
|
'common.rename': 'Átnevezés',
|
||||||
|
'common.discardChanges': 'Változtatások elvetése',
|
||||||
|
'common.discard': 'Elveti',
|
||||||
'common.name': 'Név',
|
'common.name': 'Név',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Jelszó',
|
'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.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.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.testConnection': 'Kapcsolat tesztelése',
|
||||||
|
'memories.testShort': 'Teszt',
|
||||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
'memories.connected': 'Csatlakoztatva',
|
'memories.connected': 'Csatlakoztatva',
|
||||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Tidak ada',
|
'common.none': 'Tidak ada',
|
||||||
'common.date': 'Tanggal',
|
'common.date': 'Tanggal',
|
||||||
'common.rename': 'Ganti nama',
|
'common.rename': 'Ganti nama',
|
||||||
|
'common.discardChanges': 'Buang perubahan',
|
||||||
|
'common.discard': 'Buang',
|
||||||
'common.name': 'Nama',
|
'common.name': 'Nama',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Kata sandi',
|
'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.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Uji koneksi',
|
'memories.testConnection': 'Uji koneksi',
|
||||||
|
'memories.testShort': 'Uji',
|
||||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||||
'memories.connected': 'Terhubung',
|
'memories.connected': 'Terhubung',
|
||||||
'memories.disconnected': 'Tidak terhubung',
|
'memories.disconnected': 'Tidak terhubung',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.none': 'Nessuno',
|
'common.none': 'Nessuno',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Rinomina',
|
'common.rename': 'Rinomina',
|
||||||
|
'common.discardChanges': 'Scarta modifiche',
|
||||||
|
'common.discard': 'Scarta',
|
||||||
'common.name': 'Nome',
|
'common.name': 'Nome',
|
||||||
'common.email': 'Email',
|
'common.email': 'Email',
|
||||||
'common.password': 'Password',
|
'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.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.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test connessione',
|
'memories.testConnection': 'Test connessione',
|
||||||
|
'memories.testShort': 'Prova',
|
||||||
'memories.testFirst': 'Testa prima la connessione',
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
'memories.connected': 'Connesso',
|
'memories.connected': 'Connesso',
|
||||||
'memories.disconnected': 'Non connesso',
|
'memories.disconnected': 'Non connesso',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const nl: Record<string, string> = {
|
|||||||
'common.none': 'Geen',
|
'common.none': 'Geen',
|
||||||
'common.date': 'Datum',
|
'common.date': 'Datum',
|
||||||
'common.rename': 'Hernoemen',
|
'common.rename': 'Hernoemen',
|
||||||
|
'common.discardChanges': 'Wijzigingen verwerpen',
|
||||||
|
'common.discard': 'Verwerpen',
|
||||||
'common.name': 'Naam',
|
'common.name': 'Naam',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Wachtwoord',
|
'common.password': 'Wachtwoord',
|
||||||
@@ -612,8 +614,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
|
||||||
'admin.collab.notes.title': 'Notities',
|
'admin.collab.notes.title': 'Notities',
|
||||||
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
|
||||||
'admin.collab.polls.title': 'Peilingen',
|
'admin.collab.polls.title': 'Polls',
|
||||||
'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
|
'admin.collab.polls.subtitle': 'Groepspolls en stemmen',
|
||||||
'admin.collab.whatsnext.title': 'Wat nu',
|
'admin.collab.whatsnext.title': 'Wat nu',
|
||||||
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
|
||||||
'admin.tabs.config': 'Personalisatie',
|
'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.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.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Verbinding testen',
|
'memories.testConnection': 'Verbinding testen',
|
||||||
|
'memories.testShort': 'Testen',
|
||||||
'memories.testFirst': 'Test eerst de verbinding',
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
'memories.connected': 'Verbonden',
|
'memories.connected': 'Verbonden',
|
||||||
'memories.disconnected': 'Niet verbonden',
|
'memories.disconnected': 'Niet verbonden',
|
||||||
@@ -1658,7 +1661,7 @@ const nl: Record<string, string> = {
|
|||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notities',
|
'collab.tabs.notes': 'Notities',
|
||||||
'collab.tabs.polls': 'Peilingen',
|
'collab.tabs.polls': 'Polls',
|
||||||
'collab.whatsNext.title': 'Wat komt er',
|
'collab.whatsNext.title': 'Wat komt er',
|
||||||
'collab.whatsNext.today': 'Vandaag',
|
'collab.whatsNext.today': 'Vandaag',
|
||||||
'collab.whatsNext.tomorrow': 'Morgen',
|
'collab.whatsNext.tomorrow': 'Morgen',
|
||||||
@@ -1704,7 +1707,7 @@ const nl: Record<string, string> = {
|
|||||||
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
'collab.notes.attachFiles': 'Bestanden bijvoegen',
|
||||||
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
'collab.notes.noCategoriesYet': 'Nog geen categorieën',
|
||||||
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
|
||||||
'collab.polls.title': 'Peilingen',
|
'collab.polls.title': 'Polls',
|
||||||
'collab.polls.new': 'Nieuwe poll',
|
'collab.polls.new': 'Nieuwe poll',
|
||||||
'collab.polls.empty': 'Nog geen polls',
|
'collab.polls.empty': 'Nog geen polls',
|
||||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
'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.none': 'Brak',
|
||||||
'common.date': 'Data',
|
'common.date': 'Data',
|
||||||
'common.rename': 'Zmień nazwę',
|
'common.rename': 'Zmień nazwę',
|
||||||
|
'common.discardChanges': 'Odrzuć zmiany',
|
||||||
|
'common.discard': 'Odrzuć',
|
||||||
'common.name': 'Nazwa',
|
'common.name': 'Nazwa',
|
||||||
'common.email': 'E-mail',
|
'common.email': 'E-mail',
|
||||||
'common.password': 'Hasło',
|
'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.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.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Test',
|
'memories.testConnection': 'Test',
|
||||||
|
'memories.testShort': 'Test',
|
||||||
'memories.connected': 'Połączono',
|
'memories.connected': 'Połączono',
|
||||||
'memories.disconnected': 'Nie połączono',
|
'memories.disconnected': 'Nie połączono',
|
||||||
'memories.connectionSuccess': 'Połączono z Immich',
|
'memories.connectionSuccess': 'Połączono z Immich',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const ru: Record<string, string> = {
|
|||||||
'common.none': 'Нет',
|
'common.none': 'Нет',
|
||||||
'common.date': 'Дата',
|
'common.date': 'Дата',
|
||||||
'common.rename': 'Переименовать',
|
'common.rename': 'Переименовать',
|
||||||
|
'common.discardChanges': 'Отменить изменения',
|
||||||
|
'common.discard': 'Отменить',
|
||||||
'common.name': 'Имя',
|
'common.name': 'Имя',
|
||||||
'common.email': 'Эл. почта',
|
'common.email': 'Эл. почта',
|
||||||
'common.password': 'Пароль',
|
'common.password': 'Пароль',
|
||||||
@@ -1619,6 +1621,7 @@ const ru: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||||
'memories.testConnection': 'Проверить подключение',
|
'memories.testConnection': 'Проверить подключение',
|
||||||
|
'memories.testShort': 'Проверить',
|
||||||
'memories.testFirst': 'Сначала проверьте подключение',
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
'memories.connected': 'Подключено',
|
'memories.connected': 'Подключено',
|
||||||
'memories.disconnected': 'Не подключено',
|
'memories.disconnected': 'Не подключено',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zh: Record<string, string> = {
|
|||||||
'common.none': '无',
|
'common.none': '无',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重命名',
|
'common.rename': '重命名',
|
||||||
|
'common.discardChanges': '放弃更改',
|
||||||
|
'common.discard': '放弃',
|
||||||
'common.name': '名称',
|
'common.name': '名称',
|
||||||
'common.email': '邮箱',
|
'common.email': '邮箱',
|
||||||
'common.password': '密码',
|
'common.password': '密码',
|
||||||
@@ -1619,6 +1621,7 @@ const zh: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '测试连接',
|
'memories.testConnection': '测试连接',
|
||||||
|
'memories.testShort': '测试',
|
||||||
'memories.testFirst': '请先测试连接',
|
'memories.testFirst': '请先测试连接',
|
||||||
'memories.connected': '已连接',
|
'memories.connected': '已连接',
|
||||||
'memories.disconnected': '未连接',
|
'memories.disconnected': '未连接',
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const zhTw: Record<string, string> = {
|
|||||||
'common.none': '無',
|
'common.none': '無',
|
||||||
'common.date': '日期',
|
'common.date': '日期',
|
||||||
'common.rename': '重新命名',
|
'common.rename': '重新命名',
|
||||||
|
'common.discardChanges': '捨棄變更',
|
||||||
|
'common.discard': '捨棄',
|
||||||
'common.name': '名稱',
|
'common.name': '名稱',
|
||||||
'common.email': '郵箱',
|
'common.email': '郵箱',
|
||||||
'common.password': '密碼',
|
'common.password': '密碼',
|
||||||
@@ -1679,6 +1681,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||||
'memories.testConnection': '測試連線',
|
'memories.testConnection': '測試連線',
|
||||||
|
'memories.testShort': '測試',
|
||||||
'memories.testFirst': '請先測試連線',
|
'memories.testFirst': '請先測試連線',
|
||||||
'memories.connected': '已連線',
|
'memories.connected': '已連線',
|
||||||
'memories.disconnected': '未連線',
|
'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 {
|
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 { 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 bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
const tm = dark ? '#94a3b8' : '#64748b'
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
// Bucket list content
|
// Bucket list content
|
||||||
const bucketContent = (
|
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 => (
|
{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 }}>
|
<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 */}
|
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||||
<div style={{ display: 'grid' }}>
|
<div style={{ display: 'grid' }}>
|
||||||
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
<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 ═══ */}
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
{/* Countries hero */}
|
{/* Countries hero */}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
|||||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
|||||||
expect(copyButtons.length).toBeGreaterThan(0);
|
expect(copyButtons.length).toBeGreaterThan(0);
|
||||||
await user.click(copyButtons[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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
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 TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { useCountUp } from '../hooks/useCountUp'
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
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 [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
setArchivedTrips(prev => prev.map(update))
|
setArchivedTrips(prev => prev.map(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (trip: DashboardTrip) => {
|
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||||
|
|
||||||
|
const confirmCopy = async () => {
|
||||||
|
if (!copyTrip) return
|
||||||
try {
|
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]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.copied'))
|
toast.success(t('dashboard.toast.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.copyError'))
|
toast.error(t('dashboard.toast.copyError'))
|
||||||
}
|
}
|
||||||
|
setCopyTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
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 || '' })}
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTripDialog
|
||||||
|
isOpen={!!copyTrip}
|
||||||
|
tripTitle={copyTrip?.title || ''}
|
||||||
|
onClose={() => setCopyTrip(null)}
|
||||||
|
onConfirm={confirmCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -1468,7 +1468,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
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 });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
@@ -1493,10 +1493,10 @@ describe('JourneyDetailPage', () => {
|
|||||||
await openSettingsDialog(user);
|
await openSettingsDialog(user);
|
||||||
|
|
||||||
await waitFor(() => {
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(deleteCalled).toBe(true);
|
expect(deleteCalled).toBe(true);
|
||||||
@@ -2905,7 +2905,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||||
// These reuse the same i18n keys as the main tab bar
|
// 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();
|
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -8,6 +9,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
|||||||
import { addListener, removeListener } from '../api/websocket'
|
import { addListener, removeListener } from '../api/websocket'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||||
@@ -67,11 +69,13 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
|||||||
return groups
|
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')
|
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 {
|
return {
|
||||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +88,7 @@ export default function JourneyDetailPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
@@ -186,7 +190,9 @@ export default function JourneyDetailPage() {
|
|||||||
const winner = lastPast || firstAhead
|
const winner = lastPast || firstAhead
|
||||||
if (winner) {
|
if (winner) {
|
||||||
setActiveEntryId(winner.id)
|
setActiveEntryId(winner.id)
|
||||||
mapRef.current?.highlightMarker(winner.id)
|
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||||
|
mapRef.current?.highlightMarker(winner.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -277,16 +283,38 @@ export default function JourneyDetailPage() {
|
|||||||
[current?.entries]
|
[current?.entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
const sidebarMapItems = useMemo(() => {
|
||||||
id: String(e.id),
|
const allDates = [...new Set(
|
||||||
lat: e.location_lat!,
|
(current?.entries || [])
|
||||||
lng: e.location_lng!,
|
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||||
title: e.title || '',
|
.map(e => e.entry_date)
|
||||||
location_name: e.location_name || '',
|
.sort()
|
||||||
mood: e.mood,
|
)]
|
||||||
created_at: e.entry_date,
|
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||||
entry_date: e.entry_date,
|
const dayCounters = new Map<string, number>()
|
||||||
})), [mapEntries])
|
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 tripDates = useMemo(() => {
|
||||||
const dates = new Set<string>()
|
const dates = new Set<string>()
|
||||||
@@ -422,7 +450,7 @@ export default function JourneyDetailPage() {
|
|||||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||||
: 'flex w-full overflow-hidden'
|
: '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) */}
|
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||||
<div
|
<div
|
||||||
@@ -482,7 +510,7 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</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')}
|
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,14 +603,14 @@ export default function JourneyDetailPage() {
|
|||||||
|
|
||||||
{sortedDates.map((date, dayIdx) => {
|
{sortedDates.map((date, dayIdx) => {
|
||||||
const entries = dayGroups.get(date)!
|
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))]
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
<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="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="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}
|
{dayIdx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -611,7 +639,7 @@ export default function JourneyDetailPage() {
|
|||||||
.catch(() => toast.error(t('common.errorOccurred')))
|
.catch(() => toast.error(t('common.errorOccurred')))
|
||||||
}
|
}
|
||||||
return (
|
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 && (
|
{canReorder && (
|
||||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||||
<button
|
<button
|
||||||
@@ -733,7 +761,8 @@ export default function JourneyDetailPage() {
|
|||||||
journey={current}
|
journey={current}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
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>
|
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||||
onLocationClick: (id: string) => void
|
onLocationClick: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
// group map entries by date
|
// group map entries by date
|
||||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||||
mapEntries.forEach((e, i) => {
|
mapEntries.forEach((e, i) => {
|
||||||
@@ -872,7 +901,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
{dates.map((date, dayIdx) => {
|
{dates.map((date, dayIdx) => {
|
||||||
const items = byDate.get(date)!
|
const items = byDate.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date}>
|
<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>
|
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 truncate">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -981,9 +1010,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
||||||
|
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
for (const p of e.photos) {
|
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) => {
|
const handleDeletePhoto = async (photoId: number) => {
|
||||||
// Optimistic update — remove photo from local state immediately
|
|
||||||
const store = useJourneyStore.getState()
|
const store = useJourneyStore.getState()
|
||||||
if (store.current) {
|
if (!store.current) return
|
||||||
const updated = {
|
const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
|
||||||
...store.current,
|
if (!target) return
|
||||||
entries: store.current.entries.map(e => ({
|
const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
|
||||||
...e,
|
|
||||||
photos: e.photos.filter(p => p.id !== photoId),
|
// Optimistic update — remove every row with this photo_id
|
||||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
const updated = {
|
||||||
}
|
...store.current,
|
||||||
useJourneyStore.setState({ current: updated })
|
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 {
|
try {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
onRefresh() // Revert on error
|
onRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,7 +1398,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
{entry.location_name && (
|
{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">
|
<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" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name}</span>
|
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{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">
|
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||||
{entry.location_name && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1480,7 +1520,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
|
|||||||
{entry.title || t('journey.detail.newEntry')}
|
{entry.title || t('journey.detail.newEntry')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
<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>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
<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')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
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="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-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="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 */}
|
{/* 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">
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1778,7 +1818,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* 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 */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1.5 mb-3">
|
<div className="flex gap-1.5 mb-3">
|
||||||
{[
|
{[
|
||||||
@@ -1864,7 +1904,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add-to entry selector */}
|
{/* 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">
|
<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>
|
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||||
<button
|
<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))
|
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||||
if (selectable.length === 0) return null
|
if (selectable.length === 0) return null
|
||||||
return (
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1942,7 +1982,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Photo grid */}
|
{/* 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 ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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 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="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>
|
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||||
@@ -2214,6 +2254,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
pendingLinkIds.length > 0
|
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 = () => {
|
const handleClose = () => {
|
||||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||||
onClose()
|
onClose()
|
||||||
@@ -2323,7 +2366,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
{showGalleryPick && (
|
{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="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">
|
<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
|
<div
|
||||||
key={gp.id}
|
key={gp.id}
|
||||||
onClick={async () => {
|
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 }} />
|
<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>
|
</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 className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2952,7 +2995,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
onClick={deleteLink}
|
onClick={deleteLink}
|
||||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||||
>
|
>
|
||||||
Remove share link
|
{t('share.deleteLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
journey: JourneyDetail
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onOpenInvite: () => void
|
onOpenInvite: () => void
|
||||||
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [title, setTitle] = useState(journey.title)
|
const [title, setTitle] = useState(journey.title)
|
||||||
@@ -2972,6 +3016,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
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 coverRef = useRef<HTMLInputElement>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -3030,12 +3078,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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">
|
<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>
|
<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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3131,7 +3179,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
try {
|
try {
|
||||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||||
toast.success(t('journey.contributors.removed'))
|
toast.success(t('journey.contributors.removed'))
|
||||||
onSaved()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('journey.contributors.removeFailed'))
|
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} />}
|
{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>
|
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||||
</button>
|
</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">
|
<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')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -3229,6 +3277,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
confirmLabel={t('common.delete')}
|
confirmLabel={t('common.delete')}
|
||||||
danger
|
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>
|
</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();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
// Desktop two-column: map sidebar is always rendered alongside the timeline;
|
||||||
const mapBtn = buttons.find(
|
// there is no standalone "Map" tab button on desktop.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
await waitFor(() => {
|
||||||
);
|
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
||||||
expect(mapBtn).toBeDefined();
|
});
|
||||||
if (mapBtn) {
|
// Timeline entries remain visible (two-column shows both simultaneously)
|
||||||
fireEvent.click(mapBtn);
|
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
|
||||||
// After clicking map tab, the timeline entries should no longer be visible
|
|
||||||
// and the map view content should be rendered (even if JourneyMap errors internally
|
|
||||||
// due to jsdom limitations, the tab state switches)
|
|
||||||
await waitFor(() => {
|
|
||||||
// Shibuya Crossing (timeline-only) should not appear once map is active
|
|
||||||
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
|
||||||
@@ -303,24 +295,18 @@ describe('JourneyPublicPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// FE-PAGE-PUBLICJOURNEY-012
|
// FE-PAGE-PUBLICJOURNEY-012
|
||||||
it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
setupSuccess();
|
setupSuccess();
|
||||||
render(<JourneyPublicPage />);
|
render(<JourneyPublicPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapBtn = screen.getAllByRole('button').find(
|
// Desktop two-column: map sidebar is always rendered; no tab click required.
|
||||||
btn => btn.textContent && /map/i.test(btn.textContent),
|
|
||||||
);
|
|
||||||
expect(mapBtn).toBeDefined();
|
|
||||||
await user.click(mapBtn!);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('journey-map')).toBeInTheDocument();
|
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');
|
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 { useParams } from 'react-router-dom'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
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 JourneyMap from '../components/Journey/JourneyMap'
|
||||||
|
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
|
|
||||||
interface PublicEntry {
|
interface PublicEntry {
|
||||||
id: number
|
id: number
|
||||||
@@ -36,15 +44,31 @@ interface PublicPhoto {
|
|||||||
caption?: string | null
|
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 {
|
function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
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')
|
const date = new Date(d + 'T00:00:00')
|
||||||
return {
|
return {
|
||||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +94,15 @@ export default function JourneyPublicPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
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(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -84,10 +117,6 @@ export default function JourneyPublicPage() {
|
|||||||
const journey = data?.journey || {}
|
const journey = data?.journey || {}
|
||||||
const stats = data?.stats || {}
|
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(
|
const timelineEntries = useMemo(
|
||||||
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
() => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
|
||||||
[entries],
|
[entries],
|
||||||
@@ -100,12 +129,43 @@ export default function JourneyPublicPage() {
|
|||||||
)
|
)
|
||||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
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
|
// Set default view based on permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
|
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
|
||||||
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
||||||
}, [perms])
|
}, [perms])
|
||||||
|
|
||||||
|
// When switching to desktop two-column, 'map' standalone tab no longer exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (desktopTwoColumn && view === 'map') setView('timeline')
|
||||||
|
}, [desktopTwoColumn, view])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
<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 = [
|
const availableViews = [
|
||||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
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_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 }[]
|
].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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
<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 && (
|
{journey.cover_image && (
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
<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', 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)' }} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
{desktopTwoColumn ? (
|
||||||
|
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||||
{/* View tabs */}
|
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||||
{availableViews.length > 1 && (
|
{/* Left: feed */}
|
||||||
<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">
|
<div className="flex-1 min-w-0 px-8 py-6">
|
||||||
{availableViews.map(v => (
|
{renderTabs(availableViews)}
|
||||||
<button
|
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||||
key={v.id}
|
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||||
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>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile combined map+timeline (public, read-only) */}
|
{/* Right: sticky map — matches auth page aside proportions */}
|
||||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
<aside
|
||||||
<MobileMapTimeline
|
className="flex-shrink-0"
|
||||||
entries={timelineEntries}
|
style={{
|
||||||
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 }))}
|
width: '44%', minWidth: 420, maxWidth: 760,
|
||||||
dark={document.documentElement.classList.contains('dark')}
|
position: 'sticky', top: 0, height: '100dvh',
|
||||||
readOnly
|
padding: '16px 16px 16px 0',
|
||||||
onEntryClick={() => {}}
|
alignSelf: 'flex-start',
|
||||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<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) */}
|
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||||
{sortedDates.map(date => {
|
<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">
|
||||||
const dayEntries = groupedEntries.get(date)!
|
{availableViews.map(v => (
|
||||||
const fd = formatDate(date)
|
<button
|
||||||
return (
|
key={v.id}
|
||||||
<div key={date}>
|
onClick={() => setView(v.id)}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||||
<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>
|
view === v.id
|
||||||
<div>
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<v.icon size={13} />
|
||||||
<div className="flex flex-col gap-4 pl-[52px]">
|
{v.label}
|
||||||
{dayEntries.map(entry => (
|
</button>
|
||||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
))}
|
||||||
{entry.photos.length > 0 && (
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={photoUrl(entry.photos[0], token!)}
|
|
||||||
className="w-full h-52 object-cover cursor-pointer"
|
|
||||||
alt=""
|
|
||||||
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
|
|
||||||
/>
|
|
||||||
{entry.photos.length > 1 && (
|
|
||||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
|
||||||
<Image size={10} /> +{entry.photos.length - 1}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.title && (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
|
|
||||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
{!entry.photos.length && entry.title && (
|
|
||||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
|
|
||||||
)}
|
|
||||||
{entry.location_name && (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
|
|
||||||
<MapPin size={11} /> {entry.location_name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.story && (
|
|
||||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
|
||||||
<JournalBody text={entry.story} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
||||||
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
|
|
||||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
|
|
||||||
{entry.pros_cons.pros!.map((p, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
|
|
||||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
|
|
||||||
{entry.pros_cons.cons!.map((c, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
|
||||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gallery */}
|
|
||||||
{view === 'gallery' && perms.share_gallery && (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
|
||||||
{allPhotos.map(({ photo }, idx) => (
|
|
||||||
<div
|
|
||||||
key={photo.id}
|
|
||||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
|
|
||||||
>
|
|
||||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Map */}
|
{renderTabs(availableViews)}
|
||||||
{view === 'map' && perms.share_map && (
|
|
||||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
{/* Mobile combined map+timeline (public, read-only) */}
|
||||||
<JourneyMap
|
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||||
checkins={[]}
|
<MobileMapTimeline
|
||||||
entries={mapEntries.map(e => ({
|
entries={timelineEntries}
|
||||||
id: String(e.id),
|
mapEntries={sidebarMapItems as any}
|
||||||
lat: e.location_lat!,
|
dark={document.documentElement.classList.contains('dark')}
|
||||||
lng: e.location_lng!,
|
readOnly
|
||||||
title: e.title || '',
|
onEntryClick={() => {}}
|
||||||
mood: e.mood,
|
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||||
created_at: e.entry_date,
|
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
|
||||||
entry_date: e.entry_date,
|
|
||||||
})) as any}
|
|
||||||
height={500}
|
|
||||||
/>
|
/>
|
||||||
</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 */}
|
{/* Powered by */}
|
||||||
<div className="flex flex-col items-center py-8 gap-2">
|
<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 })
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
|
setEditingReservation(null)
|
||||||
if (data.type === 'hotel') {
|
if (data.type === 'hotel') {
|
||||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export interface Reservation {
|
|||||||
place_id?: number | null
|
place_id?: number | null
|
||||||
assignment_id?: number | null
|
assignment_id?: number | null
|
||||||
accommodation_id?: number | null
|
accommodation_id?: number | null
|
||||||
|
accommodation_start_day_id?: number | null
|
||||||
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
import type { AssignmentsMap } from '../types'
|
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'])
|
const ZERO_DECIMAL_CURRENCIES = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF'])
|
||||||
|
|
||||||
export function currencyDecimals(currency: string): number {
|
export function currencyDecimals(currency: string): number {
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ class _MockIntersectionObserver {
|
|||||||
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||||
|
|
||||||
// ResizeObserver — used by resizable panels
|
// ResizeObserver — used by resizable panels
|
||||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
class _MockResizeObserver {
|
||||||
observe: vi.fn(),
|
observe = vi.fn()
|
||||||
unobserve: vi.fn(),
|
unobserve = vi.fn()
|
||||||
disconnect: vi.fn(),
|
disconnect = vi.fn()
|
||||||
})) as unknown as typeof ResizeObserver;
|
constructor(_callback: ResizeObserverCallback) {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
||||||
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
// 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(`
|
return db.prepare(`
|
||||||
SELECT DISTINCT j.*,
|
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_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 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 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
|
(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
|
// stats
|
||||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
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 places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||||
|
|
||||||
const userPrefs = db.prepare(
|
const userPrefs = db.prepare(
|
||||||
|
|||||||
@@ -627,7 +627,9 @@ export async function fetchSynologyThumbnailBytes(
|
|||||||
mode: 'download',
|
mode: 'download',
|
||||||
id: parsedId.id,
|
id: parsedId.id,
|
||||||
type: 'unit',
|
type: 'unit',
|
||||||
size: 'sm',
|
// Match the uncached streamSynologyAsset default — 'sm' (240px) looked
|
||||||
|
// pixelated on retina.
|
||||||
|
size: 'm',
|
||||||
cache_key: parsedId.cacheKey,
|
cache_key: parsedId.cacheKey,
|
||||||
_sid: sid.data,
|
_sid: sid.data,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ const saveEndpoints = db.transaction((reservationId: number, endpoints: Endpoint
|
|||||||
export function listReservations(tripId: string | number) {
|
export function listReservations(tripId: string | number) {
|
||||||
const reservations = db.prepare(`
|
const reservations = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
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
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.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) {
|
export function getReservationWithJoins(id: string | number) {
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
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
|
FROM reservations r
|
||||||
LEFT JOIN days d ON r.day_id = d.id
|
LEFT JOIN days d ON r.day_id = d.id
|
||||||
LEFT JOIN places p ON r.place_id = p.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;
|
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 (start_day_id && end_day_id) {
|
||||||
if (resolvedAccId) {
|
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 = ?')
|
||||||
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);
|
||||||
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
|
|
||||||
} else {
|
|
||||||
db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
|
|
||||||
.run(start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
|
|
||||||
}
|
|
||||||
} else if (accPlaceId) {
|
} else if (accPlaceId) {
|
||||||
const accResult = db.prepare(
|
const accResult = db.prepare(
|
||||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'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);
|
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);
|
return Number(newTripId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -950,6 +950,52 @@ describe('Copy trip with data', () => {
|
|||||||
expect(newNotes).toHaveLength(1);
|
expect(newNotes).toHaveLength(1);
|
||||||
expect(newNotes[0].text).toBe('Pack early!');
|
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
|
### PR Description
|
||||||
|
|
||||||
Include:
|
Follow the template provided by default (.github/PULL_REQUEST_TEMPLATE.md).
|
||||||
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`)
|
|
||||||
|
|
||||||
### What Will Get Your PR Closed
|
### What Will Get Your PR Closed
|
||||||
|
|
||||||
@@ -51,32 +48,16 @@ Include:
|
|||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
```bash
|
See the [[Development Environment|Development-environment]] page for the full setup guide, including forking, remote configuration, branch conventions, and available scripts.
|
||||||
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)
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|---|---|
|
|---|---------------------------------------------------------------------------------|
|
||||||
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
|
| Frontend | React 18, TypeScript, Zustand, Leaflet, Tailwind CSS, Vite |
|
||||||
| Backend | Express, TypeScript, better-sqlite3 |
|
| Backend | Express, TypeScript, better-sqlite3 |
|
||||||
| Real-time | WebSocket (ws) |
|
| Real-time | WebSocket (ws) |
|
||||||
| Database | SQLite with WAL mode |
|
| Database | SQLite with WAL mode |
|
||||||
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
|
| Auth | JWT (HS256), bcrypt, TOTP MFA, OIDC |
|
||||||
| Maps | Leaflet + react-leaflet, OSRM, Nominatim, CartoDB tiles |
|
| 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) |
|
| 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
|
# 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
|
## 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.
|
- 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/`.
|
- 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 |
|
| [My Trips Dashboard](My-Trips-Dashboard) | Start planning your first trip |
|
||||||
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
| [Admin Panel](Admin-Panel-Overview) | Configure your instance |
|
||||||
| [MCP / AI Integration](MCP-Overview) | Connect Claude, Cursor, or any MCP client |
|
| [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
|
# 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
|
## WebSocket not connecting / real-time sync broken
|
||||||
|
|
||||||
**Cause:** Your reverse proxy is not forwarding WebSocket upgrade headers on the `/ws` path.
|
**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
|
```bash
|
||||||
sudo chown -R 1000:1000 ./data ./uploads
|
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
|
## Help
|
||||||
- [[FAQ]]
|
- [[FAQ]]
|
||||||
- [[Troubleshooting]]
|
- [[Troubleshooting]]
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
- [[Contributing]]
|
||||||
|
- [[Development Environment|Development-environment]]
|
||||||
|
|||||||
Reference in New Issue
Block a user