mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1963573db4 | |||
| 5046e1a2e0 | |||
| a1f3b4476e | |||
| b2a39a3071 | |||
| 21511c2f68 | |||
| 0e5c819f7c | |||
| 0f44d7d264 | |||
| e078a9d9e1 | |||
| fef12b0e8b | |||
| df075630fb | |||
| bffb55d8c0 | |||
| 5c24213b0e | |||
| 12a457801a | |||
| ae4d317dc3 | |||
| f7c6854059 | |||
| bdb6b01765 | |||
| 129dfabaa3 | |||
| 8a6d1b2aaf | |||
| 465b78411a | |||
| 272b32b410 | |||
| 7945e752d6 | |||
| 6eb3ab38fb |
@@ -331,8 +331,8 @@ export const journeyApi = {
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Props {
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
@@ -57,15 +59,20 @@ const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
||||
: (highlighted ? '#18181B' : '#18181B')
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = dark ? '#3F3F46' : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
|
||||
? (dark
|
||||
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
@@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
@@ -138,7 +145,9 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
try {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -156,7 +165,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
scrollWheelZoom: fullScreen ? true : false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
@@ -185,8 +194,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — subtle dashed connection
|
||||
if (items.length > 1) {
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
if (!fullScreen && items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
@@ -229,7 +238,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
@@ -245,7 +255,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl])
|
||||
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto): string {
|
||||
return `/api/photos/${p.photo_id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
index: number
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const thumbSrc = firstPhoto
|
||||
? publicPhotoUrl
|
||||
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
|
||||
: photoUrl(firstPhoto as JourneyPhoto)
|
||||
: null
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{entry.location_name || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X, Pencil, Trash2, MapPin, Clock, Camera,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown, ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import JournalBody from './JournalBody'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
|
||||
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: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
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 date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
|
||||
{/* Hero photo(s) */}
|
||||
{photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(photos[0])}
|
||||
alt=""
|
||||
className="w-full max-h-[50vh] object-cover cursor-pointer"
|
||||
onClick={() => onPhotoClick(photos, 0)}
|
||||
/>
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<Camera size={12} />
|
||||
{photos.length} photos
|
||||
</div>
|
||||
)}
|
||||
{/* Photo strip for multiple photos */}
|
||||
{photos.length > 1 && (
|
||||
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
|
||||
{photos.map((p, i) => (
|
||||
<img
|
||||
key={p.id || i}
|
||||
src={photoUrl(p, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 pb-32">
|
||||
|
||||
{/* Date + time + location header */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.location_name && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{entry.location_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{entry.title && (
|
||||
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
|
||||
{entry.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Mood + Weather chips */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
|
||||
<mood.icon size={13} />
|
||||
{mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<weather.icon size={13} />
|
||||
{weather.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{entry.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
|
||||
{prosArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsUp size={12} /> Pros
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{prosArr.map((p, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-emerald-500 mt-0.5">+</span> {p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{prosArr.length > 0 && consArr.length > 0 && (
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-700" />
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
|
||||
<ThumbsDown size={12} /> Cons
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{consArr.map((c, i) => (
|
||||
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
|
||||
<span className="text-red-500 mt-0.5">−</span> {c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import JourneyMap from './JourneyMap'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
import type { JourneyMapHandle } from './JourneyMap'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: JourneyEntry[] | any[]
|
||||
mapEntries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
dark?: boolean
|
||||
readOnly?: boolean
|
||||
onEntryClick: (entry: any) => void
|
||||
onAddEntry?: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileMapTimeline({
|
||||
entries,
|
||||
mapEntries,
|
||||
trail,
|
||||
dark,
|
||||
readOnly,
|
||||
onEntryClick,
|
||||
onAddEntry,
|
||||
publicPhotoUrl,
|
||||
}: Props) {
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) return
|
||||
|
||||
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
|
||||
if (mapEntry) {
|
||||
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
|
||||
} else {
|
||||
try { mapRef.current?.highlightMarker(null) } catch {}
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
|
||||
// IntersectionObserver for instant snap detection
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(observed) => {
|
||||
for (const o of observed) {
|
||||
if (o.isIntersecting) {
|
||||
const idx = Number(o.target.getAttribute('data-idx'))
|
||||
if (!isNaN(idx)) {
|
||||
setActiveIndex(idx)
|
||||
syncMapToCarousel(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: el, threshold: 0.6 },
|
||||
)
|
||||
|
||||
cardRefs.current.forEach(node => observer.observe(node))
|
||||
return () => observer.disconnect()
|
||||
}, [entries.length, syncMapToCarousel])
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const cardWidth = 272
|
||||
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
|
||||
}, [entries])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
const timer = setTimeout(() => syncMapToCarousel(0), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const activeEntryId = entries[activeIndex]
|
||||
? String(entries[activeIndex].id)
|
||||
: null
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
||||
{/* Full-screen map */}
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
entries={mapEntries}
|
||||
checkins={[]}
|
||||
trail={trail}
|
||||
height={9999}
|
||||
dark={dark}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
paddingBottom={200}
|
||||
/>
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed bottom-20 left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x' }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry: any, i: number) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-idx={i}
|
||||
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
|
||||
style={{ scrollSnapAlign: 'center' }}
|
||||
>
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
index={i}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB: add entry — top right */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Album linking
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
|
||||
if (!selectedProvider) {
|
||||
toast.error(t('memories.error.linkAlbum'))
|
||||
return
|
||||
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
provider: selectedProvider,
|
||||
...(passphrase ? { passphrase } : {}),
|
||||
})
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{albums.map(album => {
|
||||
const isLinked = linkedIds.has(album.id)
|
||||
return (
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
|
||||
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
|
||||
disabled={isLinked}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
|
||||
|
||||
@@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Main area */}
|
||||
|
||||
@@ -1023,7 +1023,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
|
||||
@@ -112,8 +112,9 @@ describe('ModalRenderer', () => {
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
// CTA is only shown on the last page; navigate there first
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
|
||||
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
|
||||
@@ -121,6 +122,12 @@ describe('ModalRenderer', () => {
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
// Navigate to last page
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
|
||||
await act(async () => {
|
||||
fireEvent.click(ctaBtn);
|
||||
@@ -299,17 +306,22 @@ describe('ModalRenderer', () => {
|
||||
expect(screen.getByText('Notice A')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 3')).toBeTruthy();
|
||||
|
||||
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
|
||||
// Navigate to last page where X button is available
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
|
||||
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 3'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
|
||||
expect(screen.getByText('Notice B')).toBeTruthy();
|
||||
expect(screen.getByText('1 / 2')).toBeTruthy();
|
||||
// Dismiss all from last page — store shrinks
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
useSystemNoticeStore.setState({ notices: [], loaded: true });
|
||||
rerender(<ModalRenderer notices={[]} />);
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
// All dismissed — modal should be gone
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
|
||||
@@ -321,6 +333,12 @@ describe('ModalRenderer', () => {
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// X button only appears on the last page — navigate there
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
});
|
||||
@@ -330,7 +348,7 @@ describe('ModalRenderer', () => {
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
|
||||
it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
|
||||
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
|
||||
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
|
||||
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
|
||||
@@ -339,6 +357,12 @@ describe('ModalRenderer', () => {
|
||||
render(<ModalRenderer notices={[noticeA, noticeB]} />);
|
||||
await flushGraceDelay();
|
||||
|
||||
// ESC only works on last page — navigate there first
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Go to notice 2'));
|
||||
});
|
||||
await flushGraceDelay();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -62,6 +63,7 @@ interface ContentProps {
|
||||
|
||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||
|
||||
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
|
||||
const LucideIcon: React.ElementType = notice.icon
|
||||
@@ -69,125 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
: DefaultIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative">
|
||||
{/* Dismiss X button */}
|
||||
{notice.dismissible && (
|
||||
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||
{/* Dismiss X button — only on last page so users read all notices */}
|
||||
{notice.dismissible && isLastPage && (
|
||||
<button
|
||||
onClick={onDismissAll}
|
||||
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
{/* Severity icon (when no hero) */}
|
||||
{!notice.media && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Body — markdown */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-center text-slate-600 dark:text-slate-400 max-w-[340px] mx-auto mb-4"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 dark:text-blue-400 underline hover:no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
{/* Scrollable content — vertically centered when shorter than available space */}
|
||||
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 max-w-[340px] mx-auto"
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="max-w-[340px] mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
|
||||
{/* Severity icon (when no hero and not Heart) */}
|
||||
{!notice.media && notice.icon !== 'Heart' && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (not for Heart — rendered in gradient header) */}
|
||||
{(notice.icon !== 'Heart' || notice.media) && (
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Body — markdown */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Signature line styling (e.g. "— Maurice")
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
|
||||
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
|
||||
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
|
||||
}
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
hr: () => (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">♡</span>
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
|
||||
<div
|
||||
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
|
||||
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
|
||||
>
|
||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||
{total > 1 && (
|
||||
<div className="flex flex-col items-center gap-1 mb-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPage || currentPage === 0}
|
||||
aria-label={t('system_notice.pager.prev')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
@@ -211,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
onClick={onNext}
|
||||
disabled={!canPage || currentPage === total - 1}
|
||||
aria-label={t('system_notice.pager.next')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
@@ -226,8 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3 mt-2">
|
||||
{ctaLabel ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{ctaLabel && isLastPage ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
@@ -235,16 +280,16 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : (
|
||||
) : (notice.dismissible || isLastPage) && (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onDismissAll}
|
||||
onClick={isLastPage ? onDismissAll : onNext}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
{notice.dismissible && ctaLabel && (
|
||||
{notice.dismissible && isLastPage && ctaLabel && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
||||
@@ -283,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
|
||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||
const canPage = notice?.dismissible !== false;
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
||||
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
||||
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
|
||||
// when the user is scrolled into content and pans down to scroll back up.
|
||||
const scrollTopAtTouchStart = useRef(0);
|
||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
@@ -295,7 +346,15 @@ export function ModalRenderer({ notices }: Props) {
|
||||
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||
const isPageNavRef = useRef(false);
|
||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// The sheet element itself — animated on vertical drag-to-dismiss
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const clipRef = useRef<HTMLDivElement>(null);
|
||||
// Individual slot scroll containers (prev / center / next)
|
||||
const prevSlotRef = useRef<HTMLDivElement>(null);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
|
||||
const nextSlotRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile breakpoint
|
||||
useEffect(() => {
|
||||
@@ -368,15 +427,16 @@ export function ModalRenderer({ notices }: Props) {
|
||||
};
|
||||
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ESC key — closes all modal notices (same as clicking X)
|
||||
// ESC key — closes all modal notices (only on last page so users read all notices)
|
||||
const isLastPage = notices.length <= 1 || idx === notices.length - 1;
|
||||
useEffect(() => {
|
||||
if (!visible || !notice?.dismissible) return;
|
||||
if (!visible || !notice?.dismissible || !isLastPage) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleDismissAll();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, notice?.dismissible]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Arrow-key pager navigation
|
||||
useEffect(() => {
|
||||
@@ -410,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
|
||||
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
}, [idx, isMobile]);
|
||||
|
||||
function announceIndex(newIdx: number, total: number) {
|
||||
setPageAnnouncement(
|
||||
t('system_notice.pager.position')
|
||||
@@ -453,6 +519,17 @@ export function ModalRenderer({ notices }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
|
||||
sheet.style.transition = 'transform 300ms ease-out';
|
||||
sheet.style.transform = 'translateY(110%)';
|
||||
sheet.addEventListener('transitionend', function onDone() {
|
||||
sheet.removeEventListener('transitionend', onDone);
|
||||
handleDismissAll();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
|
||||
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||
// rather than hide+show.
|
||||
@@ -531,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
||||
|
||||
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
|
||||
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const slotRawBody = t(n.bodyKey);
|
||||
const slotBody = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
slotRawBody
|
||||
)
|
||||
: slotRawBody;
|
||||
return {
|
||||
notice: n,
|
||||
title: t(n.titleKey),
|
||||
body: slotBody,
|
||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: slotIdx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
}
|
||||
|
||||
const prevNotice = notices[idx - 1] ?? null;
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
@@ -538,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={notice.dismissible ? handleDismiss : undefined}
|
||||
onClick={notice.dismissible ? animatedDismissAll : undefined}
|
||||
/>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
|
||||
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
|
||||
onTouchEnd={e => {
|
||||
if (touchStartY.current !== null && notice.dismissible) {
|
||||
const delta = e.changedTouches[0].clientY - touchStartY.current;
|
||||
if (delta > 80) handleDismiss();
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
onTouchStart={e => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
dragLockRef.current = null;
|
||||
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
|
||||
}}
|
||||
onTouchMove={e => {
|
||||
if (prefersReducedMotion) return;
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
if (startX === null || startY === null) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
// Classify gesture direction on first significant movement
|
||||
if (!dragLockRef.current) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
// Reset adjacent slots to top before they slide into view.
|
||||
if (dragLockRef.current === 'h') {
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragLockRef.current === 'h') {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
strip.style.transition = 'none';
|
||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
|
||||
// If scrolled into content, let native pan-y scroll it back up.
|
||||
if (scrollTopAtTouchStart.current > 0) return;
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || dy <= 0) return;
|
||||
sheet.style.transition = 'none';
|
||||
sheet.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
const lock = dragLockRef.current;
|
||||
dragLockRef.current = null;
|
||||
|
||||
if (lock === 'h') {
|
||||
if (startX === null) return;
|
||||
const deltaX = e.changedTouches[0].clientX - startX;
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
|
||||
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
|
||||
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
|
||||
const canGoNext = canPage && idx < notices.length - 1;
|
||||
const canGoPrev = canPage && idx > 0;
|
||||
|
||||
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
|
||||
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
|
||||
strip.style.transition = 'transform 200ms ease-out';
|
||||
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
|
||||
strip.addEventListener('transitionend', function onDone() {
|
||||
strip.removeEventListener('transitionend', onDone);
|
||||
strip.style.transition = 'none';
|
||||
// Render new content into the center slot BEFORE moving the strip,
|
||||
// so the browser never paints old content at the center position.
|
||||
const newIdx = goNext ? idx + 1 : idx - 1;
|
||||
flushSync(() => {
|
||||
isPageNavRef.current = true;
|
||||
setIdx(newIdx);
|
||||
announceIndex(newIdx, notices.length);
|
||||
});
|
||||
// Reset all slot scrolls so the new center starts at top.
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Spring back to center
|
||||
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.addEventListener('transitionend', function onSnap() {
|
||||
strip.removeEventListener('transitionend', onSnap);
|
||||
strip.style.transition = '';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical drag — animated dismiss or spring back (only when at scroll top)
|
||||
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
|
||||
const deltaY = e.changedTouches[0].clientY - startY;
|
||||
const sheet = sheetRef.current;
|
||||
if (deltaY > 80 && notice.dismissible) {
|
||||
animatedDismissAll();
|
||||
} else if (sheet && deltaY > 0) {
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
sheet.addEventListener('transitionend', function onSnap() {
|
||||
sheet.removeEventListener('transitionend', onSnap);
|
||||
sheet.style.transition = '';
|
||||
sheet.style.transform = '';
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="pt-3 pb-1 flex justify-center">
|
||||
{/* Drag handle — fixed, does not scroll */}
|
||||
<div className="pt-3 pb-1 flex justify-center shrink-0">
|
||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
</div>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
|
||||
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
|
||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||
<div
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
>
|
||||
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
||||
</div>
|
||||
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,7 +798,7 @@ export function ModalRenderer({ notices }: Props) {
|
||||
}
|
||||
|
||||
// Desktop centered modal
|
||||
const maxWidth = notice.severity === 'critical' ? 'max-w-[560px]' : 'max-w-[480px]';
|
||||
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
|
||||
const desktopMotion = prefersReducedMotion
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
|
||||
@@ -578,7 +807,7 @@ export function ModalRenderer({ notices }: Props) {
|
||||
<div
|
||||
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="presentation"
|
||||
onClick={notice.dismissible ? handleDismiss : undefined}
|
||||
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
|
||||
>
|
||||
{/* Screen-reader page announcements */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
|
||||
@@ -588,7 +817,7 @@ export function ModalRenderer({ notices }: Props) {
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`w-full ${maxWidth} rounded-2xl overflow-hidden shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
|
||||
className={`w-full ${maxWidth} rounded-2xl overflow-hidden overflow-y-auto max-h-[90vh] shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div ref={contentWrapperRef}>
|
||||
|
||||
@@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<DetailPane
|
||||
@@ -419,7 +419,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||
<NewTaskPane
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/** Returns true when the viewport is below the lg breakpoint (1024px). */
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth < 1024,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
setIsMobile(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -2019,6 +2019,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
|
||||
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -2222,6 +2222,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -2226,6 +2226,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -2226,6 +2226,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
|
||||
|
||||
// System notices — persönlicher Dank
|
||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -1958,6 +1958,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
|
||||
'journey.detail.noPhotos': 'No photos yet',
|
||||
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
|
||||
'journey.detail.journeyTab': 'Journey',
|
||||
'journey.detail.journeyStats': 'Journey Stats',
|
||||
'journey.detail.syncedTrips': 'Synced Trips',
|
||||
'journey.detail.noTripsLinked': 'No trips linked yet',
|
||||
@@ -2248,6 +2249,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'A personal note from me',
|
||||
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
|
||||
|
||||
// System notices — onboarding
|
||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||
|
||||
@@ -2228,6 +2228,10 @@ const es: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -2222,6 +2222,10 @@ const fr: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -2223,6 +2223,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -2264,6 +2264,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -2223,6 +2223,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -2222,6 +2222,10 @@ const nl: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -2215,6 +2215,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -2222,6 +2222,10 @@ const ru: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -2222,6 +2222,10 @@ const zh: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
|
||||
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -2223,6 +2223,10 @@ const zhTw: Record<string, string> = {
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
|
||||
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
|
||||
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -323,7 +323,7 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbalken */
|
||||
/* Scrollbars — styled on desktop, hidden on mobile */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -333,21 +333,23 @@ body {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
* { scrollbar-width: none; }
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
@@ -405,6 +407,7 @@ img[alt="TREK"] {
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
@@ -447,11 +450,6 @@ img[alt="TREK"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Scroll-Container */
|
||||
.scroll-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db #f1f5f9;
|
||||
}
|
||||
|
||||
/* Toast-Animationen */
|
||||
@keyframes slideUp {
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -84,7 +87,9 @@ export default function JourneyDetailPage() {
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
||||
@@ -202,10 +207,60 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
|
||||
|
||||
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||
{showMobileCombined && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
mapEntries={sidebarMapItems}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
onEntryClick={(entry) => setViewingEntry(entry)}
|
||||
onAddEntry={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fullscreen entry view (mobile) */}
|
||||
{viewingEntry && (
|
||||
<MobileEntryView
|
||||
entry={viewingEntry}
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating tab toggle on mobile combined view */}
|
||||
{showMobileCombined && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
|
||||
<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">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
||||
|
||||
{/* Back link — desktop */}
|
||||
@@ -298,11 +353,17 @@ export default function JourneyDetailPage() {
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center justify-between mt-5 mb-5">
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].map(v => (
|
||||
{(isMobile
|
||||
? [
|
||||
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
]
|
||||
: [
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
]
|
||||
).map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
@@ -317,21 +378,21 @@ export default function JourneyDetailPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'timeline' && (
|
||||
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
||||
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && (
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && view === 'timeline' && (
|
||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
@@ -398,8 +459,8 @@ export default function JourneyDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full Map View */}
|
||||
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
@@ -908,11 +969,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map(({ photo, entry }) => (
|
||||
{allPhotos.map(({ photo, entry }, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
|
||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
@@ -960,7 +1021,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (assetIds, entryId) => {
|
||||
onAdd={async (assetIds, entryId, passphrase) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
@@ -974,7 +1035,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
}
|
||||
let added = 0
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
if (added > 0) {
|
||||
@@ -1450,13 +1511,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
||||
onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
@@ -1518,13 +1580,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (albumId: string) => {
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
@@ -1643,7 +1706,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
@@ -1773,13 +1836,13 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
@@ -1821,7 +1884,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd([...selected], targetEntryId)}
|
||||
onClick={() => onAdd([...selected], targetEntryId, selectedAlbumPassphrase)}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 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 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -2027,8 +2090,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<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">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
@@ -2185,7 +2249,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pros.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
value={p}
|
||||
@@ -2219,7 +2283,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{cons.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
value={c}
|
||||
@@ -2283,7 +2347,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
/>
|
||||
{locationLat && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<MapPin size={13} className="text-emerald-500" />
|
||||
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2330,8 +2394,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const active = mood === key
|
||||
return (
|
||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
|
||||
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||
}`}
|
||||
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||
<Icon size={12} />
|
||||
{t(config.label)}
|
||||
</button>
|
||||
@@ -2361,7 +2427,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 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-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={onClose} className="px-3.5 py-2 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} className="px-3.5 py-2 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-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function JourneyPage() {
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -7,6 +7,8 @@ import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
|
||||
interface PublicEntry {
|
||||
id: number
|
||||
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
||||
const { t } = useTranslation()
|
||||
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && perms.share_timeline && (
|
||||
{/* Mobile combined map+timeline (public, read-only) */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={entries}
|
||||
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
onEntryClick={() => {}}
|
||||
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timeline (desktop, or mobile without map permission) */}
|
||||
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
|
||||
@@ -838,7 +838,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{selectedPlace && isMobile && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
|
||||
@@ -1629,6 +1629,11 @@ function runMigrations(db: Database.Database): void {
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 104: Passphrase support for Synology shared-album links (#689)
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, asset_ids, caption } = req.body || {};
|
||||
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
|
||||
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||
|
||||
// Batch mode: { provider, asset_ids: string[] }
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
|
||||
|
||||
// Single mode (backward compat)
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
@@ -100,8 +101,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if(page > 0) offset = page*limit;
|
||||
if(size > 0) limit = size;
|
||||
if (size > 0) limit = size;
|
||||
if (page > 0) offset = page * limit;
|
||||
|
||||
handleServiceResult(res, await searchSynologyPhotos(
|
||||
authReq.user.id,
|
||||
@@ -115,12 +116,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, photoId, ownerId } = req.params;
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else {
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
||||
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,6 +132,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
|
||||
const rawSize = String(req.query.size ?? 'sm');
|
||||
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
|
||||
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
|
||||
|
||||
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||
return handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||
@@ -139,7 +142,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
|
||||
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||
}
|
||||
else{
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
|
||||
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -84,7 +84,8 @@ router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, re
|
||||
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
|
||||
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
|
||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -628,12 +628,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
|
||||
import { Response } from 'express';
|
||||
import { canAccessTrip, db } from "../../db/database";
|
||||
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
// helpers for handling return types
|
||||
|
||||
@@ -42,6 +43,7 @@ export function handleServiceResult<T>(res: Response, result: ServiceResult<T>):
|
||||
export type Selection = {
|
||||
provider: string;
|
||||
asset_ids: string[];
|
||||
passphrase?: string;
|
||||
};
|
||||
|
||||
export type StatusResult = {
|
||||
@@ -59,7 +61,7 @@ export type SyncAlbumResult = {
|
||||
|
||||
|
||||
export type AlbumsList = {
|
||||
albums: Array<{ id: string; albumName: string; assetCount: number }>
|
||||
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
@@ -230,17 +232,40 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe
|
||||
}
|
||||
}
|
||||
|
||||
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
if (!access) return fail('Trip not found or access denied', 404);
|
||||
|
||||
try {
|
||||
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
|
||||
|
||||
if (!row) return fail('Album link not found', 404);
|
||||
|
||||
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
|
||||
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
|
||||
} catch {
|
||||
return fail('Failed to retrieve album link', 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSyncTimeForAlbumLink(linkId: string): void {
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
}
|
||||
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
|
||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
|
||||
try {
|
||||
const resp = await safeFetch(url, { headers, signal: signal as any });
|
||||
|
||||
response.status(resp.status);
|
||||
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
||||
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||
if (!resp.ok) {
|
||||
response.set('Cache-Control', 'no-store, max-age=0');
|
||||
} else if (resp.headers.get('cache-control')) {
|
||||
response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||
} else if (defaultCacheControl) {
|
||||
response.set('Cache-Control', defaultCacheControl);
|
||||
}
|
||||
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
|
||||
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
|
||||
|
||||
|
||||
@@ -246,8 +246,7 @@ export async function streamImmichAsset(
|
||||
? `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`
|
||||
: `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
|
||||
|
||||
response.set('Cache-Control', 'public, max-age=86400');
|
||||
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
|
||||
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout), 'public, max-age=86400');
|
||||
}
|
||||
|
||||
// ── Albums ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
passphrase?: string,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
|
||||
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -61,15 +69,18 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
|
||||
if (photo.file_path) {
|
||||
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
if (fs.existsSync(localPath)) {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(localPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
@@ -77,7 +88,8 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
@@ -112,7 +124,8 @@ export async function getPhotoInfo(
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiK
|
||||
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { addTripPhotos } from './unifiedService';
|
||||
import {
|
||||
getAlbumIdFromLink,
|
||||
getAlbumLinkForSync,
|
||||
updateSyncTimeForAlbumLink,
|
||||
Selection,
|
||||
ServiceResult,
|
||||
@@ -432,41 +432,66 @@ export async function testSynologyConnection(userId: number, synologyUrl: string
|
||||
return success({ connected: true, user: { name: synologyUsername } });
|
||||
}
|
||||
|
||||
async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
|
||||
const pageSize = 100;
|
||||
const all: any[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
|
||||
if (!result.success) return result as ServiceResult<any[]>;
|
||||
const items = result.data.list || [];
|
||||
all.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
return success(all);
|
||||
}
|
||||
|
||||
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Album',
|
||||
method: 'list',
|
||||
version: 4,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
if (!result.success) return result as ServiceResult<AlbumsList>;
|
||||
const [personal, shared, sharedWithMe] = await Promise.allSettled([
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
|
||||
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
|
||||
]);
|
||||
|
||||
const albums = (result.data.list || []).map((album: any) => ({
|
||||
id: String(album.id),
|
||||
albumName: album.name || '',
|
||||
assetCount: album.item_count || 0,
|
||||
}));
|
||||
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
|
||||
|
||||
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
|
||||
if (result.status === 'rejected') return;
|
||||
if (!result.value.success) {
|
||||
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
|
||||
return;
|
||||
}
|
||||
for (const album of result.value.data ?? []) {
|
||||
const id = String(album.id);
|
||||
const passphrase = extractPassphrase(album);
|
||||
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
|
||||
}
|
||||
};
|
||||
|
||||
addAlbums(personal, () => undefined);
|
||||
addAlbums(shared, (a) => a.passphrase || undefined);
|
||||
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
|
||||
|
||||
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
|
||||
return personal.value as ServiceResult<AlbumsList>;
|
||||
}
|
||||
|
||||
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
|
||||
return success({ albums });
|
||||
}
|
||||
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(albumId),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const params: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
@@ -483,23 +508,21 @@ export async function getSynologyAlbumPhotos(userId: number, albumId: string): P
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
const response = getAlbumLinkForSync(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
const { albumId, passphrase } = response.data;
|
||||
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(response.data),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
const itemParams: ApiCallParams = passphrase
|
||||
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
|
||||
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
|
||||
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -512,9 +535,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
|
||||
const selection: Selection = {
|
||||
provider: SYNOLOGY_PROVIDER,
|
||||
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
|
||||
passphrase,
|
||||
};
|
||||
|
||||
|
||||
const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
|
||||
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||
|
||||
@@ -558,16 +581,18 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
|
||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return fail('Invalid photo ID format', 400);
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
||||
const infoParams: ApiCallParams = {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'get',
|
||||
version: 5,
|
||||
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
|
||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||
});
|
||||
};
|
||||
if (passphrase) infoParams.passphrase = passphrase;
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
|
||||
|
||||
if (!result.success) return result as ServiceResult<AssetInfo>;
|
||||
|
||||
@@ -585,6 +610,8 @@ export async function streamSynologyAsset(
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
passphrase?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
@@ -610,28 +637,23 @@ export async function streamSynologyAsset(
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
: new URLSearchParams({
|
||||
api: 'SYNO.Foto.Download',
|
||||
method: 'download',
|
||||
version: '2',
|
||||
cache_key: parsedId.cacheKey,
|
||||
unit_id: `[${parsedId.id}]`,
|
||||
_sid: sid.data,
|
||||
});
|
||||
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
|
||||
// (original uses xl size to get a full-resolution JPEG-compatible render)
|
||||
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
|
||||
const params = new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: resolvedSize,
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
await pipeAsset(url, response)
|
||||
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
|
||||
//-----------------------------------------------
|
||||
// managing photos in trip
|
||||
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult<boolean> {
|
||||
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
|
||||
const providerResult = _validProvider(provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||
@@ -147,7 +148,7 @@ export async function addTripPhotos(
|
||||
for (const raw of selection.asset_ids) {
|
||||
const assetId = String(raw || '').trim();
|
||||
if (!assetId) continue;
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId);
|
||||
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<{ added: number; shared: boolean }>;
|
||||
}
|
||||
@@ -222,7 +223,7 @@ export function removeTripPhoto(
|
||||
// ----------------------------------------------
|
||||
// managing album links in trip
|
||||
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
|
||||
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName);
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return fail('Album already linked', 409);
|
||||
|
||||
@@ -100,6 +100,20 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
priority: 70,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 1 — personal thank-you from the creator (shown first)
|
||||
id: 'v3-thankyou',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Heart',
|
||||
titleKey: 'system_notice.v3_thankyou.title',
|
||||
bodyKey: 'system_notice.v3_thankyou.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 95,
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createRequire } from 'module';
|
||||
import semver from 'semver';
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNoticeDTO } from './types.js';
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
return process.env.APP_VERSION || '0.0.0';
|
||||
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
const pkg = require('../../package.json') as { version?: string };
|
||||
return semver.valid(pkg.version ?? '') ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
function severityWeight(s: string): number {
|
||||
|
||||
@@ -350,6 +350,7 @@ export interface TrekPhoto {
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
passphrase?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -936,6 +936,50 @@ describe('Share link update', () => {
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Provider photos passphrase (JOURNEY-INT-046, 047)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Provider photos — passphrase persistence', () => {
|
||||
it('JOURNEY-INT-046 — single mode with passphrase persists encrypted passphrase on trek_photos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'synologyphotos', asset_id: 'shared-asset-1', passphrase: 'pp-test' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
expect(typeof row?.passphrase).toBe('string');
|
||||
});
|
||||
|
||||
it('JOURNEY-INT-047 — batch mode with passphrase persists encrypted passphrase on all trek_photos rows', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'synologyphotos', asset_ids: ['batch-asset-1', 'batch-asset-2'], passphrase: 'pp-batch' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Photo upload without files (JOURNEY-INT-045)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -166,16 +166,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Original download
|
||||
if (apiName === 'SYNO.Foto.Download') {
|
||||
const imageBytes = Buffer.from('fake-synology-original');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
|
||||
}
|
||||
|
||||
@@ -396,6 +386,139 @@ describe('Synology search and albums', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album listing — multi-source merge ───────────────────────────────────────
|
||||
|
||||
describe('Synology listSynologyAlbums multi-source merge', () => {
|
||||
// Capture and restore the default safeFetch implementation around each test
|
||||
// in this block so the persistent mockImplementation we set doesn't leak.
|
||||
let _savedImpl: ((...args: any[]) => any) | undefined;
|
||||
beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); });
|
||||
afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); });
|
||||
|
||||
it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
// Always read both URL params and body params; body takes precedence for request-specific fields.
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
// personal albums
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any);
|
||||
}
|
||||
// shared category → failure
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums).toHaveLength(1);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
|
||||
});
|
||||
|
||||
it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any);
|
||||
}
|
||||
// shared category — one album with passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums).toHaveLength(4);
|
||||
|
||||
const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
|
||||
expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
|
||||
expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
|
||||
expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
|
||||
expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });
|
||||
|
||||
// personal albums carry no passphrase
|
||||
expect(byName('Alpha Album').passphrase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
|
||||
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
|
||||
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
|
||||
const api = urlParams.get('api') || bodyParams.get('api') || '';
|
||||
const category = bodyParams.get('category') || urlParams.get('category');
|
||||
|
||||
if (api === 'SYNO.API.Auth') {
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Browse.Album') {
|
||||
if (!category) {
|
||||
// personal: album id=99 without passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any);
|
||||
}
|
||||
// shared: no entries
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any);
|
||||
}
|
||||
if (api === 'SYNO.Foto.Sharing.Misc') {
|
||||
// shared-with-me: same album id=99 with passphrase
|
||||
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected API: ${api}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
// Deduplicated to a single album
|
||||
expect(res.body.albums).toHaveLength(1);
|
||||
expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
|
||||
// shared-with-me wins (last write) → passphrase present
|
||||
expect(res.body.albums[0].passphrase).toBe('pp-dup');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Asset access ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology asset access', () => {
|
||||
@@ -571,6 +694,7 @@ describe('Synology auth checks', () => {
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { addAlbumLink } from '../helpers/factories';
|
||||
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
@@ -632,6 +756,70 @@ describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
// Insert a link with an encrypted passphrase directly into the DB.
|
||||
const rawPassphrase = 'syno-share-pass-abc';
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase));
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any;
|
||||
|
||||
// Override safeFetch so browse-item only succeeds when called with the passphrase param.
|
||||
vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
|
||||
const bodyParams = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');
|
||||
|
||||
if (apiName === 'SYNO.API.Auth') {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any;
|
||||
}
|
||||
|
||||
if (apiName === 'SYNO.Foto.Browse.Item') {
|
||||
// Only respond successfully when the passphrase param is present.
|
||||
if (bodyParams.get('passphrase') !== rawPassphrase) {
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any;
|
||||
}
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBeGreaterThan(0);
|
||||
|
||||
// The trek_photos row for the synced photo must have a non-null passphrase.
|
||||
const photo = testDb.prepare(`
|
||||
SELECT tkp.passphrase FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||
LIMIT 1
|
||||
`).get(trip.id, user.id) as { passphrase: string | null } | undefined;
|
||||
|
||||
expect(photo).toBeDefined();
|
||||
expect(photo!.passphrase).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session retry logic ───────────────────────────────────────────────────────
|
||||
@@ -691,8 +879,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
|
||||
// Four safeFetch calls: login, failed album list, re-login, successful album list
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
// Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
|
||||
// plus one additional call for the shared or shared-with-me source (handled by default mock)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
|
||||
@@ -735,7 +924,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
// Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
|
||||
// plus one additional call for the shared or shared-with-me source (handled by default mock)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -843,6 +1034,83 @@ describe('Synology searchSynologyPhotos date range', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search pagination ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology search pagination', () => {
|
||||
it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 2, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
// With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
|
||||
expect(capturedBody!.get('offset')).toBe('50');
|
||||
expect(capturedBody!.get('limit')).toBe('50');
|
||||
});
|
||||
|
||||
it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 3, size: 25 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
// page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
|
||||
expect(capturedBody!.get('offset')).toBe('50');
|
||||
expect(capturedBody!.get('limit')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
|
||||
|
||||
describe('Synology SSRF blocked error handling', () => {
|
||||
@@ -865,13 +1133,21 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Auth succeeds, but the album-list call throws SsrfBlockedError
|
||||
const emptyAlbumResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] } }),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
|
||||
// listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
@@ -879,14 +1155,17 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'))
|
||||
.mockResolvedValueOnce(emptyAlbumResponse)
|
||||
.mockResolvedValueOnce(emptyAlbumResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
// Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1412,3 +1412,24 @@ describe('Edge cases', () => {
|
||||
expect(filledRow.source_place_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Passphrase on addProviderPhoto -------------------------------------------
|
||||
|
||||
describe('addProviderPhoto — passphrase', () => {
|
||||
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
|
||||
|
||||
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
|
||||
|
||||
expect(photo).not.toBeNull();
|
||||
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
expect(typeof row?.passphrase).toBe('string');
|
||||
// stored value must be encrypted (not plaintext)
|
||||
expect(row?.passphrase).not.toBe('secret-pp');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user