diff --git a/client/.gitignore b/client/.gitignore index 11215320b..2530b230e 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -3,3 +3,6 @@ e2e/.tmp/ test-results/ playwright-report/ playwright/.cache/ + +# vite-plugin-pwa dev output (devOptions.enabled) +dev-dist/ diff --git a/client/src/App.tsx b/client/src/App.tsx index 75c323e90..d73be86a7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -27,8 +27,10 @@ import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' import SaveToCollectionModal from './components/Collections/SaveToCollectionModal' +import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet' import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' -import BottomNav from './components/Layout/BottomNav' +import MobileShell from './mobile/MobileShell' +import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' @@ -53,6 +55,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR const addonStore = useAddonStore() const { t } = useTranslation() const location = useLocation() + const isPhone = useIsPhone() if (isLoading) { return ( @@ -87,12 +90,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR return } - return ( -
-
{children}
- -
- ) + // Below the md breakpoint the new mobile shell owns chrome (tokens, dock, + // sheets, toasts); from 768px up the legacy wrapper stays untouched. The + // shell branches internally so pages keep their state when the viewport + // crosses the breakpoint. + return {children} } function RootRedirect() { @@ -200,6 +202,7 @@ export default function App() { } }, [settings.dark_mode, settings.appearance, isSharedPage]) + const isPhone = useIsPhone() const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register') || location.pathname.startsWith('/forgot-password') @@ -210,7 +213,7 @@ export default function App() { {!isAuthPage && } {!isAuthPage && } - {!isAuthPage && } + {!isAuthPage && (isPhone ? : )} } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d2f716143..d572b98e5 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -624,6 +624,50 @@ export interface PluginMapMarker { tone: 'default' | 'success' | 'warn' | 'danger' } +/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized: + * coordinates range-checked, vertex budget capped, styling clamped to the tone + * palette + bounded numerics — never free-form CSS or markup. */ +export interface PluginMapLayerFeature { + type: 'polyline' | 'polygon' | 'circle'; + points?: Array<[number, number]>; + center?: [number, number]; + radiusM?: number; + tone: 'default' | 'success' | 'warn' | 'danger'; + width: number; + dash: 'solid' | 'dash' | 'dot'; + opacity: number; + fill: boolean; + label?: string; +} + +/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */ +export interface PluginMapLayer { + pluginId: string; id: string; name?: string; + features: PluginMapLayerFeature[]; +} + +/** A time contribution a dayScheduleProvider plugin attaches to the day plan + * ("35 min charging at this stop"). Server-normalized: dayIds checked against + * the trip, minutes clamped to a day, labels sanitized + capped. */ +export interface PluginDayScheduleItem { + pluginId: string; id: string; dayId: number; + assignmentId?: number; reservationId?: number; + position?: 'start' | 'end'; + minutes?: number; label: string; + tone: 'default' | 'success' | 'warn' | 'danger'; +} + +/** A route computed by a routeProvider plugin (server-normalized: coordinates + * range-checked, legs forced to waypoints-1, vias capped). null = provider failed + * or refused — the caller falls back to straight lines like on an OSRM outage. */ +export interface PluginRouteResult { + pluginId: string; profile: string; + coordinates: Array<[number, number]>; + distance: number; duration: number; + legs: Array<{ distance: number; duration: number; note?: string }>; + viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>; +} + /** A text-only section a pdfSectionProvider plugin appends to the trip PDF export. * Server-normalized: counts + lengths are capped, cells are plain strings. */ export interface PluginPdfSection { @@ -667,6 +711,19 @@ export const pluginsApi = { // (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers). mapMarkers: (tripId: number | string) => apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }), + // Vector overlays (polylines/polygons/circles) plugins draw on the trip map via + // the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe. + mapLayers: (tripId: number | string) => + apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }), + // Route the given waypoints through ONE routeProvider plugin profile (targeted, + // not a fan-out — the user picked this profile in the route toggle). Slow by + // design (external solvers): the server allows the plugin 20 s. + pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) => + apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }), + // Time contributions plugins attach to the day plan via the dayScheduleProvider + // hook (charging stops, security buffers). Host-normalized; fail-safe. + daySchedule: (tripId: number | string) => + apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }), // Text-only sections plugins append to the trip PDF export via the // pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe. pdfSections: (tripId: number | string) => diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index d14b64654..2d0baa95b 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -5,7 +5,7 @@ import { ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, - Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' @@ -188,6 +188,7 @@ const PERM_KEYS = [ 'events:subscribe', 'jobs:run', 'ws:broadcast:trip', 'ws:broadcast:user', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', + 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read', 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound', ] @@ -239,6 +240,10 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) + if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) + if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') }) + if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 715b24a12..7bbaf6c93 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -21,6 +21,7 @@ import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' import { NumericInput } from '../shared/NumericInput' +import EmptyState from '../shared/EmptyState' export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record { const n = members.length @@ -467,9 +468,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {dayBanner} {dayGroups.length === 0 ? ( -
- {search ? t('costs.noMatch') : t('costs.emptyText')} -
+ search ? ( +
+ {t('costs.noMatch')} +
+ ) : ( + + ) ) : dayGroups.map(g => { const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return ( diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index 924361f79..34f6339e6 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -124,9 +124,13 @@ describe('CollabChat', () => { expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); }); - it('FE-COMP-CHAT-009: shows hint text in empty state', async () => { + it('FE-COMP-CHAT-009: shows guidance in empty state', async () => { render(); - await screen.findByText(/Share ideas, plans/i); + // The empty state now renders the shared EmptyState: a chat-scene mascot + // plus the single "Start the conversation" title (the separate hint + // paragraph was dropped in the mobile rewrite). + await screen.findByText('Start the conversation'); + expect(document.querySelector('svg.trek--chat')).toBeInTheDocument(); }); it('FE-COMP-CHAT-010: chat container renders', () => { diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx index 6c5abab98..1fe7b658a 100644 --- a/client/src/components/Collab/CollabChatMessages.tsx +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -1,10 +1,11 @@ import React from 'react' -import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react' +import { Trash2, Reply, ChevronUp } from 'lucide-react' import { URL_REGEX } from './CollabChat.constants' import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers' import { MessageText } from './CollabChatMessageText' import { LinkPreview } from './CollabChatLinkPreview' import { ReactionBadge } from './CollabChatReactionBadge' +import EmptyState from '../shared/EmptyState' export function ChatMessages(props: any) { const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props @@ -12,11 +13,7 @@ export function ChatMessages(props: any) { <> {/* Messages */} {messages.length === 0 ? ( -
- - {t('collab.chat.empty')} - {t('collab.chat.emptyDesc') || ''} -
+ ) : (
{sortedNotes.length === 0 ? ( /* ── Empty state ── */ -
- -
- {t('collab.notes.empty')} -
-
- {t('collab.notes.emptyDesc') || 'Create a note to get started'} -
-
+ ) : ( /* ── Notes grid — 2 columns ── */
{polls.length === 0 ? ( -
- -
{t('collab.polls.empty')}
-
{t('collab.polls.emptyHint')}
-
+ ) : (
{activePolls.length > 0 && activePolls.map(poll => ( diff --git a/client/src/components/Collab/WhatsNextWidget.tsx b/client/src/components/Collab/WhatsNextWidget.tsx index 14a9cfcf6..fad2d3d68 100644 --- a/client/src/components/Collab/WhatsNextWidget.tsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -3,7 +3,8 @@ import { avatarSrc } from '../../utils/avatarSrc' import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react' +import { MapPin, Clock, Users, Sparkles } from 'lucide-react' +import EmptyState from '../shared/EmptyState' function formatTime(timeStr, is12h) { if (!timeStr) return '' @@ -100,11 +101,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro {/* List */}
{upcoming.length === 0 ? ( -
- -
{t('collab.whatsNext.empty')}
-
{t('collab.whatsNext.emptyHint')}
-
+ ) : (
{upcoming.map((item, idx) => { diff --git a/client/src/components/Collections/MSaveToCollectionSheet.tsx b/client/src/components/Collections/MSaveToCollectionSheet.tsx new file mode 100644 index 000000000..621e2b0ce --- /dev/null +++ b/client/src/components/Collections/MSaveToCollectionSheet.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react' +import MSheet from '../../mobile/components/MSheet' +import MIconBtn from '../../mobile/components/MIconBtn' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { collectionsApi } from '../../api/collections' +import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' +import { getApiErrorMessage } from '../../utils/apiError' +import type { Collection, CollectionMembership } from '@trek/shared' + +/** + * Mobile counterpart of SaveToCollectionModal — the same store-driven list + * picker (load lists + membership, toggle the place in/out of each), dressed in + * the mobile design language (MSheet card, m-* tokens) so it matches the place + * detail sheet. Rendered instead of the desktop modal on phones (see App.tsx). + */ +export default function MSaveToCollectionSheet() { + const target = useSaveToCollectionStore(s => s.target) + const close = useSaveToCollectionStore(s => s.close) + const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion) + const { t } = useTranslation() + const toast = useToast() + const navigate = useNavigate() + + const [lists, setLists] = useState([]) + const [membership, setMembership] = useState(null) + const [loading, setLoading] = useState(false) + const [busyId, setBusyId] = useState(null) + + const membershipQuery = useMemo(() => { + if (!target) return null + return { + google_place_id: target.google_place_id ?? undefined, + google_ftid: target.google_ftid ?? undefined, + name: target.name, + lat: target.lat ?? undefined, + lng: target.lng ?? undefined, + } + }, [target]) + + const refreshMembership = useCallback(async () => { + if (!membershipQuery) return + try { + setMembership(await collectionsApi.membership(membershipQuery)) + } catch { + setMembership({ saved: false, lists: [] }) + } + }, [membershipQuery]) + + // Load lists + membership whenever the picker opens for a new target. + useEffect(() => { + if (!target) return + let cancelled = false + setLoading(true) + setMembership(null) + Promise.all([ + collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), + membershipQuery + ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] })) + : Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }), + ]) + .then(([listRes, m]) => { + if (cancelled) return + setLists(listRes.collections) + setMembership(m) + }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]) + + const savedByCollection = new Map() + for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id) + + const handleToggle = async (list: Collection) => { + if (busyId != null || !target) return + const savedPlaceId = savedByCollection.get(list.id) + setBusyId(list.id) + try { + if (savedPlaceId != null) { + await collectionsApi.deletePlace(savedPlaceId) + toast.success(t('collections.removedFromList', { name: list.name })) + } else { + await collectionsApi.savePlace({ + collection_id: list.id, + source_trip_id: target.source_trip_id ?? null, + source_place_id: target.source_place_id ?? null, + name: target.name, + description: target.description ?? null, + lat: target.lat ?? null, + lng: target.lng ?? null, + address: target.address ?? null, + category_id: target.category_id ?? null, + price: target.price ?? null, + currency: target.currency ?? null, + notes: target.notes ?? null, + image_url: target.image_url ?? null, + google_place_id: target.google_place_id ?? null, + google_ftid: target.google_ftid ?? null, + osm_id: target.osm_id ?? null, + website: target.website ?? null, + phone: target.phone ?? null, + force: true, + }) + toast.success(t('collections.addedToList', { name: list.name })) + } + await refreshMembership() + bumpVersion() + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setBusyId(null) + } + } + + return ( + + {/* Header — mirrors the place detail sheet: icon + title + target name + close */} +
+
+ + + +
+
{t('collections.pickList')}
+ {target?.name && ( +
{target.name}
+ )} +
+ + + +
+
+ +
+ {loading ? ( +
+ +
+ ) : lists.length === 0 ? ( +
+ + + +

{t('collections.noListsYet')}

+ +
+ ) : ( + lists.map(list => { + const saved = savedByCollection.has(list.id) + const busy = busyId === list.id + return ( + + ) + }) + )} +
+ + {lists.length > 0 && ( +
+ + +
+ )} +
+ ) +} diff --git a/client/src/components/Files/FileManagerFilesView.tsx b/client/src/components/Files/FileManagerFilesView.tsx index 0caf64e90..377db7102 100644 --- a/client/src/components/Files/FileManagerFilesView.tsx +++ b/client/src/components/Files/FileManagerFilesView.tsx @@ -1,8 +1,9 @@ import { Fragment } from 'react' -import { Upload, FileText, Star } from 'lucide-react' +import { Upload, Star } from 'lucide-react' import type { FileManagerState } from './useFileManager' import { FileRow } from './FileManagerRow' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' +import EmptyState from '../shared/EmptyState' export function FilesView(S: FileManagerState) { const { @@ -66,11 +67,7 @@ export function FilesView(S: FileManagerState) { {/* File list */}
{filteredFiles.length === 0 ? ( -
- -

{t('files.empty')}

-

{t('files.emptyHint')}

-
+ ) : (
{filteredFiles.map(file => { diff --git a/client/src/components/Journey/JourneyDetailPageChips.tsx b/client/src/components/Journey/JourneyDetailPageChips.tsx index 2028a03c4..fcc33a98d 100644 --- a/client/src/components/Journey/JourneyDetailPageChips.tsx +++ b/client/src/components/Journey/JourneyDetailPageChips.tsx @@ -7,8 +7,8 @@ export function MoodChip({ mood }: { mood: string }) { if (!config) return null const Icon = config.icon return ( -
- +
+ {t(config.label)}
) @@ -20,8 +20,8 @@ export function WeatherChip({ weather }: { weather: string }) { if (!config) return null const Icon = config.icon return ( -
- +
+ {t(config.label)}
) diff --git a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx index 179d4a566..836ab4893 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryCard.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { createPortal } from 'react-dom' -import { MapPin, Clock, MoreHorizontal, Pencil, Trash2 } from 'lucide-react' +import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react' import { formatLocationName } from '../../utils/formatters' import { useTranslation } from '../../i18n' import { pluginsApi } from '../../api/client' @@ -45,7 +45,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { const hasProscons = prosArr.length > 0 || consArr.length > 0 return ( -
+
{/* Hero area: photos with title overlay */} {photos.length > 0 ? ( @@ -185,22 +185,25 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick? return (
-
- +
+
-
+
{entry.title || t('journey.detail.newEntry')}
-
+
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
-
- {t('journey.detail.addEntry')} → -
+ {onClick && ( + + {t('journey.detail.addEntry')} + + )}
) } diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 514912873..d28f5c037 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -5,7 +5,6 @@ import { type ResilientResult, type UploadProgress } from '../../utils/uploadQue 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' @@ -25,7 +24,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos }) { 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]) @@ -139,20 +137,21 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos 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)} @@ -166,7 +165,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
-
+