Files
TREK/client/src/pages/JourneyDetailPage.tsx
T
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00

3262 lines
158 KiB
TypeScript

import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
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/JourneyMapAuto'
import { DAY_COLORS } from '../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
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, ChevronUp, 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, GalleryPhoto, JourneyTrip, 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, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
// instead of the browser's navigator.language.
return {
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
month: date.toLocaleDateString(locale, { month: 'long' }),
day: date.getDate(),
}
}
function photoUrl(p: { photo_id: number }, 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, locale } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, 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'>('timeline')
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const feedRef = useRef<HTMLDivElement>(null)
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 — the sticky map on the right follows whichever
// entry the user is currently reading in the feed on the left. We use
// scroll position (not IntersectionObserver) because short text-only
// entries pass through any IO band too quickly to reliably register.
const rafRef = useRef<number | null>(null)
const scrollCleanupRef = useRef<(() => void) | null>(null)
// Suppress scroll-sync updates while a programmatic smooth-scroll is
// running (triggered by a marker click). The scroll-progress reference
// line doesn't align with `scrollIntoView({ block: 'center' })`, so the
// sync would otherwise pick random entries as the scroll animates past
// them and end up nowhere near the clicked marker.
const suppressScrollSyncRef = useRef(false)
const suppressTimerRef = useRef<number | null>(null)
const setupScrollSync = useCallback(() => {
scrollCleanupRef.current?.()
const feed = feedRef.current
if (!feed) return
const commitWinner = () => {
if (suppressScrollSyncRef.current) return
const nodes = document.querySelectorAll('[data-entry-id]')
if (nodes.length === 0) return
const feedRect = feed.getBoundingClientRect()
// Reference line tracks scroll progress — at the top of the feed
// it sits at the top edge; at the bottom it sits at the bottom
// edge. This keeps every entry passing through the line exactly
// once even when they're too short to cross a static line before
// the feed runs out of scroll.
const maxScroll = feed.scrollHeight - feed.clientHeight
const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0
const referenceY = feedRect.top + feedRect.height * progress
let lastPast: { id: string; top: number } | null = null
let firstAhead: { id: string; top: number } | null = null
nodes.forEach(el => {
const entryId = el.getAttribute('data-entry-id')
if (!entryId) return
const top = el.getBoundingClientRect().top
if (top <= referenceY) {
if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top }
} else {
if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top }
}
})
const winner = lastPast || firstAhead
if (winner) {
setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) {
mapRef.current?.highlightMarker(winner.id)
}
}
}
const onScroll = () => {
if (rafRef.current != null) return
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null
commitWinner()
})
}
feed.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
// prime once so the map syncs on initial load
commitWinner()
scrollCleanupRef.current = () => {
feed.removeEventListener('scroll', onScroll)
window.removeEventListener('scroll', onScroll)
if (rafRef.current != null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [])
useEffect(() => {
if (current?.entries?.length) {
const t = window.setTimeout(setupScrollSync, 300)
return () => {
window.clearTimeout(t)
scrollCleanupRef.current?.()
}
}
return () => scrollCleanupRef.current?.()
}, [current?.entries, setupScrollSync])
const handleMarkerClick = useCallback((entryId: string) => {
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
if (!el) return
// Commit the choice immediately so the highlighted marker stays pinned
// to the clicked entry even while smooth-scroll passes over others.
suppressScrollSyncRef.current = true
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
// Smooth scroll typically finishes within ~500ms; 750ms gives a safety
// buffer so the sync doesn't snap back to the wrong entry on the very
// last frame.
suppressTimerRef.current = window.setTimeout(() => {
suppressScrollSyncRef.current = false
suppressTimerRef.current = null
}, 750)
}, [])
useEffect(() => () => {
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
}, [])
const handleLocationClick = useCallback((id: string) => {
setActiveLocationId(id)
}, [])
useEffect(() => {
// give the sidebar map a chance to recalc its size when the view switches
// (feed column width can shift slightly if the gallery vs timeline
// renders with a different scrollbar state).
requestAnimationFrame(() => mapRef.current?.invalidateSize())
}, [view])
// On desktop we run a two-pane layout where only the feed column scrolls;
// the body must not scroll underneath it. Restore on unmount.
useEffect(() => {
if (isMobile) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [isMobile])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
const mapEntries = useMemo(
() => (current?.entries || []).filter(e =>
e.location_lat && e.location_lng &&
e.title !== 'Gallery' &&
e.title !== '[Trip Photos]' &&
e.type !== 'skeleton'
),
[current?.entries]
)
const sidebarMapItems = useMemo(() => {
const allDates = [...new Set(
(current?.entries || [])
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
.map(e => e.entry_date)
.sort()
)]
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
const dayCounters = new Map<string, number>()
return sorted.map(e => {
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const 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 => (!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'
const showMobileGallery = isMobile && view === 'gallery'
const isMobileChromeless = showMobileCombined || showMobileGallery
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 Journey + Gallery views: back | tabs+title | settings */}
{isMobileChromeless && (
<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 justify-center">
<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 ${
view === 'timeline'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<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 ${
view === 'gallery'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<Grid size={13} />
{t('journey.share.gallery')}
</button>
</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={
isMobile
? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden'
}
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
>
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div
ref={feedRef}
className={
isMobile
? ''
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
}
>
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
<div className={`px-4 md:px-0 mb-6 ${isMobileChromeless ? 'hidden' : ''}`}>
<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">
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2">
{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>
</div>
<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 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
{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 content (was a 2-col grid with right-sidebar panels;
now single column inside the left feed — right pane is a
sticky fullscreen map further below). */}
<div className={isMobile ? 'px-4' : ''}>
<div>
{/* View Controls — hidden on mobile (floating top bar has them) */}
<div className={`flex items-center justify-between mt-5 mb-5 ${isMobileChromeless ? 'hidden' : ''}`}>
<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') },
]
).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, locale)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return (
<div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
{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, idx) => {
// Skeletons are just "suggested" places pulled
// from the linked trip — they aren't real
// journey entries until the user edits them,
// so reordering them does not make sense.
const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton'
const move = (direction: -1 | 1) => {
if (!current) return
const target = idx + direction
if (target < 0 || target >= entries.length) return
const reordered = [...entries]
const [moved] = reordered.splice(idx, 1)
reordered.splice(target, 0, moved)
reorderEntries(current.id, reordered.map(e => e.id))
.catch(() => toast.error(t('common.errorOccurred')))
}
return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
{canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button
type="button"
onClick={() => move(-1)}
disabled={idx === 0}
aria-label="Move up"
className="w-7 h-7 rounded-lg bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronUp size={14} />
</button>
<button
type="button"
onClick={() => move(1)}
disabled={idx === entries.length - 1}
aria-label="Move down"
className="w-7 h-7 rounded-lg bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronDown size={14} />
</button>
</div>
)}
<div className={canReorder ? 'flex-1 min-w-0' : ''}>
{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>
)
})}
</div>
)}
{/* Gallery View — mobile gets extra top padding so the floating top bar doesn't overlap */}
<div
className={view === 'gallery' ? '' : 'hidden'}
style={showMobileGallery ? { paddingTop: 'calc(var(--nav-h, 56px) + 64px)' } : undefined}
>
<GalleryView
entries={current.entries}
gallery={current.gallery || []}
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 ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
</div>
</div>
</div>
</div>
</div>
{/* RIGHT column on desktop — sticky rounded map (polarsteps-style).
Hidden on mobile; mobile gets its own chromeless combined view. */}
{!isMobile && (
<aside className="w-[44%] max-w-[760px] min-w-[420px] pt-6 pr-4 pb-4 pl-0">
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
/>
</div>
</aside>
)}
</div>
</div>
{/* Entry Editor */}
{editingEntry && (
<EntryEditor
entry={editingEntry}
journeyId={current.id}
tripDates={tripDates}
galleryPhotos={current.gallery || []}
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={() => { setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
/>
)}
{/* 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, locale } = 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, locale)
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">
{formatLocationName(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, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
userId: number
trips: JourneyTrip[]
onPhotoClick: (photos: GalleryPhoto[], 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 = gallery
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 {
const formData = new FormData()
for (const f of files) formData.append('photos', f)
await journeyApi.uploadGalleryPhotos(journeyId, 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 (galleryPhotoId: number) => {
const store = useJourneyStore.getState()
if (!store.current) return
// Optimistic update — remove from gallery and all entry photo lists
useJourneyStore.setState({
current: {
...store.current,
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== galleryPhotoId),
})),
},
})
try {
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch {
toast.error(t('common.error'))
onRefresh()
}
}
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, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, 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>
))}
</div>
)}
{/* Provider Photo Picker Modal */}
{showPicker && (
<ProviderPicker
provider={pickerProvider!}
userId={userId}
entries={entriesWithContent}
trips={trips}
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => {
let added = 0
for (const group of groups) {
try {
if (entryId) {
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, 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">{formatLocationName(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 && createPortal(
<>
<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>
</>,
document.body,
)}
</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">{formatLocationName(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 && createPortal(
<>
<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>
</>,
document.body,
)}
</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">
{formatLocationName(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-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<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 flex-shrink-0">
{/* 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'), short: t('common.all') },
{ 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.short ? (
<>
<span className="hidden sm:inline">{f.label}</span>
<span className="sm:hidden">{f.short}</span>
</>
) : 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 flex-shrink-0">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
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 flex-shrink-0">
<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 overscroll-contain p-4 min-h-0">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</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 flex-shrink-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</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' }) : null
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"
>
{formatted ? (
<span>{formatted}</span>
) : (
<span>
<span className="hidden sm:inline">{t('journey.picker.selectDate')}</span>
<span className="sm:hidden">{t('common.date')}</span>
</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: GalleryPhoto[]
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
onDone: () => void
}) {
const { t } = useTranslation()
const isMobile = useIsMobile()
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 | GalleryPhoto)[]>(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 availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
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]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
{/* The modal itself is constrained to the feed column on desktop so it
centers there — but the backdrop stays full-width (covering the map
too) for a uniform dim/blur across the whole page. */}
<div
className="absolute top-0 bottom-0 left-0 flex items-end sm:items-center sm:justify-center sm:p-5"
style={{ right: isMobile ? 0 : 'clamp(420px, 44vw, 760px)' }}
>
<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. Safari collapses
`aspect-square` items inside an overflow-scroll grid, so
the square is enforced with a padding-top spacer + an
absolutely positioned image (works across all browsers). */}
{showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{availableGalleryPhotos.map(gp => (
<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="relative w-full 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"
style={{ paddingTop: '100%' }}
>
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{availableGalleryPhotos.length === 0 && (
<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()
setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => 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>
</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"
>
{t('share.deleteLink')}
</button>
</div>
)}
</div>
)
}
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
journey: JourneyDetail
onClose: () => void
onSaved: () => void
onOpenInvite: () => void
onRefresh: () => void
}) {
const { t } = useTranslation()
const [title, setTitle] = useState(journey.title)
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 [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast()
const navigate = useNavigate()
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={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={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 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'))
onRefresh()
} 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={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')}
</button>
</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
/>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div>
)
}