import React, { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react' import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared' import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' import { mapsApi } from '../../api/client' import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' import { useToast } from '../shared/Toast' import { getApiErrorMessage } from '../../types' 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[] }) => Promise onCopyToTrip: () => void onRemove: () => 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, onCopyToTrip, onRemove, t, }: CollectionPlaceDetailProps): React.ReactElement { const toast = useToast() const [editing, setEditing] = useState(false) 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 [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 ?? []) // 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 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 ?? []) } 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) setSaving(true) try { await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds }) 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 && }
{place.category?.name && ( {place.category.name} )}
{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}
)} {/* Status — live for editors, read-only for viewers */} {}} t={t} /> {editing ? (
{/* Category */}
{t('collections.category')}
{categories.map(cat => { const Icon = getCategoryIcon(cat.icon ?? undefined) const on = categoryId === cat.id return ( ) })}
{/* Labels */} {labels.length > 0 && (
{t('collections.labels.title')}
{labels.map(l => { const on = labelIds.includes(l.id) return ( ) })}
)} {/* Description */}
{t('collections.description')}