import React, { useId, useRef } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Bookmark, Search, MapPin, MapPinned, Loader2, Trash2, Check, X } from 'lucide-react' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { AddRowButton, EditorField, GRID_2, INPUT, PANEL, Segmented, TEXTAREA } from '../shared/dialogParts' import { NumericInput } from '../shared/NumericInput' import NoteFormatToolbar from '../shared/NoteFormatToolbar' import { Tooltip } from '../shared/Tooltip' import { getCategoryIcon } from '../shared/categoryIcons' import { normalizeLinkUrl, STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import type { Category, TranslationFn } from '../../types' import type { CollectionStatus } from '@trek/shared' import { str } from './addPlaceModel' import { useAddPlaceToCollection } from './useAddPlaceToCollection' /** * The search row, pinned to the top of the scrolling body. The dialog stays open * after an add, and the row used to sit above the viewport once the form was * scrolled, so it looked like the button had vanished (#1921). The negative * margins let its background cover the body's padding while it is stuck. */ const STICKY_SEARCH = 'sticky top-0 z-20 -mx-6 -mt-5 bg-surface-card px-6 pb-1 pt-5' /** A chip of the category row; the picked one wears the category's own colour. */ const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold' const CHIP_OFF = 'border-edge-faint bg-surface-card text-content-muted hover:text-content' const CHIP_NONE_ON = 'border-content bg-surface-card text-content' const ROW_ACTION = 'grid h-8 w-8 flex-none place-items-center rounded-[9px] text-content-faint hover:bg-surface-secondary' interface AddPlaceToCollectionModalProps { isOpen: boolean collectionId: number collectionName: string categories: Category[] onClose: () => void onAdded: () => void t: TranslationFn } /** * Add a place to the current list, everything in one view: the name typed into * the head band, a search that fills it and the location in when a result is * picked, plus status, category, a markdown description and links, all editable * together before saving. Stays open after each add so several places can be * added in a row. */ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collectionName, categories, onClose, onAdded, t }: AddPlaceToCollectionModalProps): React.ReactElement { const searchRef = useRef(null) const { query, setQuery, results, searching, noResults, setNoResults, dismissResults, name, setName, address, setAddress, lat, setLat, lng, setLng, categoryId, setCategoryId, description, setDescription, links, setLinks, setLink, status, setStatus, saving, search, pick, coordPaste, save, } = useAddPlaceToCollection({ variant: 'dialog', open: isOpen, collectionId, collectionName, t, onClose, onAdded, normalizeLinkUrl, // The dialog stays open for the next place, so hand the caret back to the // search field. The add button the user just clicked goes disabled with the // cleared name, which drops the focus to and leaves the dialog dead // to the keyboard (#1921). afterAdd: () => searchRef.current?.focus(), }) const descRef = useRef(null) const labelId = useId() const fieldId = useId() const header = ( } tint={NEUTRAL_TINT} labelId={labelId} onClose={onClose} eyebrow={t('collections.addPlace')} // Typed into the band, as in the planner's place dialog. It takes no focus // on opening: the dialog starts at the search, which fills the name in. titleInput={{ value: name, onChange: setName, label: t('common.name'), placeholder: t('common.name') }} sub={<>{collectionName}} /> ) const footer = ( {t('common.cancel')} void save()} disabled={saving || !name.trim()} icon={saving ? : } > {t('common.add')} ) return ( {/* Search: picking a result fills the name and the location below. */}
void search()} disabled={!query.trim() || searching} icon={searching ? : } > {t('common.search')}
{(results.length > 0 || noResults) && (
{t('common.search')}
{noResults ? (
{t('planner.noPlacesFound')}
) : results.map((r, i) => ( ))}
)}
{/* Where it is: filled in by a picked result, but also typeable, so a place can be added by GPS alone (#1435). */}
setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT} />
label={t('mobileCollections.status')} value={status} onChange={setStatus} options={STATUS_ORDER.map(s => { const Icon = STATUS_META[s].icon return { value: s, label: t(STATUS_META[s].labelKey), icon: } })} /> {categories.length > 0 && (
{categories.map(cat => { const Icon = getCategoryIcon(cat.icon ?? undefined) const on = categoryId === cat.id const col = cat.color || '#6366f1' // theme-lint-disable: the category's own colour, and the default one without a colour is drawn in return ( ) })}
)} {t('collections.description')}} action={} >