import { useAuthStore } from '../store/authStore' import { journeyApi } from '../api/client' import Navbar from '../components/Layout/Navbar' import JourneyMap from '../components/Journey/JourneyMapAuto' import { DAY_COLORS } from '../components/Journey/dayColors' import PhotoLightbox from '../components/Journey/PhotoLightbox' import ContributorInviteDialog from '../components/Journey/ContributorInviteDialog' import ConfirmDialog from '../components/shared/ConfirmDialog' import { ArrowLeft, MoreHorizontal, Download, List, Grid, MapPin, Plus, BookOpen, ChevronUp, ChevronDown, Eye, EyeOff, } from 'lucide-react' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileEntryView from '../components/Journey/MobileEntryView' import { useJourneyStore } from '../store/journeyStore' import type { JourneyEntry } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { useJourneyDetail } from './journeyDetail/useJourneyDetail' import { pickGradient, groupByDate, formatDate, photoUrl } from './journeyDetail/JourneyDetailPage.helpers' import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard' import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView' import { EntryEditor } from '../components/Journey/JourneyDetailPageEntryEditor' import { AddTripDialog } from '../components/Journey/JourneyDetailPageAddTripDialog' import { JourneySettingsDialog } from '../components/Journey/JourneyDetailPageSettingsDialog' export default function JourneyDetailPage() { // Page = wiring container: load + live sync, view state, dialogs, the // scroll-synced map and the map/trip-date derivations live in the hook. const { id, navigate, toast, t, locale, current, loading, canEditEntries, canEditJourney, myRole, view, setView, activeEntryId, setActiveEntryId, feedRef, viewingEntry, setViewingEntry, editingEntry, setEditingEntry, lightbox, setLightbox, deleteTarget, setDeleteTarget, showInvite, setShowInvite, showAddTrip, setShowAddTrip, unlinkTrip, setUnlinkTrip, showSettings, setShowSettings, hideSkeletons, setHideSkeletons, mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick, mapEntries, sidebarMapItems, tripDates, isMobile, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto, } = useJourneyDetail() if (loading || !current) { return (
) } 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 (
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */} {showMobileCombined && ( 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 && ( 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 && (
{canEditJourney ? ( ) : (
)}
)}
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
{current.cover_image && (
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
{lifecycle !== 'live' && lifecycle !== 'archived' && (
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
)} {lifecycle === 'archived' && (
{t('journey.status.archived')}
)}
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
{canEditJourney && ( )}

{current.title}

{current.subtitle &&

{current.subtitle}

}
{[ { 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 => (
{s.value} {s.label}
))}
{/* 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). */}
{/* View Controls — hidden on mobile (floating top bar has them) */}
{(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 => ( ))}
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && ( )}
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */} {!isMobile && (
{sortedDates.length === 0 && (

No entries yet

Add a trip to get started with skeleton entries

)} {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 (
{dayIdx + 1}

{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

{entries.length} {t('journey.synced.places')}
{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 (
{ 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 && (
)}
{entry.type === 'skeleton' ? ( setEditingEntry(entry) : undefined} /> ) : entry.type === 'checkin' ? ( setEditingEntry(entry) : undefined} /> ) : ( 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 })} /> )}
) })}
) })}
)} {/* Gallery View — mobile gets extra top padding so the floating top bar doesn't overlap */}
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))} />
{/* RIGHT column on desktop — sticky rounded map (polarsteps-style). Hidden on mobile; mobile gets its own chromeless combined view. */} {!isMobile && ( )}
{/* Entry Editor */} {editingEntry && ( 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, files, cbs) => { return await uploadPhotos(entryId, files, cbs) }} onDone={() => { setEditingEntry(null) loadJourney(Number(id)) }} /> )} {/* Journey Settings */} {showSettings && ( setShowSettings(false)} onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} onOpenInvite={() => { setShowInvite(true) }} onRefresh={() => loadJourney(Number(id))} /> )} {/* Add Trip Dialog */} {showAddTrip && current && ( t.trip_id)} onClose={() => setShowAddTrip(false)} onAdded={() => { setShowAddTrip(false); loadJourney(Number(id)) }} /> )} {/* Contributor Invite Dialog */} {showInvite && ( c.user_id)} onClose={() => setShowInvite(false)} onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }} /> )} {/* Delete confirm */} 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 */} 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 && ( ({ 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)} /> )}
) }