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 && (
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"
>
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'
}`}
>
{t('journey.detail.journeyTab') || 'Journey'}
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'
}`}
>
{t('journey.share.gallery')}
{canEditJourney ? (
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"
>
) : (
)}
)}
{/* 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 && (
)}
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"
>
{/* 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')}
)}
{ 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">
{
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 ? : }
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
{canEditJourney && (
setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25">
)}
{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 => (
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.label}
))}
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && (
{
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' : ''}`}
>
)}
{/* 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 && (
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"
>
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"
>
)}
{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)}
/>
)}
)
}