import { useState, useRef } from 'react' import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' import { useTranslation } from '../../i18n' import { journeyApi, mapsApi } from '../../api/client' import { useToast } from '../shared/Toast' import { useIsMobile } from '../../hooks/useIsMobile' import { getApiErrorMessage } from '../../types' import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../store/journeyStore' import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import MarkdownToolbar from './MarkdownToolbar' import { DatePicker } from './JourneyDetailPageDatePicker' export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: { entry: JourneyEntry journeyId: number tripDates: Set galleryPhotos: GalleryPhoto[] onClose: () => void onSave: (data: Record) => Promise onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise> onDone: () => void }) { const { t } = useTranslation() const toast = useToast() 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(entry.location_lat ?? null) const [locationLng, setLocationLng] = useState(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 | null>(null) const [mood, setMood] = useState(entry.mood || '') const [weather, setWeather] = useState(entry.weather || '') const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [saving, setSaving] = useState(false) const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [pendingFiles, setPendingFiles] = useState([]) const [pendingLinkIds, setPendingLinkIds] = useState([]) const [showGalleryPick, setShowGalleryPick] = useState(false) const fileRef = useRef(null) const storyRef = useRef(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 filesToUpload = pendingFiles setUploadProgress({ done: 0, total: filesToUpload.length }) try { const { failed } = await onUploadPhotos(entryId, filesToUpload, { onProgress: p => setUploadProgress({ done: p.done, total: p.total }), }) setPendingFiles(failed) if (failed.length > 0) { toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) })) } } catch (err) { toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed'))) } finally { setUploadProgress(null) } } // 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) => { 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. const normalized = await normalizeImageFiles(files) setPendingFiles(prev => [...prev, ...normalized]) } return (
{/* 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. */}

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

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" />
{ (e.target as HTMLInputElement).value = '' }} className="hidden" />
{galleryPhotos.length > 0 && ( )}
{/* 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 && (
{availableGalleryPhotos.map(gp => (
{ 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%' }} > { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
))} {availableGalleryPhotos.length === 0 && (
{t('journey.editor.allPhotosAdded')}
)}
)} {(photos.length > 0 || pendingFiles.length > 0) && (
{photos.map((p, idx) => (
1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}> { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> {idx === 0 && photos.length > 1 && ( {t('journey.editor.photoFirst')} )} {idx > 0 && photos.length > 1 && ( )}
))} {pendingFiles.map((f, i) => (
))}
)}