Files
TREK/client/src/pages/JourneyDetailPage.tsx
T
Maurice 4974013995 fix journey bugs reported by roel-de-vries (#722-#736)
Mobile UI:
- #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var)
- #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries
- #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA
- #725 add back/settings buttons + journey title subtitle to mobile activity view
- #726 active entry re-centers after scroll settle; tap inactive card activates
  it (does not jump straight into editor)

Entry editor flow:
- #727 photo uploads queue locally until Save for existing entries too
  (previously fired upload immediately; Cancel silently kept the new photo)
- #728 Cancel/Close with unsaved changes now requires confirm (window.confirm)
- #729 linking a Gallery photo into an entry now copies the row (old MOVE
  behavior meant Remove-from-Entry also nuked the Gallery original)
- #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton
  entries to concrete 'entry' type when content is added

Permissions:
- #732 updateJourney switched from canEdit to isOwner — editors can still
  edit entries and photos, just not the journey shell (title, cover, status)
- #733 Contributors list gains a per-row remove (X) control with confirm
- #734 my_role is computed server-side and returned with the journey; UI
  gates Settings/Add/Edit/Delete controls based on role
- #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require
  isOwner (previously NO permission check at all — anyone authenticated
  could publish or unpublish a journey)

Immich upload (#730):
- migration 111: add users.immich_auto_upload (default 0)
- migration 112: seed provider_field for the toggle (idempotent, FK-safe)
- journey photo upload only mirrors to Immich when the user has opted in
- Settings UI gets a "Mirror journey photos to Immich on upload" checkbox

Test updates:
- JOURNEY-SVC-019 inverted to assert editor cannot update journey settings
- JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink
- FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save
- client/tests still green (2676/2676)

Also fixed en route: gallery entry title is now the literal 'Gallery' on the
wire (used to send the translated label, which broke server-side title === 'Gallery'
checks in non-English locales); confirm interpolation uses {username} single
braces matching the existing i18n runtime; Settings footer uses icon-only
delete/archive buttons on mobile so the row doesn't wrap.
2026-04-18 19:11:16 +02:00

3160 lines
154 KiB
TypeScript

import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import { useToast } from '../components/shared/Toast'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import {
ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy,
Clock, Package, Image, ChevronRight,
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
Archive, ArchiveRestore,
} 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'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
'linear-gradient(135deg, #1E293B 0%, #7C3AED 50%, #F59E0B 100%)',
'linear-gradient(135deg, #134E5E 0%, #71B280 100%)',
'linear-gradient(135deg, #2D1B69 0%, #11998E 100%)',
'linear-gradient(135deg, #4B134F 0%, #C94B4B 100%)',
'linear-gradient(135deg, #373B44 0%, #4286F4 100%)',
]
function pickGradient(id: number): string {
return GRADIENTS[id % GRADIENTS.length]
}
const MOOD_CONFIG: Record<string, { bg: string; text: string; icon: typeof Laugh; label: string }> = {
amazing: { bg: '#FDF2F8', text: '#BE185D', icon: Laugh, label: 'journey.mood.amazing' },
good: { bg: '#FFFBEB', text: '#B45309', icon: Smile, label: 'journey.mood.good' },
neutral: { bg: '#F4F4F5', text: '#3F3F46', icon: Meh, label: 'journey.mood.neutral' },
rough: { bg: '#F5F3FF', text: '#6D28D9', icon: Frown, label: 'journey.mood.rough' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'journey.weather.sunny' },
partly: { icon: CloudSun, label: 'journey.weather.partly' },
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
stormy: { icon: CloudLightning, label: 'journey.weather.stormy' },
cold: { icon: Snowflake, label: 'journey.weather.cold' },
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
for (const e of entries) {
const d = e.entry_date
if (!groups.has(d)) groups.set(d, [])
groups.get(d)!.push(e)
}
return groups
}
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
return {
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(),
}
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}`
}
export default function JourneyDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
// Role-based permissions (server-provided via my_role). Fall back to
// "owner" when the field isn't present yet (legacy responses) so behavior
// matches the pre-permissions era.
const myRole = (current as any)?.my_role ?? 'owner'
const canEditEntries = myRole === 'owner' || myRole === 'editor'
const canEditJourney = myRole === 'owner'
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)
const [showInvite, setShowInvite] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
// WebSocket real-time updates
useEffect(() => {
if (!id) return
const journeyId = Number(id)
const handler = (event: Record<string, unknown>) => {
const type = event.type as string
if (!type?.startsWith('journey:')) return
if (event.journeyId !== journeyId) return
// reload journey data on any change from other contributors
loadJourney(journeyId)
}
addListener(handler)
return () => removeListener(handler)
}, [id])
// scroll sync with map
const observerRef = useRef<IntersectionObserver | null>(null)
const setupObserver = useCallback(() => {
observerRef.current?.disconnect()
observerRef.current = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
const entryId = e.target.getAttribute('data-entry-id')
if (entryId) mapRef.current?.highlightMarker(entryId)
}
}
}, { threshold: 0.5 })
document.querySelectorAll('[data-entry-id]').forEach(el => {
observerRef.current?.observe(el)
})
}, [])
useEffect(() => {
if (current?.entries?.length) {
setTimeout(setupObserver, 300)
}
return () => observerRef.current?.disconnect()
}, [current?.entries, setupObserver])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
const handleLocationClick = useCallback((id: string) => {
setActiveLocationId(id)
}, [])
useEffect(() => {
if (view === 'map') {
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
}
}, [view])
const mapEntries = useMemo(
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
[current?.entries]
)
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
})), [mapEntries])
const tripDates = useMemo(() => {
const dates = new Set<string>()
if (!current?.trips) return dates
for (const trip of current.trips) {
if (!trip.start_date || !trip.end_date) continue
const start = new Date(trip.start_date + 'T00:00:00')
const end = new Date(trip.end_date + 'T00:00:00')
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(d.toISOString().split('T')[0])
}
}
return dates
}, [current?.trips])
if (loading || !current) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
</div>
)
}
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const tripDateMin = current.trips.length
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
: null
const tripDateMax = current.trips.length
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
: null
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
const showMobileCombined = isMobile && view === 'timeline'
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems}
dark={document.documentElement.classList.contains('dark')}
readOnly={!canEditEntries}
onEntryClick={(entry) => setViewingEntry(entry)}
onAddEntry={canEditEntries ? () => {
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)
} : undefined}
/>
)}
{/* Fullscreen entry view (mobile) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry}
readOnly={!canEditEntries}
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 top bar on mobile combined view: back | tabs+title | settings */}
{showMobileCombined && (
<div
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
>
<button
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
>
<ArrowLeft size={16} />
</button>
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
<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>
{current?.title && (
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
{current.title}
</div>
)}
</div>
{canEditJourney ? (
<button
onClick={() => setShowSettings(true)}
aria-label={t('journey.settings.title')}
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
>
<MoreHorizontal size={16} />
</button>
) : (
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
)}
</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 */}
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
<ArrowLeft size={14} />
{t('journey.detail.backToJourney')}
</button>
{/* Hero card — full width */}
<div className="px-4 md:px-0 mb-6">
<div className="rounded-none md:rounded-2xl -mx-4 md:mx-0 overflow-hidden relative p-5 md:p-7" style={{ background: pickGradient(current.id), color: 'white' }}>
{current.cover_image && (
<div className="absolute inset-0 z-[1]">
<img src={`/uploads/${current.cover_image}`} className="w-full h-full object-cover" alt="" />
<div className="absolute inset-0" style={{ background: pickGradient(current.id), opacity: 0.55 }} />
</div>
)}
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'radial-gradient(circle at 20% 20%, rgba(236,72,153,0.3), transparent 50%), radial-gradient(circle at 80% 80%, rgba(99,102,241,0.3), transparent 50%)' }} />
<div className="relative z-[3] flex items-center justify-between mb-5">
{/* Desktop: badges */}
<div className="hidden md:flex items-center gap-2">
{lifecycle === 'live' && (
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{t('journey.frontpage.live')}
</div>
)}
{lifecycle !== 'archived' && current.trips.length > 0 && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
<RefreshCw size={11} />
{t('journey.detail.syncedWithTrips')}
</div>
)}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
</div>
)}
{lifecycle === 'archived' && (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
{t('journey.status.archived')}
</div>
)}
</div>
{/* Mobile: back button on the left */}
<button
onClick={() => navigate('/journey')}
className="md:hidden w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
<div className="flex items-center gap-1.5">
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<div className="relative group">
<button
onClick={async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}}
className={`w-[34px] h-[34px] rounded-lg backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
{canEditJourney && (
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
)}
</div>
</div>
<div className="relative z-[3] mb-5">
<h1 className="text-[32px] font-bold tracking-[-0.02em] leading-tight mb-1.5">{current.title}</h1>
{current.subtitle && <p className="text-[13px] opacity-85">{current.subtitle}</p>}
</div>
<div className="relative z-[3] border-t border-white/15 pt-5 flex items-end justify-between">
<div className="flex gap-8">
{[
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.places, label: t('journey.stats.places') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
].map(s => (
<div key={s.label} className="flex flex-col gap-0.5">
<span className="text-[20px] font-bold">{s.value}</span>
<span className="text-[10px] uppercase tracking-[0.08em] opacity-70">{s.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Main grid */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6 px-4 md:px-0">
{/* Left column */}
<div>
{/* 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">
{(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)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
{canEditEntries && (!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 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
>
<Plus size={16} />
</button>
)}
</div>
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && (
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<BookOpen size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
<p className="text-[12px] text-zinc-500 mt-1">Add a trip to get started with skeleton entries</p>
</div>
)}
{sortedDates.map((date, dayIdx) => {
const entries = dayGroups.get(date)!
const fd = formatDate(date)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return (
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
{dayIdx + 1}
</div>
<div>
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
</div>
</div>
<div className="flex items-center gap-3 text-[11px] text-zinc-500">
<span className="flex items-center gap-1"><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
</div>
</div>
{entries.map(entry => (
<div key={entry.id} data-entry-id={String(entry.id)}>
{entry.type === 'skeleton' ? (
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
) : entry.type === 'checkin' ? (
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
) : (
<EntryCard
entry={entry}
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
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 })}
/>
)}
</div>
))}
</div>
)
})}
</div>
)}
{/* Gallery View */}
<div className={view === 'gallery' ? '' : 'hidden'}>
<GalleryView
entries={current.entries}
journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
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 })}
onRefresh={() => loadJourney(Number(id))}
/>
</div>
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && (
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
<MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
activeLocationId={activeLocationId}
fullMapRef={fullMapRef}
onLocationClick={handleLocationClick}
/>
</div>
)}
</div>
{/* Right sidebar — hidden on mobile */}
<div className="hidden lg:flex flex-col gap-4 lg:sticky lg:top-[80px] lg:self-start">
{/* Map panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={240}
onMarkerClick={handleMarkerClick}
/>
<div className="px-3.5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between text-[11px] text-zinc-500">
<span>{mapEntries.length} {t('journey.stats.places')}</span>
</div>
</div>
{/* Stats panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
<div className="grid grid-cols-2 gap-2">
{[
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.places, label: t('journey.stats.places') },
].map(s => (
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
<div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
</div>
))}
</div>
</div>
{/* Synced Trips panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3.5">
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.syncedTrips')}</span>
<button onClick={() => setShowAddTrip(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
<Plus size={12} />
</button>
</div>
<div className="flex flex-col gap-1">
{current.trips.map((trip: any) => (
<div
key={trip.trip_id}
onClick={() => navigate(`/trips/${trip.trip_id}`)}
className="group flex items-center gap-2.5 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer"
>
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
{trip.place_count || 0} {t('journey.detail.places')}
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
</div>
</div>
<button
onClick={e => { e.stopPropagation(); setUnlinkTrip({ trip_id: trip.trip_id, title: trip.title }) }}
className="w-6 h-6 rounded-md flex items-center justify-center text-zinc-400 opacity-0 group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-opacity"
title="Unlink trip"
>
<Trash2 size={12} />
</button>
<ChevronRight size={14} className="text-zinc-400" />
</div>
))}
{current.trips.length === 0 && (
<p className="text-[11px] text-zinc-400 text-center py-3">{t('journey.detail.noTripsLinked')}</p>
)}
</div>
</div>
{/* Contributors panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3.5">
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.contributors')}</span>
<button onClick={() => setShowInvite(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
<UserPlus size={12} />
</button>
</div>
<div className="flex flex-col gap-2.5">
{current.contributors.map((c: any) => (
<div key={c.user_id} className="flex items-center gap-2.5">
{c.avatar_url ? (
<img src={c.avatar_url} className="w-7 h-7 rounded-full object-cover" alt="" />
) : (
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
{(c.username || '?')[0].toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-900 dark:text-white">{c.username}</div>
</div>
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${
c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
}`}>
{c.role}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Entry Editor */}
{editingEntry && (
<EntryEditor
entry={editingEntry}
journeyId={current.id}
tripDates={tripDates}
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
onClose={() => setEditingEntry(null)}
onSave={async (data) => {
let entryId = editingEntry.id
if (editingEntry.id === 0) {
const created = await useJourneyStore.getState().createEntry(current.id, data)
entryId = created.id
} else {
await updateEntry(editingEntry.id, data)
}
return entryId
}}
onUploadPhotos={async (entryId, formData) => {
return await uploadPhotos(entryId, formData)
}}
onDone={() => {
setEditingEntry(null)
loadJourney(Number(id))
}}
/>
)}
{/* Journey Settings */}
{showSettings && (
<JourneySettingsDialog
journey={current}
onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
/>
)}
{/* Add Trip Dialog */}
{showAddTrip && current && (
<AddTripDialog
journeyId={current.id}
existingTripIds={current.trips.map((t: any) => t.trip_id)}
onClose={() => setShowAddTrip(false)}
onAdded={() => { setShowAddTrip(false); loadJourney(Number(id)) }}
/>
)}
{/* Contributor Invite Dialog */}
{showInvite && (
<ContributorInviteDialog
journeyId={current.id}
existingUserIds={current.contributors.map((c: any) => c.user_id)}
onClose={() => setShowInvite(false)}
onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }}
/>
)}
{/* Delete confirm */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return
await deleteEntry(deleteTarget.id)
setDeleteTarget(null)
loadJourney(Number(id))
}}
title={t('journey.entries.deleteTitle')}
message={t('journey.deleteConfirmMessage', { title: deleteTarget?.title || 'this entry' })}
confirmLabel={t('common.delete')}
danger
/>
{/* Unlink Trip confirm */}
<ConfirmDialog
isOpen={!!unlinkTrip}
onClose={() => setUnlinkTrip(null)}
onConfirm={async () => {
if (!unlinkTrip || !current) return
try {
await journeyApi.removeTrip(current.id, unlinkTrip.trip_id)
toast.success(t('journey.trips.tripUnlinked'))
setUnlinkTrip(null)
loadJourney(Number(id))
} catch {
toast.error(t('journey.trips.unlinkFailed'))
}
}}
title={t('journey.trips.unlinkTrip')}
message={t('journey.trips.unlinkMessage', { title: unlinkTrip?.title })}
confirmLabel={t('journey.trips.unlink')}
danger
/>
{/* Lightbox */}
{lightbox && (
<PhotoLightbox
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))}
startIndex={lightbox.index}
onClose={() => setLightbox(null)}
/>
)}
</div>
)
}
// ── Map View ──────────────────────────────────────────────────────────────
function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRef, onLocationClick }: {
entries: JourneyEntry[]
mapEntries: JourneyEntry[]
sortedDates: string[]
activeLocationId: string | null
fullMapRef: React.RefObject<JourneyMapHandle | null>
onLocationClick: (id: string) => void
}) {
const { t } = useTranslation()
// group map entries by date
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
mapEntries.forEach((e, i) => {
const d = e.entry_date
if (!byDate.has(d)) byDate.set(d, [])
byDate.get(d)!.push({ entry: e, globalIdx: i })
})
const dates = [...byDate.keys()].sort()
// find first and last entry indices
const firstId = mapEntries[0]?.id
const lastId = mapEntries[mapEntries.length - 1]?.id
const mapItems = useMemo(() => 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,
})), [mapEntries])
return (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<JourneyMap
ref={fullMapRef}
checkins={[]}
entries={mapItems as any}
height={560}
activeMarkerId={activeLocationId}
onMarkerClick={onLocationClick}
/>
{/* Locations list */}
<div>
{/* Stats header */}
{mapEntries.length > 0 && (
<div className="mx-5 mt-4 mb-2 grid grid-cols-3 gap-2">
{[
{ value: mapEntries.length, label: t('journey.stats.places') },
{ value: dates.length, label: t('journey.stats.days') },
{ value: entries.filter(e => e.type === 'entry').length, label: 'Stories' },
].map(s => (
<div key={s.label} className="bg-zinc-50 dark:bg-zinc-800 rounded-xl p-3 text-center">
<div className="text-[17px] font-bold text-zinc-900 dark:text-white tracking-tight">{s.value}</div>
<div className="text-[9px] font-medium text-zinc-500 uppercase tracking-[0.06em]">{s.label}</div>
</div>
))}
</div>
)}
{/* Day groups */}
<div className="px-5 pb-5">
{dates.map((date, dayIdx) => {
const items = byDate.get(date)!
const fd = formatDate(date)
return (
<div key={date}>
{/* Day separator */}
<div className="flex items-center gap-2.5 py-3">
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</div>
{/* Location items */}
{items.map(({ entry: e, globalIdx }, itemIdx) => {
const isActive = activeLocationId === String(e.id)
const isFirst = e.id === firstId
const isLast = e.id === lastId
const showConnector = itemIdx < items.length - 1
return (
<div key={e.id}>
<div
onClick={() => onLocationClick(String(e.id))}
className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
isActive
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
}`}
>
{/* Number badge */}
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[13px] font-bold flex-shrink-0 border-2 border-white dark:border-zinc-900 ${
isActive
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_2px_rgba(0,0,0,0.15)]'
: 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_1px_rgba(0,0,0,0.1)]'
}`}>
{globalIdx + 1}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div>
<div className="text-[11px] text-zinc-500 truncate">
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div>
</div>
{/* Chevron */}
<ChevronRight size={14} className={`flex-shrink-0 ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`} />
</div>
{/* Connector line */}
{showConnector && (
<div className="w-0.5 h-2 bg-zinc-200 dark:bg-zinc-700 ml-[18px] rounded-full" />
)}
</div>
)
})}
</div>
)
})}
</div>
</div>
</div>
)
}
// ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[]
journeyId: number
userId: number
trips: JourneyTrip[]
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
onRefresh: () => void
}) {
const { t } = useTranslation()
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false)
const toast = useToast()
// check which providers are enabled AND connected for the current user
useEffect(() => {
(async () => {
try {
const addonsData = await addonsApi.enabled()
const enabledProviders = (addonsData.addons || []).filter(
(a: any) => a.type === 'photo_provider' && a.enabled
)
const connected: { id: string; name: string }[] = []
for (const p of enabledProviders) {
try {
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
if (res.ok) {
const status = await res.json()
if (status.connected) connected.push({ id: p.id, name: p.name })
}
} catch {}
}
setAvailableProviders(connected)
} catch {}
})()
}, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
for (const e of entries) {
for (const p of e.photos) {
allPhotos.push({ photo: p, entry: e })
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
const browseProvider = (provider: string) => {
setPickerProvider(provider)
setShowPicker(true)
}
const galleryFileRef = useRef<HTMLInputElement>(null)
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
setGalleryUploading(true)
try {
// find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
}
const formData = new FormData()
for (const f of files) formData.append('photos', f)
await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh()
} catch {
toast.error(t('journey.settings.coverFailed'))
} finally {
setGalleryUploading(false)
}
e.target.value = ''
}
const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState()
if (store.current) {
const updated = {
...store.current,
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== photoId),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
}
useJourneyStore.setState({ current: updated })
}
try {
await journeyApi.deletePhoto(photoId)
} catch {
toast.error(t('common.error'))
onRefresh() // Revert on error
}
}
return (
<div>
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => galleryFileRef.current?.click()}
disabled={galleryUploading}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
>
{galleryUploading ? (
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={12} /> {t('common.upload')}</>
)}
</button>
{availableProviders.map(p => (
<button
key={p.id}
onClick={() => browseProvider(p.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<Image size={12} />
{p.name}
</button>
))}
</div>
</div>
{allPhotos.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<Image size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">{t('journey.detail.noPhotos')}</p>
<p className="text-[12px] text-zinc-500 mt-1">{t('journey.detail.noPhotosHint')}</p>
</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 }, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{/* Delete button */}
<button
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X size={12} />
</button>
{photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
</span>
</div>
)}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{photo.caption}</p>
</div>
)}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div>
))}
</div>
)}
{/* Provider Photo Picker Modal */}
{showPicker && (
<ProviderPicker
provider={pickerProvider!}
userId={userId}
entries={entriesWithContent}
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 (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
targetId = entry.id
} catch { return }
}
let added = 0
for (const group of groups) {
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} catch {}
}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
}
setShowPicker(false)
}}
/>
)}
</div>
)
}
// ── Expandable Story ─────────────────────────────────────────────────────
function ExpandableStory({ story }: { story: string }) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [clamped, setClamped] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const measuredRef = useRef(false)
useEffect(() => {
measuredRef.current = false
}, [story])
useEffect(() => {
if (measuredRef.current) return
const el = ref.current
if (el && !expanded) {
setClamped(el.scrollHeight > el.clientHeight)
measuredRef.current = true
}
})
return (
<div>
<div
ref={ref}
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
>
<JournalBody text={story} />
</div>
{clamped && !expanded && (
<button
onClick={() => setExpanded(true)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
{t('common.showMore')} <ChevronRight size={10} />
</button>
)}
{expanded && (
<button
onClick={() => setExpanded(false)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
{t('common.showLess')} <ChevronRight size={10} className="rotate-[-90deg]" />
</button>
)}
</div>
)
}
// ── Verdict Section (Pros & Cons) ────────────────────────────────────────
function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
// On desktop always show, on mobile toggle
return (
<div className="mt-5">
{/* Header — clickable on mobile */}
<button
onClick={() => setOpen(o => !o)}
className="md:pointer-events-none w-full flex items-center gap-2.5 mb-3.5 group"
>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-zinc-400 flex items-center gap-1.5">
{t('journey.editor.prosCons')}
<ChevronDown
size={12}
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
/>
</span>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</button>
{/* Collapsed summary on mobile */}
{!open && (
<div className="flex items-center justify-center gap-3 md:hidden">
{pros.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-md bg-green-500 flex items-center justify-center">
<Check size={11} className="text-white" strokeWidth={3} />
</div>
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{pros.length}</span>
</div>
)}
{cons.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-md bg-red-500 flex items-center justify-center">
<Minus size={11} className="text-white" strokeWidth={3} />
</div>
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{cons.length}</span>
</div>
)}
</div>
)}
{/* Content — always visible on desktop, toggled on mobile */}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 overflow-hidden transition-all duration-300 ease-in-out ${
open ? 'max-h-[800px] opacity-100' : 'max-h-0 md:max-h-none opacity-0 md:opacity-100'
}`}
>
{pros.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4 bg-gradient-to-b from-green-50 to-white dark:from-green-950/30 dark:to-zinc-900">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
<Check size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
</div>
<div className="flex flex-col gap-2">
{pros.map((p, i) => (
<div key={i} className="flex items-start gap-2">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[7px]" />
<span className="text-[13px] text-green-900 dark:text-green-100 leading-snug">{p}</span>
</div>
))}
</div>
</div>
)}
{cons.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4 bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-zinc-900">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
<Minus size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
</div>
<div className="flex flex-col gap-2">
{cons.map((c, i) => (
<div key={i} className="flex items-start gap-2">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[7px]" />
<span className="text-[13px] text-red-900 dark:text-red-100 leading-snug">{c}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
// ── Entry Card ────────────────────────────────────────────────────────────
function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
entry: JourneyEntry
readOnly?: boolean
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}) {
const { t } = useTranslation()
const [menuOpen, setMenuOpen] = useState(false)
const menuBtnRef = useRef<HTMLButtonElement>(null)
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
return (
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
{/* Hero area: photos with title overlay */}
{photos.length > 0 ? (
<div className="relative">
<PhotoGrid photos={photos} onClick={(idx) => onPhotoClick(photos, idx)} />
{/* Gradient overlay for title */}
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.2) 50%, transparent 100%)', height: '60%' }} />
{/* Badges top-left */}
<div className="absolute top-3 left-4 right-14 flex items-center gap-1.5 z-[2]">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
<Clock size={10} />
{entry.entry_time}
</span>
)}
</div>
{/* Menu top-right */}
{!readOnly && (
<div className="absolute top-2.5 right-3 z-[2]">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
)}
{/* Title on photo */}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 z-[2] pointer-events-none">
<h3 className="text-[22px] font-bold text-white tracking-[-0.02em] leading-tight drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
) : (
/* No photos: simple header */
<div className="flex items-center justify-between px-4 pt-3">
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
<Clock size={10} /> {entry.entry_time}
</span>
)}
</div>
{!readOnly && (
<div className="relative">
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<MoreHorizontal size={14} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
</div>
</>
)}
</div>
)}
</div>
)}
<div className="px-5 pt-4 pb-5">
{/* Title (only if no photos — otherwise shown on image) */}
{!photos.length && entry.title && (
<h3 className="text-base font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-1">{entry.title}</h3>
)}
{!photos.length && entry.location_name && !entry.title && (
<div className="mb-2" />
)}
{entry.story && (
<ExpandableStory story={entry.story} />
)}
{/* Pros & Cons — "Pros & Cons" style */}
{hasProscons && (
<VerdictSection pros={prosArr} cons={consArr} />
)}
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
<div className="flex items-center justify-between pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex items-center gap-1.5">
{mood && <MoodChip mood={entry.mood!} />}
{weather && <WeatherChip weather={entry.weather!} />}
</div>
<div className="flex gap-1">
{entry.tags?.map((tag, i) => (
<span key={i} className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">{tag}</span>
))}
</div>
</div>
)}
</div>
</div>
)
}
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
const { t } = useTranslation()
return (
<div
onClick={onClick}
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
<MapPin size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">
{entry.title || t('journey.detail.newEntry')}
</div>
<div className="text-[11px] text-zinc-500 mt-0.5">
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div>
</div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
{t('journey.detail.addEntry')} &rarr;
</div>
</div>
)
}
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
return (
<div
onClick={onClick}
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
>
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
<MapPin size={13} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white flex items-center gap-1.5">
{entry.title}
{entry.location_name && <span className="text-zinc-500 font-normal text-xs">· {entry.location_name}</span>}
</div>
{entry.story && <div className="text-[11px] text-zinc-500 mt-0.5">{entry.story}</div>}
</div>
<div className="flex items-center gap-2.5 flex-shrink-0">
{entry.entry_time && <span className="text-[11px] text-zinc-400 tabular-nums">{entry.entry_time}</span>}
</div>
</div>
)
}
function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
const src = photoUrl(photo, 'thumbnail')
return (
<img
src={src}
alt=""
className={className}
style={style}
onClick={onClick}
loading="lazy"
/>
)
}
function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick: (idx: number) => void }) {
const count = photos.length
if (count === 0) return null
if (count === 1) {
return (
<div className="overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
<PhotoImg photo={photos[0]} className="w-full h-72 object-cover" />
</div>
)
}
if (count === 2) {
return (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<PhotoImg key={p.id} photo={p} className="w-full h-52 object-cover cursor-pointer" onClick={() => onClick(i)} />
))}
</div>
)
}
return (
<div className="overflow-hidden flex" style={{ height: 300, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
<PhotoImg photo={photos[0]} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
<PhotoImg photo={photos[1]} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
{count > 3 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} />
+{count - 3}
</div>
)}
</div>
</div>
</div>
)
}
function MoodChip({ mood }: { mood: string }) {
const { t } = useTranslation()
const config = MOOD_CONFIG[mood]
if (!config) return null
const Icon = config.icon
return (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: config.bg, color: config.text }}>
<Icon size={11} />
{t(config.label)}
</div>
)
}
function WeatherChip({ weather }: { weather: string }) {
const { t } = useTranslation()
const config = WEATHER_CONFIG[weather]
if (!config) return null
const Icon = config.icon
return (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<Icon size={11} />
{t(config.label)}
</div>
)
}
// ── Scroll Trigger ───────────────────────────────────────────────────────
function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
obs.observe(el)
return () => obs.disconnect()
}, [onVisible, loading])
return (
<div ref={ref} className="flex justify-center py-4 mt-2">
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
</div>
)
}
// ── Photo date grouping ───────────────────────────────────────────────────
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
const map = new Map<string, any[]>()
for (const asset of photos) {
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(asset)
}
return [...map.entries()].map(([date, assets]) => ({
date,
label: date === '__unknown__'
? 'Unknown date'
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
assets,
}))
}
// ── Provider Picker ───────────────────────────────────────────────────────
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
provider: string
userId: number
entries: JourneyEntry[]
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [photos, setPhotos] = 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)
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
const [addToOpen, setAddToOpen] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
// compute trip range
const tripRange = useMemo(() => {
let from = '', to = ''
for (const t of trips) {
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
}
return { from, to }
}, [trips])
const cancelPending = () => {
if (abortRef.current) { abortRef.current.abort() }
abortRef.current = new AbortController()
return abortRef.current.signal
}
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
const signal = cancelPending()
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
setSearchFrom(from)
setSearchTo(to)
setSearchPage(page)
try {
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
method: 'POST', credentials: 'include', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to, page, size: 50 }),
})
if (res.ok) {
const data = await res.json()
const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
} else {
setHasMore(false)
}
} catch (e: any) {
if (e.name !== 'AbortError') setHasMore(false)
}
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
}
const loadMorePhotos = () => {
if (loadingMore || !hasMore) return
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
}
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
const signal = cancelPending()
setLoading(true)
setPhotos([])
setHasMore(false)
try {
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)
}
const loadAlbums = async () => {
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
if (res.ok) setAlbums((await res.json()).albums || [])
} catch {}
}
// load on mount / filter change
useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
} else if (filter === 'album' && albums.length === 0) {
loadAlbums()
}
}, [filter])
const handleCustomSearch = () => {
if (customFrom && customTo) searchPhotos(customFrom, customTo)
}
const toggleAsset = (id: string) => {
setSelected(prev => {
const next = new Map(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
}
return next
})
}
const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
: t('journey.picker.newGallery')
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
{/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
{[
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
filter === f.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
{f.label}
</button>
))}
</div>
{/* Filter content — always visible row */}
<div className="min-h-[36px] flex items-center">
{filter === 'trip' && (
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
{tripRange.from && tripRange.to ? (
<>
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
<span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="ml-1 text-zinc-400">
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
</span>
</>
) : (
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
)}
</div>
)}
{filter === 'custom' && (
<div className="flex items-center gap-2 flex-1">
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
<span className="text-zinc-400 text-[12px]">&mdash;</span>
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button onClick={handleCustomSearch}
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
{t('journey.picker.search')}
</button>
</div>
)}
{filter === 'album' && (
<div className="flex gap-2 overflow-x-auto flex-1">
{albums.map((a: any) => (
<button
key={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'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button>
))}
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
</div>
)}
</div>
</div>
{/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
</button>
{addToOpen && (
<>
<div className="fixed inset-0 z-[9]" onClick={() => setAddToOpen(false)} />
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
<button
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
!targetEntryId
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
>
<Camera size={12} />
{t('journey.picker.newGallery')}
</button>
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
)}
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
<button
key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
targetEntryId === e.id
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
>
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Select all bar — sticky above grid */}
{!loading && photos.length > 0 && (() => {
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button
onClick={() => {
if (allSelected) {
setSelected(new Map())
} else {
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
allSelected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
</div>
)
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
) : photos.length === 0 ? (
<div className="text-center py-12">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
</p>
</div>
) : (
<div>
{groupPhotosByDate(photos).map(group => (
<div key={group.date}>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
{group.label}
</p>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id)
const alreadyAdded = existingAssetIds.has(asset.id)
return (
<div
key={asset.id}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative aspect-square rounded-lg overflow-hidden ${
alreadyAdded
? 'opacity-40 cursor-not-allowed'
: isSelected
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
: 'cursor-pointer'
}`}
>
<img
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${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
if (!img.src.includes('/original')) img.src = original
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
{asset.city && (
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
<p className="text-[8px] text-white truncate">{asset.city}</p>
</div>
)}
</div>
)
})}
</div>
</div>
))}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
<div className="flex items-center gap-2">
<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={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
onAdd(groups, targetEntryId)
}}
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"
>
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>
</div>
)
}
// ── Date Picker ───────────────────────────────────────────────────────────
function DatePicker({ value, onChange, tripDates }: {
value: string
onChange: (date: string) => void
tripDates?: Set<string>
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date()
return { year: d.getFullYear(), month: d.getMonth() }
})
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
const prevMonth = () => {
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
}
const nextMonth = () => {
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
}
const pad = (n: number) => String(n).padStart(2, '0')
const cells: (number | null)[] = []
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
>
<span>{formatted}</span>
<Calendar size={13} className="text-zinc-400" />
</button>
{open && (
<>
<div className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
{/* Month nav */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<ArrowLeft size={14} />
</button>
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
<ChevronRight size={14} />
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{cells.map((day, i) => {
if (day === null) return <div key={`e${i}`} />
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
const isSelected = dateStr === value
const isTrip = tripDates?.has(dateStr)
const isToday = dateStr === new Date().toISOString().split('T')[0]
return (
<button
key={dateStr}
type="button"
onClick={() => { onChange(dateStr); setOpen(false) }}
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
isSelected
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: isToday
? 'text-zinc-900 dark:text-white font-bold'
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
})}
</div>
</div>
</>
)}
</div>
)
}
function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
entry: JourneyEntry
journeyId: number
tripDates: Set<string>
galleryPhotos: JourneyPhoto[]
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
onDone: () => void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
const [locationQuery, setLocationQuery] = useState('')
const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
const [locationSearching, setLocationSearching] = useState(false)
const [showLocationResults, setShowLocationResults] = useState(false)
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
// Track which fields differ from the entry we started editing so we can
// warn before discarding on close/cancel.
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
const isDirty = (
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
entryTime !== (entry.entry_time || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
mood !== (entry.mood || '') ||
weather !== (entry.weather || '') ||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
pendingFiles.length > 0 ||
pendingLinkIds.length > 0
)
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
}
const handleSave = async () => {
setSaving(true)
try {
const entryId = await onSave({
title: title || null,
story: story || null,
entry_date: entryDate,
entry_time: entryTime || null,
location_name: locationName || null,
location_lat: locationLat,
location_lng: locationLng,
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
})
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
const formData = new FormData()
for (const f of pendingFiles) formData.append('photos', f)
await onUploadPhotos(entryId, formData)
}
// link gallery photos that were picked before save
if (pendingLinkIds.length > 0 && entryId) {
for (const photoId of pendingLinkIds) {
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
}
}
onDone()
} finally {
setSaving(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence.
setPendingFiles(prev => [...prev, ...Array.from(files)])
}
return (
<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>
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={t('journey.editor.titlePlaceholder')}
className="w-full text-[20px] font-medium bg-transparent border-0 border-b border-transparent focus:border-zinc-300 dark:focus:border-zinc-600 outline-none text-zinc-900 dark:text-white placeholder:text-zinc-400 pb-2"
/>
<div>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
>
{uploading ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)}
</button>
{galleryPhotos.length > 0 && (
<button
onClick={() => setShowGalleryPick(!showGalleryPick)}
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
showGalleryPick
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
</div>
{/* Gallery picker — directly below buttons */}
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
<div
key={gp.id}
onClick={async () => {
if (entry.id > 0) {
try {
const linked = await journeyApi.linkPhoto(entry.id, gp.id)
if (linked) setPhotos(prev => [...prev, linked])
} catch {}
} else {
setPendingLinkIds(prev => [...prev, gp.id])
setPhotos(prev => [...prev, gp])
}
}}
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
>
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)}
</div>
</div>
)}
{(photos.length > 0 || pendingFiles.length > 0) && (
<div className="mt-3">
<div className="flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
{idx > 0 && photos.length > 1 && (
<button
onClick={e => {
e.stopPropagation()
setPhotos(prev => {
const next = [...prev]
const [moved] = next.splice(idx, 1)
next.unshift(moved)
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
return next
})
}}
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
>
{t('journey.editor.makeFirst')}
</button>
)}
<button
onClick={async (e) => {
e.stopPropagation()
await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id))
}}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
{pendingFiles.map((f, i) => (
<div key={`pending-${i}`} className="w-20 h-20 rounded-lg overflow-hidden relative group">
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
<button
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
</div>
</div>
)}
</div>
<div className="shrink-0 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden focus-within:border-zinc-400 dark:focus-within:border-zinc-500">
<MarkdownToolbar textareaRef={storyRef} onUpdate={setStory} />
<textarea
ref={storyRef}
value={story}
onChange={e => setStory(e.target.value)}
placeholder={t('journey.editor.writeStory')}
rows={6}
style={{ minHeight: '144px' }}
className="w-full px-3 py-2.5 text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none resize-none border-0 shrink-0"
/>
</div>
{/* Pros & Cons */}
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
<div className="mb-4">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Pros */}
<div>
<div className="flex items-center gap-[7px] mb-2.5">
<div className="w-4 h-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Check size={9} className="text-green-700 dark:text-green-400" strokeWidth={3.5} />
</div>
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{t('journey.editor.pros')}</span>
</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 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}
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
placeholder={t('journey.editor.proPlaceholder')}
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
/>
{pros.length > 1 && (
<button onClick={() => setPros(pros.filter((_, j) => j !== i))} className="p-1 text-green-300 dark:text-green-700 hover:text-green-600 dark:hover:text-green-400 flex-shrink-0">
<X size={13} strokeWidth={2.5} />
</button>
)}
</div>
))}
<button
onClick={() => setPros([...pros, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
{/* Cons */}
<div>
<div className="flex items-center gap-[7px] mb-2.5">
<div className="w-4 h-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Minus size={9} className="text-red-700 dark:text-red-400" strokeWidth={3.5} />
</div>
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{t('journey.editor.cons')}</span>
</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 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}
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
placeholder={t('journey.editor.conPlaceholder')}
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
/>
{cons.length > 1 && (
<button onClick={() => setCons(cons.filter((_, j) => j !== i))} className="p-1 text-red-300 dark:text-red-700 hover:text-red-600 dark:hover:text-red-400 flex-shrink-0">
<X size={13} strokeWidth={2.5} />
</button>
)}
</div>
))}
<button
onClick={() => setCons([...cons, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
</div>
</div>
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
</div>
<div className="relative">
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
<div className="relative">
<input
value={locationQuery || locationName}
onChange={e => {
const q = e.target.value
setLocationQuery(q)
setShowLocationResults(true)
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
if (q.trim().length >= 2) {
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true)
try {
const res = await mapsApi.search(q)
setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
})))
} catch { setLocationResults([]) }
finally { setLocationSearching(false) }
}, 400)
} else {
setLocationResults([])
}
}}
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
placeholder={t('journey.editor.searchLocation')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
{locationLat && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
</div>
)}
</div>
{showLocationResults && locationResults.length > 0 && (
<>
<div className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg overflow-hidden max-h-[240px] overflow-y-auto">
{locationResults.map((r, i) => (
<button
key={i}
onClick={() => {
setLocationName(r.name)
setLocationLat(r.lat)
setLocationLng(r.lng)
setLocationQuery('')
setShowLocationResults(false)
setLocationResults([])
}}
className="w-full text-left px-3 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-start gap-2.5 border-b border-zinc-100 dark:border-zinc-700 last:border-0"
>
<MapPin size={13} className="text-zinc-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{r.name}</div>
{r.address && <div className="text-[11px] text-zinc-500 truncate">{r.address}</div>}
</div>
</button>
))}
</div>
</>
)}
{locationSearching && (
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
{t('journey.editor.searching')}
</div>
)}
</div>
</div>
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
<div className="flex gap-2">
{Object.entries(MOOD_CONFIG).map(([key, config]) => {
const Icon = config.icon
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 ${
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>
)
})}
</div>
</div>
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
<div className="flex flex-wrap gap-2">
{Object.entries(WEATHER_CONFIG).map(([key, config]) => {
const Icon = config.icon
const active = weather === key
return (
<button key={key} onClick={() => setWeather(active ? '' : key)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium border transition-all ${
active ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}>
<Icon size={12} />
{t(config.label)}
</button>
)
})}
</div>
</div>
</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" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
<button onClick={handleClose} 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')}
</button>
</div>
</div>
</div>
)
}
// ── Add Trip Dialog ──────────────────────────────────────────────────────
function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
journeyId: number
existingTripIds: number[]
onClose: () => void
onAdded: () => void
}) {
const { t } = useTranslation()
const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
const [search, setSearch] = useState('')
const [adding, setAdding] = useState<number | null>(null)
const toast = useToast()
useEffect(() => {
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
}, [])
const filtered = trips.filter(trip => {
if (existingTripIds.includes(trip.id)) return false
if (!search) return true
const q = search.toLowerCase()
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
})
const handleAdd = async (tripId: number) => {
setAdding(tripId)
try {
await journeyApi.addTrip(journeyId, tripId)
toast.success(t('journey.trips.tripLinked'))
onAdded()
} catch {
toast.error(t('journey.trips.linkFailed'))
} finally {
setAdding(null)
}
}
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-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.trips.linkTrip')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.trips.searchTrip')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.trips.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
<div className="max-h-[280px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
)}
{filtered.map(trip => (
<div
key={trip.id}
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
>
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
{(trip.destination || trip.start_date) && (
<div className="text-[11px] text-zinc-500 truncate">
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
</div>
)}
</div>
<button
onClick={() => handleAdd(trip.id)}
disabled={adding === trip.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
>
{adding === trip.id ? '...' : t('journey.trips.link')}
</button>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ── Contributor Invite Dialog ─────────────────────────────────────────────
function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
journeyId: number
existingUserIds: number[]
onClose: () => void
onInvited: () => void
}) {
const { t } = useTranslation()
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
const [search, setSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
const [sending, setSending] = useState(false)
const toast = useToast()
useEffect(() => {
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
}, [])
const filtered = users.filter(u => {
if (existingUserIds.includes(u.id)) return false
if (!search) return true
const q = search.toLowerCase()
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
})
const handleInvite = async () => {
if (!selectedUserId) return
setSending(true)
try {
await journeyApi.addContributor(journeyId, selectedUserId, role)
toast.success(t('journey.contributors.added'))
onInvited()
} catch {
toast.error(t('journey.contributors.addFailed'))
} finally {
setSending(false)
}
}
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-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
{/* Search */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
{/* User list */}
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<div
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
}`}
>
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
{u.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
</div>
{selectedUserId === u.id && (
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
</div>
))}
</div>
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
<button
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
{t(`journey.invite.${r}`)}
</button>
))}
</div>
</div>
</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">
<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={handleInvite}
disabled={!selectedUserId || sending}
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"
>
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
</div>
)
}
// ── Journey Settings Dialog ───────────────────────────────────────────────
// ── Journey Share Section ─────────────────────────────────────────────────
function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const toast = useToast()
useEffect(() => {
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
}, [journeyId])
const createLink = async () => {
try {
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
toast.success(t('journey.share.linkCreated'))
} catch { toast.error(t('journey.share.createFailed')) }
}
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!link) return
const updated = { ...link, [key]: !link[key] }
setLink(updated)
try {
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
}
const deleteLink = async () => {
try {
await journeyApi.deleteShareLink(journeyId)
setLink(null)
toast.success(t('journey.share.linkDeleted'))
} catch { toast.error(t('journey.share.deleteFailed')) }
}
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = () => {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) return null
return (
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
{!link ? (
<button
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Link size={14} /> {t('journey.share.createLink')}
</button>
) : (
<div className="flex flex-col gap-3">
{/* URL + Copy */}
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<Link size={13} className="text-zinc-400 flex-shrink-0" />
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
<button
onClick={copyLink}
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
</div>
{/* Permission toggles */}
<div className="flex flex-col gap-1.5">
{[
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
link[key]
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
<Icon size={13} />
{label}
{link[key] && <Check size={12} className="ml-auto" />}
</button>
))}
</div>
{/* Delete link */}
<button
onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
>
Remove share link
</button>
</div>
)}
</div>
)
}
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
journey: JourneyDetail
onClose: () => void
onSaved: () => void
onOpenInvite: () => void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(journey.title)
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
const [saving, setSaving] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const navigate = useNavigate()
const { updateJourney, deleteJourney } = useJourneyStore()
const handleSave = async () => {
setSaving(true)
try {
await updateJourney(journey.id, { title, subtitle: subtitle || null })
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setSaving(false)
}
}
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('cover', file)
try {
await journeyApi.uploadCover(journey.id, formData)
toast.success(t('journey.settings.coverUpdated'))
onSaved()
} catch {
toast.error(t('journey.settings.coverFailed'))
}
}
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [archiving, setArchiving] = useState(false)
const handleArchiveToggle = async () => {
setArchiving(true)
try {
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
await updateJourney(journey.id, { status: newStatus })
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
onSaved()
} catch {
toast.error(t('journey.settings.saveFailed'))
} finally {
setArchiving(false)
}
}
const handleDelete = async () => {
try {
await deleteJourney(journey.id)
navigate('/journey')
} catch {
toast.error(t('journey.settings.failedToDelete'))
}
}
return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
{/* Cover Image */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
<button
onClick={() => coverRef.current?.click()}
className="w-full h-28 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center gap-2 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 overflow-hidden relative"
>
{journey.cover_image ? (
<>
<img src={`/uploads/${journey.cover_image}`} className="absolute inset-0 w-full h-full object-cover opacity-50" alt="" />
<span className="relative z-10 flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.changeCover')}</span>
</>
) : (
<span className="flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.addCover')}</span>
)}
</button>
</div>
{/* Title */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.name')}</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
/>
</div>
{/* Subtitle */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.subtitle')}</label>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
/>
</div>
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
{/* Synced Trips */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.syncedTrips')}</label>
<div className="flex flex-col gap-1.5">
{journey.trips.map((trip: any) => (
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800">
<div className="w-8 h-8 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
<div className="flex-1 min-w-0">
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
<div className="text-[10px] text-zinc-500">{trip.place_count || 0} {t('journey.synced.places')}</div>
</div>
<button
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
className="w-8 h-8 rounded-lg flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
title="Unlink trip"
>
<Trash2 size={14} />
</button>
</div>
))}
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
<button
onClick={() => setShowAddTrip(true)}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Plus size={14} /> {t('journey.trips.addTrip')}
</button>
</div>
</div>
{/* Contributors */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.contributors')}</label>
<div className="flex flex-col gap-2">
{journey.contributors.map((c: any) => (
<div key={c.user_id} className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
{(c.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
{c.role !== 'owner' && (
<button
onClick={async () => {
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
try {
await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed'))
onSaved()
} catch {
toast.error(t('journey.contributors.removeFailed'))
}
}}
aria-label={t('journey.contributors.remove')}
title={t('journey.contributors.remove')}
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
>
<X size={13} />
</button>
)}
</div>
))}
<button
onClick={onOpenInvite}
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<UserPlus size={14} /> {t('journey.contributors.invite')}
</button>
</div>
</div>
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
{/* Public Share */}
<JourneyShareSection journeyId={journey.id} />
</div>
{/* Footer */}
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
onClick={() => setShowDeleteConfirm(true)}
aria-label={t('journey.settings.delete')}
title={t('journey.settings.delete')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
>
<Trash2 size={14} />
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
</button>
<button
onClick={handleArchiveToggle}
disabled={archiving}
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
title={t('journey.settings.endDescription')}
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
>
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button>
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
{/* Unlink Trip confirm */}
<ConfirmDialog
isOpen={!!unlinkTarget}
onClose={() => setUnlinkTarget(null)}
onConfirm={async () => {
if (!unlinkTarget) return
try {
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
toast.success(t('journey.trips.tripUnlinked'))
setUnlinkTarget(null)
onSaved()
} catch {
toast.error(t('journey.trips.unlinkFailed'))
}
}}
title={t('journey.trips.unlinkTrip')}
message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })}
confirmLabel={t('journey.trips.unlink')}
danger
/>
{/* Add Trip */}
{showAddTrip && (
<AddTripDialog
journeyId={journey.id}
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
onClose={() => setShowAddTrip(false)}
onAdded={() => { setShowAddTrip(false); onSaved() }}
/>
)}
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
title={t('journey.settings.deleteJourney')}
message={t('journey.settings.deleteMessage', { title: journey.title })}
confirmLabel={t('common.delete')}
danger
/>
</div>
)
}