mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
This commit is contained in:
@@ -572,7 +572,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
@@ -717,8 +717,8 @@ const MemoMarker = memo(function MemoMarker({
|
||||
},
|
||||
remove: () => { cleanupRef.current?.(); cleanupRef.current = null },
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseover: (e: any) => onHover({ ...place, photo: placePhotoFull(place) }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover({ ...place, photo: placePhotoFull(place) }, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
@@ -1009,9 +1009,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom uploaded image wins over the auto-fetched thumb; otherwise fall back.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const photoUrl = placePhotoUrl(place, photoUrls)
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
return (
|
||||
<MemoMarker
|
||||
@@ -1369,6 +1367,7 @@ export const MapView = memo(function MapView({
|
||||
categoryColor={hoveredPlace.category_color}
|
||||
address={hoveredPlace.address}
|
||||
rating={hoveredPlace.rating_avg}
|
||||
photo={hoveredPlace.photo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
|
||||
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
@@ -1849,9 +1849,7 @@ export function MapViewGL({
|
||||
|
||||
visiblePlaces.forEach(place => {
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = photoCacheKey(place)
|
||||
// A custom image wins over the auto-fetched thumb; otherwise fall back to it.
|
||||
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((pck && photoUrls[pck]) || place.image_url || null)
|
||||
const photoUrl = placePhotoUrl(place, photoUrls)
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
|
||||
@@ -1870,7 +1868,7 @@ export function MapViewGL({
|
||||
el.addEventListener('mouseenter', (ev) => {
|
||||
if (hoverDisabledRef.current || camMovingRef.current) return
|
||||
hoverIdRef.current = place.id
|
||||
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
||||
setHoverPlace({ ...(place as Place & { category_color?: string; category_icon?: string; category_name?: string }), photo: placePhotoFull(place) })
|
||||
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
||||
})
|
||||
el.addEventListener('mousemove', (ev) => {
|
||||
@@ -2447,7 +2445,8 @@ export function MapViewGL({
|
||||
categoryIcon={hoverPlace.category_icon}
|
||||
categoryColor={hoverPlace.category_color}
|
||||
address={hoverPlace.address}
|
||||
rating={hoverPlace.rating_avg}
|
||||
rating={hoverPlace.rating_avg}
|
||||
photo={hoverPlace.photo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Star } from 'lucide-react'
|
||||
import { MapPin, Star } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { tintOf } from '../Planner/planParts'
|
||||
|
||||
/**
|
||||
* What a place is, shown at the cursor while the pointer rests on its marker.
|
||||
@@ -24,48 +26,41 @@ export interface PlaceHoverCardProps {
|
||||
address?: string | null
|
||||
/** Average across everyone who rated it, when the surface tracks ratings. */
|
||||
rating?: number | null
|
||||
/** The picture its marker wears; the card shows it in place of the category tile. */
|
||||
photo?: string | null
|
||||
}
|
||||
|
||||
export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon, categoryColor, address, rating }: PlaceHoverCardProps): React.ReactElement {
|
||||
const CatIcon = categoryName ? getCategoryIcon(categoryIcon) : null
|
||||
export default function PlaceHoverCard({ x, y, name, categoryName, categoryIcon, categoryColor, address, rating, photo }: PlaceHoverCardProps): React.ReactElement {
|
||||
// The tile wears the category the way the planner's rows do; without one it is a quiet pin.
|
||||
const CatIcon = categoryName ? getCategoryIcon(categoryIcon) : MapPin
|
||||
const tone = categoryName ? categoryColor || 'var(--text-muted)' : null
|
||||
return (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: x + 14,
|
||||
top: y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'var(--font-system)',
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{name}
|
||||
<div data-testid="tooltip"
|
||||
className="flex max-w-[300px] items-center gap-3 rounded-[18px] border border-edge-faint bg-surface-card p-1.5 pr-4 font-system shadow-popover"
|
||||
style={{ position: 'fixed', left: x + 14, top: y - 10, zIndex: 9999, pointerEvents: 'none' }}>
|
||||
{/* Twelve inside eighteen with six of padding: the picture's corners follow the card's. */}
|
||||
{photo ? (
|
||||
<img src={photo} alt="" className="block h-14 w-14 flex-none rounded-[12px] bg-surface-tertiary object-cover" />
|
||||
) : (
|
||||
<span className="grid h-14 w-14 flex-none place-items-center rounded-[12px]"
|
||||
style={{ background: tone ? tintOf(tone, 14) : 'var(--bg-tertiary)' }}>
|
||||
<CatIcon size={18} strokeWidth={2} style={{ color: tone || 'var(--text-faint)' }} />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate font-semibold text-content" style={fs(13, 'body')}>{name}</span>
|
||||
{typeof rating === 'number' && rating > 0 && (
|
||||
<span className="inline-flex flex-none items-center gap-0.5 font-geist tabular-nums text-content-secondary" style={fs(11)}>
|
||||
<Star size={10} strokeWidth={2} className="fill-current text-warning" aria-hidden />
|
||||
{/* One decimal only when it earns it: "4" reads faster than "4.0". */}
|
||||
{Number.isInteger(rating) ? rating : rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof rating === 'number' && rating > 0 && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, flexShrink: 0, fontSize: 11, fontWeight: 600, color: '#111827' }}>
|
||||
<Star size={10} style={{ fill: '#f59e0b', color: '#f59e0b' }} aria-hidden />
|
||||
{/* One decimal only when it earns it: "4" reads faster than "4.0". */}
|
||||
{Number.isInteger(rating) ? rating : rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{categoryName && <span className="truncate text-content-muted" style={fs(11)}>{categoryName}</span>}
|
||||
{address && <span className="truncate text-content-faint" style={fs(11)}>{address}</span>}
|
||||
</div>
|
||||
{categoryName && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: categoryColor || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{categoryName}</span>
|
||||
</div>
|
||||
)}
|
||||
{address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ export interface MapHoverInfo {
|
||||
/** Everyone's average, where the surface keeps ratings. On the collections map it
|
||||
* is the one thing a round photo cannot tell you: which of these you liked. */
|
||||
rating_avg?: number | null
|
||||
/** The picture its marker wears, so the hover card can show the same one. */
|
||||
photo?: string | null
|
||||
routeVia?: RouteVia
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import { getCached } from '../../services/photoService'
|
||||
|
||||
const PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'
|
||||
|
||||
@@ -49,6 +50,30 @@ export function photoSourcesKey(places: Array<PhotoSource & { id: number }>): st
|
||||
return places.map(p => `${p.id}:${isCustomPlaceImage(p.image_url) ? 'own' : photoCacheKey(p)}`).join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture a place wears on the map: a custom uploaded image wins over the
|
||||
* auto-fetched thumb, which wins over whatever image_url says. The marker and the
|
||||
* hover card both ask this, so they never show two different pictures.
|
||||
*/
|
||||
export function placePhotoUrl(place: PhotoSource, thumbs: Record<string, string>): string | null {
|
||||
if (isCustomPlaceImage(place.image_url)) return place.image_url!
|
||||
const key = photoCacheKey(place)
|
||||
return (key && thumbs[key]) || place.image_url || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same picture at full size, for the hover card. The marker's thumb is a 48px
|
||||
* circle cut for a round pin, which blurs and keeps its round cut-out when it is
|
||||
* shown any larger, so the card asks the photo cache for the picture it came from.
|
||||
*/
|
||||
export function placePhotoFull(place: PhotoSource): string | null {
|
||||
if (isCustomPlaceImage(place.image_url)) return place.image_url!
|
||||
const key = photoCacheKey(place)
|
||||
const full = key ? getCached(key)?.photoUrl : null
|
||||
if (full) return full
|
||||
return place.image_url?.startsWith(PHOTO_PROXY_PREFIX) ? place.image_url : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture inside a round place marker, for both the Leaflet and the GL map.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user