import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { markdownLinkComponents } from '../shared/markdownLink' import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2, Navigation } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared' import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' import { NumericInput } from '../shared/NumericInput' import { mapsApi } from '../../api/client' import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' import { NavigationMenu } from '../shared/NavigationMenu' import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation' import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' import PlaceRating from '../shared/StarRating' import { normalizeImageFile } from '../../utils/convertHeic' import { getApiErrorMessage } from '../../utils/apiError' function linkHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } } interface CollectionPlaceDetailProps { place: CollectionPlace canEdit: boolean canDelete: boolean categories: Category[] /** The active list's custom labels, for the assign chips. */ labels: CollectionLabel[] /** When set, dock the sheet over that column (desktop split) instead of centred. */ anchorRect?: { left: number; width: number } | null onClose: () => void onSetStatus: (status: CollectionStatus) => void onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null }) => Promise /** Upload a custom cover image (#1136); enables the cover change/remove controls. */ onUploadImage?: (file: File) => Promise onCopyToTrip: () => void onRemove: () => void /** Cast/clear the current user's star vote (#1435); every member may vote. */ onRate?: (rating: number | null) => Promise | void t: TranslationFn } function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: (s: CollectionStatus) => void; t: TranslationFn }): React.ReactElement { return (
{STATUS_ORDER.map(s => { const Icon = STATUS_META[s].icon const on = status === s return ( ) })}
) } /** * Bottom detail sheet for a saved place — an opaque, clearly-sectioned card * (cover → meta → status → description → links) docked over the list column. * Read mode renders the description as markdown + link chips; edit mode swaps in * name / category / markdown description / links, saving via updatePlace. Status * is an always-live segmented control (auto-saves). */ export default function CollectionPlaceDetail({ place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t, }: CollectionPlaceDetailProps): React.ReactElement { const toast = useToast() const [editing, setEditing] = useState(false) const coverInputRef = useRef(null) const [imgBusy, setImgBusy] = useState(false) const [navOpen, setNavOpen] = useState(false) const navBtnRef = useRef(null) const navigationTargets = getNavigationTargets(place) const [name, setName] = useState(place.name) const [categoryId, setCategoryId] = useState(place.category_id ?? null) const [description, setDescription] = useState(place.description ?? '') const [links, setLinks] = useState(place.links ?? []) const [labelIds, setLabelIds] = useState(place.label_ids ?? []) const [address, setAddress] = useState(place.address ?? '') const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '') const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '') const [saving, setSaving] = useState(false) // A higher-res photo pulled from the maps provider when the place has none of // its own — the list avatar's little thumbnail is too low-res for the cover. const [fetchedPhoto, setFetchedPhoto] = useState(null) const descRef = useRef(null) // Reset only when a DIFFERENT place is opened (keyed on id, not on every field). useEffect(() => { setEditing(false) setName(place.name) setCategoryId(place.category_id ?? null) setDescription(place.description ?? '') setLinks(place.links ?? []) setLabelIds(place.label_ids ?? []) setAddress(place.address ?? '') setLat(place.lat != null ? String(place.lat) : '') setLng(place.lng != null ? String(place.lng) : '') // eslint-disable-next-line react-hooks/exhaustive-deps }, [place.id]) // Fetch a cover photo when the place doesn't carry its own image. useEffect(() => { setFetchedPhoto(null) if (place.image_url) return const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null) if (!photoId) return let cancelled = false mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name) .then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) }) .catch(() => {}) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [place.id]) const banner = place.image_url || fetchedPhoto const handleCoverPick = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] e.target.value = '' if (!file || !onUploadImage) return setImgBusy(true) try { await onUploadImage(await normalizeImageFile(file)) } catch (err) { toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) } finally { setImgBusy(false) } } const handleImageRemove = async () => { setImgBusy(true) try { await onSave({ image_url: null }) } catch (err) { toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) } finally { setImgBusy(false) } } const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id]) const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') } const coordPaste = (e: React.ClipboardEvent) => { const text = e.clipboardData.getData('text').trim() const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/) if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id)) const save = async () => { const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) const latNum = lat.trim() ? Number(lat) : Number.NaN const lngNum = lng.trim() ? Number(lng) : Number.NaN setSaving(true) try { await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null }) setEditing(false) } catch (err) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { setSaving(false) } } const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined const CatIcon = getCategoryIcon(place.category?.icon) return (
e.stopPropagation()}>
{banner && }
{/* Chip and cover controls share the top bar. The controls keep the end and never shrink, so a long category name ellipsizes instead of sliding under them, however many buttons there are and whatever size phones give them. */}
{place.category?.name && ( {place.category.name} )} {/* The cover controls wear the close button's class, so they get its look, hover and phone size. */}
{canEdit && onUploadImage && ( <> {place.image_url && !imgBusy && ( )} )}
{editing ? setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} /> :

{place.name}

}
{/* Meta (view only) */} {!editing && place.address && (
{place.address && {place.address}} {/* Same picker as inside a trip (#1455). A saved place is somewhere you intend to go, and until now the only way to get directions was to add it to a trip first. */}
)} {/* Status — live for editors, read-only for viewers */} {}} t={t} /> {/* Collaborative rating (#1435) — every member votes; the average shows. */} {/* Rating and labels on one line, each on its own ground. A bare line of stars read as another row of text, and the labels tried the cover photo first — where a blue chip on a blue sky is simply not there. Side by side they are two badges answering two questions about the place. */} {(onRate || assignedLabels.length > 0) && (
{onRate && (
)} {!editing && assignedLabels.map(l => ( {l.name} ))}
)} {editing ? (
{/* Category */}
{t('collections.category')}
{categories.map(cat => { const Icon = getCategoryIcon(cat.icon ?? undefined) const on = categoryId === cat.id return ( ) })}
{/* Address (#1870): free text, the coordinates stay separate */}
{t('places.formAddress')}
setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className="col-detail-input" />
{/* Coordinates */}
{t('collections.coordinates')}
{/* Labels */} {labels.length > 0 && (
{t('collections.labels.title')}
{labels.map(l => { const on = labelIds.includes(l.id) return ( ) })}
)} {/* Description */}
{t('collections.description')}